RoadManager/RoadManager.cs

Scene-wide RoadManager component that builds a drivable lane graph from RoadComponent and RoadIntersectionComponent, streams NPC traffic and pedestrians, spawns/despawns networked vehicle prefabs, and draws editor/play gizmos and overlays.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace RedSnail.RoadTool;

/// <summary>
/// Scene-wide traffic director. It scans every <see cref="RoadComponent"/> and <see cref="RoadIntersectionComponent"/>,
/// works out how they connect (the same proximity test "Snap Nearby Roads" uses), and builds a drivable lane graph.
/// In play mode it spawns vehicle-prefab instances that drive the network; in the editor it draws the computed layout.
/// </summary>
[Icon("directions_car")]
public sealed partial class RoadManager : Component, Component.ExecuteInEditor, IHotloadManaged
{
	private const float TrafficStreamInterval = 0.25f; // seconds between streaming passes (despawn strays, top up near players)
	private const int MaxSpawnsPerTick = 5;            // cap cars spawned per pass so a fresh area fills in gradually, not in one burst

	[SkipHotload] public static RoadManager Current { get; private set; } = null;
	
	[Property, Feature("Traffic", Icon = "traffic"), Category("Vehicles")] private VehicleSetResource VehicleSet { get; set; }
	[Property(Title = "Default Speed"), Feature("Traffic"), Category("Vehicles"), Range(5.0f, 130.0f)] private float DefaultSpeed { get; set; } = 30.0f;
	[Property(Title = "Following Gap"), Feature("Traffic"), Category("Vehicles"), Range(50.0f, 600.0f)] private float VehicleSpacing { get; set; } = 180.0f;
	[Property(Title = "Spawn Gap"), Feature("Traffic"), Category("Vehicles"), Range(50.0f, 1000.0f)] private float SpawnGap { get; set; } = 250.0f;
	[Property(Title = "Stop Margin"), Feature("Traffic"), Category("Vehicles"), Range(0.0f, 500.0f)] private float StopMargin { get; set; } = 150.0f;

	[Property(Title = "Density"), Feature("Traffic"), Category("Streaming"), Range(0.0f, 1.0f)] public float TrafficDensity { get; set; } = 0.15f;
	[Property(Title = "Clustering"), Feature("Traffic"), Category("Streaming"), Range(0.0f, 1.0f)] private float TrafficClustering { get; set; } = 0.0f;
	[Property(Title = "Spawn Min Range"), Feature("Traffic"), Category("Streaming"), Range(0.0f, 20000.0f)] private float TrafficSpawnMinDistance { get; set; } = 3000.0f;
	[Property(Title = "Spawn Range"), Feature("Traffic"), Category("Streaming"), Range(500.0f, 20000.0f)] private float TrafficSpawnDistance { get; set; } = 5000.0f;
	[Property(Title = "Despawn Range"), Feature("Traffic"), Category("Streaming"), Range(500.0f, 20000.0f)] private float TrafficDespawnDistance { get; set; } = 7000.0f;
	[Property(Title = "Height Offset"), Feature("Traffic"), Category("Streaming"), Range(0.0f, 300.0f)] private float SpawnHeightOffset { get; set; } = 0.0f;
	
	[Property(Title = "Brake For Tags"), Feature("Traffic"), Category("Awareness")] private TagSet AwarenessTags { get; set; }
	[Property(Title = "Detect Radius"), Feature("Traffic"), Category("Awareness"), Range(10.0f, 200.0f)] private float AwarenessRadius { get; set; } = 100.0f;

	[Property(Title = "Lose Patience After"), Feature("Traffic"), Category("Road Rage"), Range(1.0f, 120.0f)] private float LoosePatience { get; set; } = 10.0f;
	[Property(Title = "Road Rage Duration"), Feature("Traffic"), Category("Road Rage"), Range(1.0f, 120.0f)] private float RoadRageDuration { get; set; } = 5.0f;

	[Property, Feature("Traffic"), Category("Layout"), Range(50.0f, 500.0f)] private float WaypointSpacing { get; set { field = value.Clamp(10.0f, 10000.0f); m_IsDirty = true; } } = 150.0f;
	[Property(Title = "Connection Distance"), Feature("Traffic"), Category("Layout"), Range(20.0f, 600.0f)] private float LinkThreshold { get; set { field = value; m_IsDirty = true; } } = 200.0f;

	[Property, Feature("Debug", Icon = "bug_report", Tint = EditorTint.Red)] private bool ShowGizmos { get; set; } = true;
	[Property, Feature("Debug")] public bool ShowOverlays { get; set; } = true;

	[Property, Feature("Parking Lot", Icon = "local_parking", Tint = EditorTint.Yellow)] public float ParkedVehicleDespawnDistance { get; set; } = 6000.0f;
	[Property, Feature("Parking Lot")] public float ParkedVehicleRespawnDistance { get; set; } = 4000.0f;

	/// <summary>
	/// Optional override for how the library locates players. Set this from your game to feed players in from your own
	/// (faster) source — e.g. a static player list — and the library queries it instead of scanning the scene. Leave it
	/// null and the default applies: every object carrying the "player" tag. The default is universal — it never depends
	/// on a specific player component — but it walks the whole scene each call, so plug this in for big maps / many spots.
	/// </summary>
	public static Func<IEnumerable<GameObject>> FindPlayers { get; set; }

	/// <summary>
	/// Optional override for how the tool drives a vehicle as NPC traffic. Set this from your game to hook your OWN car
	/// controller in WITHOUT touching the road tool (and without your vehicle code referencing this library): given a
	/// spawned vehicle GameObject, return a <see cref="RoadVehicleDriver"/> wired to its controller, or null if it isn't
	/// a drivable physics car (the tool then moves it kinematically on rails). This is the single seam between the
	/// traffic AI and a vehicle's physics/controller. Leave it null and the default applies — it wires the included
	/// <see cref="DemoCarController"/> so the demo works out of the box.
	/// </summary>
	public static Func<GameObject, RoadVehicleDriver> ResolveVehicleDriver { get; set; }
	
	public static Action<GameObject> OnBeforeParkedVehicleSpawn { get; set; }

	/// <summary>
	/// Raised on the host for each traffic vehicle once it's spawned, networked and wired to its lane.
	///
	/// The seam for whatever a GAME wants on its traffic that the tool has no business knowing about — a
	/// driver sat in the seat, a livery, a siren, a delivery load. AFTER setup rather than before, so the
	/// object is already network-spawned and its seats exist: anything parented here replicates normally.
	/// </summary>
	public static Action<GameObject> OnTrafficVehicleSpawned { get; set; }

	/// <summary>
	/// The lane graph: every road and intersection lane in the scene, with its waypoints, width, speed limit and
	/// connections. Built on demand, so it's safe to ask for at any point in the frame.
	///
	/// Exposed because the layout is useful well beyond traffic — placing a delivery at a junction, a pedestrian on
	/// the pavement (offset a waypoint by <see cref="TrafficLane.RoadHalfWidth"/> to clear the carriageway), a patrol
	/// route, a map overlay. Building it needs nothing but the scene, so a client can ask too, even though only the
	/// host drives traffic.
	///
	/// Treat it as read-only: the manager owns it and throws it away whenever the layout changes.
	/// </summary>
	public RoadTrafficGraph Graph
	{
		get
		{
			if (m_Graph is null)
				RebuildGraph();

			return m_Graph;
		}
	}

	/// <summary>The <see cref="Graph"/> of the active manager, or null when there isn't one in the scene.</summary>
	public static RoadTrafficGraph GetRoadGraph()
	{
		return Current.IsValid() ? Current.Graph : null;
	}

	private RoadTrafficGraph m_Graph;
	private readonly List<TrafficVehicle> m_Vehicles = [];
	private readonly List<(TrafficLane Lane, int Index)> m_SpawnSlots = []; // candidate spawn points along the lanes, rebuilt with the graph
	private bool m_IsDirty = true;
	private float m_TrafficStreamCooldown;



	private RoadTrafficSettings Settings => new()
	{
		WaypointSpacing = WaypointSpacing,
		LinkThreshold = LinkThreshold
	};
	
	
	
	protected override void OnAwake()
	{
		Current = this;
	}
	
	
	
	void IHotloadManaged.Destroyed(Dictionary<string, object> _State)
	{
		_State["IsActive"] = Current == this;
	}



	void IHotloadManaged.Created(IReadOnlyDictionary<string, object> _State)
	{
		if (_State.GetValueOrDefault("IsActive") is true)
			Current = this;
	}
	
	
	
	protected override void OnEnabled()
	{
		m_IsDirty = true;
	}



	protected override void OnDisabled()
	{
		RemoveVehicles();
		RemovePedestrians();
		m_Graph = null;
		m_SpawnSlots.Clear();
		m_PedestrianSlots.Clear();
	}
	
	
	
	protected override void OnDestroy()
	{
		if (Current == this)
			Current = null;
	}
	
	
	
	protected override void OnUpdate()
	{
		if (SandboxUtility.IsInPlayMode)
		{
			// The RoadManager object is networked (Orphaned: Host), so it runs on clients too — but the traffic is owned
			// entirely by the host: it builds the graph, spawns and despawns. A proxy just receives the networked cars.
			if (IsProxy)
				return;

			// The host needs the lane graph before it can spawn anything; build it once.
			if (m_Graph is null)
				RebuildGraph();

			// GTA-style streaming: despawn cars that have drifted away from every player, and spawn new ones at empty
			// lane slots in a ring around players, up to the density target.
			StreamTraffic();

			// Same idea on the pavements. Separate pass on its own interval — people and cars thin out at
			// different rates, and a street with no traffic should still have someone walking down it.
			StreamPedestrians();

			// Debug
			DrawLayoutDebugOverlay();

			return;
		}

		// Editor: keep the gizmo layout in sync when a layout property changes.
		if (m_IsDirty)
		{
			RebuildGraph();
			m_IsDirty = false;
		}
	}



	/// <summary>
	/// Every player in the scene. Comes from <see cref="FindPlayers"/> when a game has plugged in its own (faster)
	/// source, otherwise the default universal scan for objects tagged "player". Iterate this for distance checks so a
	/// game's override applies to ALL callers (parking spots, etc.) at once, instead of each one scanning the scene.
	/// </summary>
	public static IEnumerable<GameObject> GetPlayers()
	{
		if (FindPlayers is null)
		{
			var players = new List<GameObject>();

			foreach (var player in Game.ActiveScene.FindAllWithTag("player"))
			{
				// We ignore all child gameobjects of the player
				if (player.Parent.IsValid() && player.Parent.Tags.Has("player"))
					continue;
				
				players.Add(player);
			}
			
			return players;
		}
		
		return FindPlayers();
	}



	/// <summary>True if any player is within <paramref name="_Distance"/> of <paramref name="_Point"/> — built on <see cref="GetPlayers"/>, so a game's override is honored here too.</summary>
	public static bool ArePlayersWithin(Vector3 _Point, float _Distance)
	{
		float distanceSq = _Distance * _Distance;

		foreach (GameObject player in GetPlayers())
		{
			if (player.WorldPosition.DistanceSquared(_Point) < distanceSq)
				return true;
		}

		return false;
	}



	/// <summary>
	/// The control surface the traffic AI drives for <paramref name="_Vehicle"/>. Comes from
	/// <see cref="ResolveVehicleDriver"/> when a game has hooked its own controller, otherwise the built-in default:
	/// wire the included <see cref="DemoCarController"/> if present. Returns null when the vehicle has no drivable
	/// controller — the brain then falls back to lightweight on-rails movement.
	/// </summary>
	public static RoadVehicleDriver GetVehicleDriver(GameObject _Vehicle)
	{
		if (ResolveVehicleDriver is not null)
			return ResolveVehicleDriver(_Vehicle);

		// Default: wire the demo car so the included demo works with no game-side hook. A real game sets
		// ResolveVehicleDriver to map its own controller instead — that controller never references this library.
		var demo = _Vehicle.GetComponent<DemoCarController>();

		if (demo is null)
			return null;

		return new RoadVehicleDriver
		{
			IsPlayerDriving = () => demo.IsDriven,
			Velocity = () => demo.Rigidbody.IsValid() ? demo.Rigidbody.Velocity : Vector3.Zero,
			SetAiControlled = _Ai => demo.IsAiControlled = _Ai,
			Drive = (_Throttle, _Steer, _Handbrake) =>
			{
				demo.AiThrottle = _Throttle;
				demo.AiSteer = _Steer;
				demo.AiHandbrake = _Handbrake;
			},
			MaxSteering = demo.GetMaxSteering
		};
	}
	
	
	
	// GTA-style streaming (host only, throttled). Two passes: cull cars that have drifted out of every player's range,
	// then top traffic back up to the density target by spawning at empty lane slots in a ring around the players.
	private void StreamTraffic()
	{
		m_TrafficStreamCooldown -= Time.Delta;

		if (m_TrafficStreamCooldown > 0.0f)
			return;

		m_TrafficStreamCooldown = TrafficStreamInterval;

		// Grab the player positions once for this whole pass.
		var players = new List<Vector3>();

		foreach (GameObject player in GetPlayers())
		{
			if (player.IsValid())
				players.Add(player.WorldPosition);
		}

		DespawnStrayVehicles(players);

		if (players.Count > 0 && m_SpawnSlots.Count > 0 && VehicleSet.IsValid() && VehicleSet.HasVehicles)
			TopUpTraffic(players);
	}



	// Per-vehicle despawn: each car is judged by ITS OWN distance to the nearest player, so only the ones that have
	// genuinely drifted out of range disappear. The car the player last drove ("last_vehicle") is never culled.
	private void DespawnStrayVehicles(List<Vector3> _Players)
	{
		float despawnSq = TrafficDespawnDistance * TrafficDespawnDistance;

		for (int i = m_Vehicles.Count - 1; i >= 0; i--)
		{
			TrafficVehicle vehicle = m_Vehicles[i];

			if (!vehicle.IsValid())
			{
				m_Vehicles.RemoveAt(i);
				continue;
			}

			// The car the player last drove, or one a game has claimed for something of its own — a pursuit, a
			// mission convoy. Both stay; only their owner decides when they're finished with.
			if (vehicle.GameObject.Tags.Has("last_vehicle") || vehicle.GameObject.Tags.Has("keep_alive"))
				continue;

			if (NearestDistanceSq(vehicle.WorldPosition, _Players) > despawnSq)
			{
				var entityFade = vehicle.GetComponent<EntityFade>();

				if (entityFade.IsValid())
				{
					entityFade.FadeOutAndDestroyBroadcasted();
				}
				else
				{
					vehicle.DestroyGameObject();
				}
				
				m_Vehicles.RemoveAt(i);
			}
		}
	}
	
	
	
	// Tops the area up to the density target by spawning at empty slots inside the spawn ring around players. Density is
	// a fraction of the road capacity currently near a player, so "1" packs the nearby roads and "0.1" leaves them nearly
	// empty. Spawns are capped per pass so a fresh area fills in over a second or two rather than in one burst.
	private void TopUpTraffic(List<Vector3> _Players)
	{
		float despawnSq = TrafficDespawnDistance * TrafficDespawnDistance;
		float minSq = TrafficSpawnMinDistance * TrafficSpawnMinDistance;
		float maxSq = TrafficSpawnDistance * TrafficSpawnDistance;
		float clearanceSq = SpawnGap * SpawnGap;

		// Capacity = slots near a player; target = that × density; deficit = how far below target we are right now.
		int capacity = 0;

		foreach (var slot in m_SpawnSlots)
		{
			if (NearestDistanceSq(slot.Lane.Waypoints[slot.Index], _Players) <= despawnSq)
				capacity++;
		}

		int live = 0;

		foreach (var vehicle in m_Vehicles)
		{
			if (vehicle.IsValid() && NearestDistanceSq(vehicle.WorldPosition, _Players) <= despawnSq)
				live++;
		}

		int deficit = Math.Min((int)(capacity * TrafficDensity) - live, MaxSpawnsPerTick);

		if (deficit <= 0)
			return;

		// Walk the slots in a clustering-controlled order, spawning at any that sit in the ring (near enough to matter,
		// far enough not to pop in) and aren't already occupied by another car.
		var order = BuildSpawnOrder();

		for (int n = 0; n < order.Count && deficit > 0; n++)
		{
			var slot = m_SpawnSlots[order[n]];
			Vector3 pos = slot.Lane.Waypoints[slot.Index];
			float nearestSq = NearestDistanceSq(pos, _Players);

			if (nearestSq < minSq || nearestSq > maxSq)
				continue;

			if (IsAreaOccupied(pos, clearanceSq))
				continue;

			SpawnVehicleAt(slot.Lane, slot.Index);
			deficit--;
		}
	}



	// The order the streaming pass tries spawn slots in, which decides how "grouped" traffic looks. The slot list is
	// laid out lane-by-lane (every slot of lane A, then lane B...), so walking it in order packs whole lanes into long
	// nose-to-tail platoons. TrafficClustering controls how much of that ordering we keep: at 1 we walk the whole list
	// in sequence (the original platoons), at 0 we fully shuffle so each spawn lands on an unrelated lane and traffic
	// scatters across the network. In between we keep short contiguous "runs" (little groups) but deliver the runs in
	// random order, so you get a few small clusters spread over many lanes.
	private List<int> BuildSpawnOrder()
	{
		int n = m_SpawnSlots.Count;
		var order = new List<int>(n);

		if (n == 0)
			return order;

		// Run length scales with clustering: a single slot (fully scattered) at 0, the whole list (one lane-by-lane
		// platoon) at 1.
		int run = Math.Max(1, (int)MathF.Round(MathX.Lerp(1.0f, n, TrafficClustering)));

		// Rotate by a random offset so we don't always favour the same lanes.
		int start = Game.Random.Next(n);

		// Split the rotated list into contiguous runs.
		var runs = new List<int>(); // each entry is a run's start offset into the rotated list

		for (int offset = 0; offset < n; offset += run)
			runs.Add(offset);

		// Shuffle the run order (Fisher-Yates) so groups arrive scattered; within a run we keep order so it stays a group.
		for (int i = runs.Count - 1; i > 0; i--)
		{
			int j = Game.Random.Next(i + 1);
			(runs[i], runs[j]) = (runs[j], runs[i]);
		}

		foreach (int offset in runs)
		{
			int len = Math.Min(run, n - offset);

			for (int k = 0; k < len; k++)
				order.Add((start + offset + k) % n);
		}

		return order;
	}



	private bool IsAreaOccupied(Vector3 _Point, float _RadiusSq)
	{
		foreach (var vehicle in m_Vehicles)
		{
			if (vehicle.IsValid() && vehicle.WorldPosition.DistanceSquared(_Point) < _RadiusSq)
				return true;
		}

		return false;
	}



	private static float NearestDistanceSq(Vector3 _Point, List<Vector3> _Players)
	{
		float best = float.MaxValue;

		foreach (Vector3 player in _Players)
			best = MathF.Min(best, player.DistanceSquared(_Point));

		return best;
	}



	private void DrawLayoutDebugOverlay()
	{
		if (!ShowOverlays)
			return;
		
		Vector3 offset = Vector3.Up * 25;
		
		foreach (var lane in m_Graph.Lanes)
		{
			if (lane.Waypoints.Count < 2)
				continue;

			Color color = lane.IsRoadLane ? Color.Cyan : new Color(0.4f, 1.0f, 0.4f);
			
			for (int i = 0; i < lane.Waypoints.Count - 1; i++)
			{
				Vector3 a = lane.Waypoints[i] + offset;
				Vector3 b = lane.Waypoints[i + 1] + offset;
				
				DebugOverlay.Line(a, b, color);
			}
			
			// Contained dead-end "U-turn" arc.
			if (lane.UTurnArc is { Count: >= 2 })
			{
				color = new Color(1.0f, 0.6f, 0.1f);

				for (int i = 0; i < lane.UTurnArc.Count - 1; i++)
				{
					Vector3 ua = lane.UTurnArc[i] + offset;
					Vector3 ub = lane.UTurnArc[i + 1] + offset;

					DebugOverlay.Line(ua, ub, color);
				}
			}
		}

		// Joins and dead ends first, so a link can never sit on top of a lane and hide what colour it is.

		// Where pavement lanes actually JOIN, in magenta. A visible gap between two yellow lines says nothing
		// on its own — routing only cares whether they're linked, and a short magenta bridge across a gap
		// means they are. No magenta where two lines nearly touch is the real fault.
		foreach (var (from, to) in m_Graph.GetSidewalkLinks())
			DebugOverlay.Line(from + offset, to + offset, Color.Magenta);

		// And in red, every pavement end that joins onto NOTHING. Red where two pavements meet is the break;
		// red at the edge of the map is just the edge of the map.
		foreach (var end in m_Graph.GetSidewalkDeadEnds())
			DebugOverlay.Sphere(new Sphere(end + offset, 40.0f), Color.Red);

		// Pavements in yellow, crossings in orange. No direction arrows: a pedestrian lane is a line to stand
		// on and walk along in either direction, so unlike a traffic lane it has no travel direction to show.
		foreach (var lane in m_Graph.SidewalkLanes)
		{
			if (lane.Waypoints.Count < 2)
				continue;

			Color color = lane.IsCrossing ? new Color(1.0f, 0.6f, 0.1f) : Color.Yellow;

			for (int i = 0; i < lane.Waypoints.Count - 1; i++)
			{
				Vector3 a = lane.Waypoints[i] + offset;
				Vector3 b = lane.Waypoints[i + 1] + offset;

				DebugOverlay.Line(a, b, color);
			}
		}
	}
	
	
	
	// TODO: Uncomment this when my VehicleController library will be publicly available
	// [InfoBox("It's heavily recommend to use my VehicleController library with this tool or building your own vehicle physics/controller.", "info", EditorTint.Yellow)]
	[Button("Rebuild Pathfinding Layout"), Feature("Traffic"), Order(10)]
	public void RebuildPathfindingLayout()
	{
		RebuildGraph();

		if (SandboxUtility.IsInPlayMode)
		{
			// Drop the current fleet; streaming re-populates it on the fresh graph next pass (around any nearby player).
			RemoveVehicles();
			RemovePedestrians();
			m_TrafficStreamCooldown = 0.0f;
			m_PedestrianStreamCooldown = 0.0f;
		}

		m_IsDirty = false;

		int roads = m_Graph?.RoadCount ?? 0;
		int intersections = m_Graph?.IntersectionCount ?? 0;
		int lanes = m_Graph?.Lanes.Count ?? 0;
		int pavements = m_Graph?.SidewalkLanes.Count ?? 0;
		int links = m_Graph?.SidewalkLinkCount ?? 0;
		int deadEnds = m_Graph?.GetSidewalkDeadEnds().Count() ?? 0;

		// Pavement segments AND joins AND dead ends: a big segment count with no joins is a pile of unconnected
		// pieces, which looks identical to a working network until something tries to walk it.
		SandboxUtility.ShowEditorNotification(
			$"Traffic layout rebuilt — {roads} roads, {intersections} intersections, {lanes} lanes, {pavements} pavements ({links} joins, {deadEnds} dead ends)");
	}



	private void RebuildGraph()
	{
		m_Graph = RoadTrafficGraph.Build(Scene, Settings);
		BuildSpawnSlots();
		BuildPedestrianSlots();
	}



	// (Re)builds the list of candidate spawn points — a waypoint index every few steps along every road lane, so two
	// cars never land on the same spot. Rebuilt with the graph; the streaming pass spawns at these on demand.
	private void BuildSpawnSlots()
	{
		m_SpawnSlots.Clear();

		if (m_Graph is null)
			return;

		// Prefer road lanes so cars start out on actual roads, not mid-intersection.
		var lanes = m_Graph.Lanes.Where(l => l.IsRoadLane && l.Waypoints.Count >= 2).ToList();

		if (lanes.Count == 0)
			lanes = m_Graph.Lanes.Where(l => l.Waypoints.Count >= 2).ToList();

		int step = Math.Max(1, (int)MathF.Ceiling(SpawnGap / MathF.Max(1.0f, WaypointSpacing)));

		foreach (var lane in lanes)
		{
			for (int idx = 0; idx < lane.Waypoints.Count - 1; idx += step)
				m_SpawnSlots.Add((lane, idx));
		}
	}



	// Spawns one networked traffic car at a slot and wires up its brain. Host only (called from the streaming pass).
	private void SpawnVehicleAt(TrafficLane _Lane, int _Index)
	{
		TrafficVehicle vehicle = SpawnTrafficVehicle(VehicleSet.PickRandomPrefab(), _Lane, _Index);

		// Raised HERE rather than inside the spawn, because the event means "the streaming produced a traffic
		// car" — an anonymous one, for the game to dress. A car the game asked for by name is not that: it
		// already has the object, and firing this at it would hand its own vehicle back to whatever fills in
		// traffic. Which, for anything with a driver seat, means a civilian sitting in it before the caller can
		// put anyone there.
		if (vehicle.IsValid())
			OnTrafficVehicleSpawned?.Invoke(vehicle.GameObject);
	}



	/// <summary>
	/// Puts a specific prefab on the road as a fully wired traffic vehicle, driving from a given lane.
	///
	/// For a game that needs a car the STREAMING wouldn't have produced — a police pursuit, an ambulance, a
	/// scripted convoy — but that should drive like everything else. Going through here rather than spawning it
	/// yourself is what gets the neighbour list, the spacing, the awareness tags and the height offset, all of
	/// which are the manager's settings rather than the prefab's; a hand-spawned car has none of them and drives
	/// like it.
	///
	/// Point it somewhere with <see cref="TrafficVehicle.Route"/> once you have it. Tag the object
	/// <c>keep_alive</c> if the game owns its lifetime, or the streaming pass will despawn it by distance along
	/// with everything else.
	///
	/// Deliberately does NOT raise <see cref="OnTrafficVehicleSpawned"/>. That event is for cars the streaming
	/// produced, which a game dresses on the way past — an anonymous vehicle needing a driver, a livery, a load.
	/// This one isn't anonymous: you asked for it, you have it, and you decide what goes in it. Firing the event
	/// would hand your car to whatever populates traffic and put a civilian in the seat before you could.
	/// </summary>
	public TrafficVehicle SpawnTrafficVehicle(GameObject _Prefab, TrafficLane _Lane, int _Index)
	{
		if (!Networking.IsHost || !_Prefab.IsValid() || _Lane is null || _Index < 0 || _Index >= _Lane.Waypoints.Count)
			return null;

		Vector3 spawnPos = _Lane.Waypoints[_Index] + Vector3.Up * SpawnHeightOffset;

		GameObject clone = _Prefab.Clone(spawnPos, Rotation.Identity, Vector3.One);

		if (!clone.IsValid())
			return null;

		clone.NetworkSpawn(Connection.Host);
		clone.Network.SetOrphanedMode(NetworkOrphaned.Host);
		clone.Network.SetOwnerTransfer(OwnerTransfer.Request);
		
		// The prefab is just the visual/body — the manager attaches the driver and points it at its lane.
		var vehicle = clone.GetOrAddComponent<TrafficVehicle>();
		vehicle.DefaultSpeed = DefaultSpeed * TrafficMath.KmhToUnits;
		vehicle.HeightOffset = SpawnHeightOffset;
		vehicle.Spacing = VehicleSpacing;
		vehicle.StopMargin = StopMargin;
		vehicle.LoosePatience = LoosePatience;
		vehicle.RoadRageDuration = RoadRageDuration;
		vehicle.Neighbors = m_Vehicles;
		vehicle.AwareTags = AwarenessTags;
		vehicle.DetectRadius = AwarenessRadius;
		vehicle.Initialize(m_Graph, _Lane, _Index, Game.Random.Next());

		var renderer = clone.GetComponent<ModelRenderer>();
		
		if (renderer.IsValid())
		{
			// Give it a cool random tint (If the vehicle doesn't have the tag 'notint', add this special tag if you don't want the color of the vehicle to be randomly chosen at spawn)
			if (!vehicle.Tags.Has("notint"))
				renderer.Tint = new Color(Game.Random.NextSingle(), Game.Random.NextSingle(), Game.Random.NextSingle(), renderer.Tint.a);
			
			// Properly place the vehicle on the ground (if it's a physic based vehicle)
			var rb = clone.GetComponent<Rigidbody>();

			if (rb.IsValid())
			{
				// Bottom of the vehicle in world space
				float bottomZ = renderer.Bounds.Mins.z;

				// Target ground height
				float groundZ = spawnPos.z;

				// Offset required to place bottom on ground
				float offsetZ = groundZ - bottomZ;

				Vector3 position = _Lane.Waypoints[_Index];
				position.z = clone.WorldPosition.z + offsetZ + 10.0f;
			
				clone.WorldPosition = position;	
			}
		}

		Network.Refresh(renderer);

		// Tracked either way, because the list is also what every vehicle uses to see its neighbours — a car
		// that isn't in it gets driven into by everything else. Whether it's DESPAWNED from here is a separate
		// question, answered by the keep_alive tag in the streaming pass.
		m_Vehicles.Add(vehicle);

		return vehicle;
	}
	
	
	
	private void RemoveVehicles()
	{
		foreach (var vehicle in m_Vehicles.ToArray())
			vehicle.DestroyGameObject();
		
		m_Vehicles.Clear();
	}



	protected override void DrawGizmos()
	{
		if (!ShowGizmos || !Gizmo.IsSelected || m_Graph is null)
			return;

		Gizmo.Draw.LineThickness = 2.0f;

		foreach (var lane in m_Graph.Lanes)
		{
			if (lane.Waypoints.Count < 2)
				continue;

			Gizmo.Draw.Color = lane.IsRoadLane ? Color.Cyan : new Color(0.4f, 1.0f, 0.4f);

			for (int i = 0; i < lane.Waypoints.Count - 1; i++)
			{
				Vector3 a = WorldTransform.PointToLocal(lane.Waypoints[i]);
				Vector3 b = WorldTransform.PointToLocal(lane.Waypoints[i + 1]);
				Gizmo.Draw.Line(a, b);
			}

			// Direction arrow at the lane end.
			Vector3 end = WorldTransform.PointToLocal(lane.EndPos);
			Vector3 dir = WorldRotation.Inverse * lane.EndDir;
			Gizmo.Draw.Arrow(end - dir * 80.0f, end);

			// Contained dead-end U-turn arc.
			if (lane.UTurnArc is { Count: >= 2 })
			{
				Gizmo.Draw.Color = new Color(1.0f, 0.6f, 0.1f);

				for (int i = 0; i < lane.UTurnArc.Count - 1; i++)
				{
					Vector3 ua = WorldTransform.PointToLocal(lane.UTurnArc[i]);
					Vector3 ub = WorldTransform.PointToLocal(lane.UTurnArc[i + 1]);
					Gizmo.Draw.Line(ua, ub);
				}
			}
		}

		// Joins and dead ends go UNDERNEATH the lanes, so a link can never sit on top of a lane and hide what
		// colour it is. That mistake costs an afternoon: a magenta line lying exactly along a crossing reads as
		// a crossing that was never generated.

		// Where pavement lanes actually JOIN, in magenta. A visible gap between two yellow lines says nothing
		// on its own — routing only cares whether they're linked, and a magenta bridge across a gap means they
		// are. No magenta where two lines nearly touch is the real fault. The ring is there because a perfect
		// join is a zero-length line: invisible, and indistinguishable from no join at all.
		Gizmo.Draw.Color = Color.Magenta;

		foreach (var (from, to) in m_Graph.GetSidewalkLinks())
		{
			Vector3 a = WorldTransform.PointToLocal(from);
			Vector3 b = WorldTransform.PointToLocal(to);

			Gizmo.Draw.Line(a, b);
			Gizmo.Draw.LineCircle((a + b) * 0.5f, Vector3.Up, 24.0f);
		}

		// And in red, every pavement end that joins onto NOTHING. Red where two pavements meet is the break;
		// red at the edge of the map is just the edge of the map.
		Gizmo.Draw.Color = Color.Red;

		foreach (var end in m_Graph.GetSidewalkDeadEnds())
			Gizmo.Draw.LineCircle(WorldTransform.PointToLocal(end), Vector3.Up, 40.0f);

		// Pavements in yellow, crossings in orange. No direction arrows: a pedestrian lane is a line to stand
		// on and walk along in either direction, so unlike a traffic lane it has no travel direction to show.
		foreach (var lane in m_Graph.SidewalkLanes)
		{
			if (lane.Waypoints.Count < 2)
				continue;

			Gizmo.Draw.Color = lane.IsCrossing ? new Color(1.0f, 0.6f, 0.1f) : Color.Yellow;

			for (int i = 0; i < lane.Waypoints.Count - 1; i++)
			{
				Vector3 a = WorldTransform.PointToLocal(lane.Waypoints[i]);
				Vector3 b = WorldTransform.PointToLocal(lane.Waypoints[i + 1]);
				Gizmo.Draw.Line(a, b);
			}
		}

		DrawSpeedLabels();
	}



	// Floating "NN km/h" labels above each road's midpoint and each intersection's center.
	private void DrawSpeedLabels()
	{
		Gizmo.Draw.Color = Color.White;

		foreach (var road in Scene.GetAll<RoadComponent>())
		{
			if (!road.IsValid() || road.ExcludeTraffic || road.Spline is null)
				continue;

			Vector3 mid = road.WorldTransform.PointToWorld(road.Spline.SampleAtDistance(road.Spline.Length * 0.5f).Position);
			Vector3 local = WorldTransform.PointToLocal(mid + Vector3.Up * 160.0f);
			Gizmo.Draw.Text($"{road.SpeedLimit:0} km/h", new Transform(local));
		}

		foreach (var intersection in Scene.GetAll<RoadIntersectionComponent>())
		{
			if (!intersection.IsValid() || intersection.ExcludeTraffic)
				continue;

			Vector3 local = WorldTransform.PointToLocal(intersection.WorldPosition + Vector3.Up * 160.0f);
			Gizmo.Draw.Text($"{intersection.SpeedLimit:0} km/h", new Transform(local));
		}
	}
}