Code/RoadIntersectionComponent/RoadIntersectionComponent.TrafficLights.cs

Traffic lights functionality for a RoadIntersectionComponent. Manages creating, positioning, grouping and cycling of traffic light GameObjects based on intersection shape, driving side and placement system, and posts ITrafficLightEvents when lights change.

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

namespace RedSnail.RoadTool;

public enum DrivingSide
{
	/// <summary>
	/// Drive on the right side (USA, Europe, etc...)
	/// </summary>
	Right,

	/// <summary>
	/// Drive on the left side (UK, Japan, etc...)
	/// </summary>
	Left
}

public enum TrafficLightSystem
{
	/// <summary>
	/// Traffic lights placed near the intersection (before crossing)
	/// </summary>
	European,

	/// <summary>
	/// Traffic lights placed far from the intersection (after crossing)
	/// </summary>
	US
}

/// <summary>
/// Scene event fired when a traffic light changes state. Wire up to the prefab's own component to
/// drive the actual light visuals / signals.
/// </summary>
public interface ITrafficLightEvents : ISceneEvent<ITrafficLightEvents>
{
	void OnTrafficLightGoesRed() { }
	void OnTrafficLightGoesGreen() { }
}

public partial class RoadIntersectionComponent
{
	private bool m_DoesTrafficLightsNeedRebuild = false;

	// Each spawned light paired with its phase group (0 = North/South axis, 1 = East/West axis).
	private readonly List<(GameObject Obj, int Group)> m_TrafficLightObjects = new();
	private int m_TrafficPhase = 0;
	private float m_TrafficPhaseTimer = 0.0f;

	[Property, FeatureEnabled("Traffic Lights", Icon = "traffic", Tint = EditorTint.Red), Change] public bool HasTrafficLights { get; set; } = false;
	[Property(Title = "Prefab"), Feature("Traffic Lights")] public GameObject TrafficLightPrefab { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } }
	[Property, Feature("Traffic Lights")] private DrivingSide DrivingSystem { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } } = DrivingSide.Right;
	[Property(Title = "Placement System"), Feature("Traffic Lights")] private TrafficLightSystem TrafficLightPlacementSystem { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } } = TrafficLightSystem.European;
	[Property(Title = "Offset From Road (X)"), Feature("Traffic Lights"), Range(-200.0f, 200.0f)] private float TrafficLightOffsetFromRoadX { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } } = 25.0f;
	[Property(Title = "Offset From Road (Y)"), Feature("Traffic Lights"), Range(-200.0f, 200.0f)] private float TrafficLightOffsetFromRoadY { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } } = 25.0f;
	[Property(Title = "Height Offset"), Feature("Traffic Lights"), Range(0.0f, 10.0f)] private float TrafficLightHeightOffset { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } } = 0.0f;
	[Property(Title = "Rotation Offset"), Feature("Traffic Lights"), Range(0.0f, 360.0f)] private float TrafficLightRotationOffset { get; set { field = value; m_DoesTrafficLightsNeedRebuild = true; } } = 0.0f;
	[Property(Title = "Cycle Duration"), Feature("Traffic Lights"), Range(5.0f, 120.0f)] private float TrafficLightCycleDuration { get; set; } = 30.0f;



	private void OnHasTrafficLightsChanged(bool _OldValue, bool _NewValue)
	{
		m_DoesTrafficLightsNeedRebuild = true;
	}



	private void CreateTrafficLights()
	{
		RemoveTrafficLights();

		if (!HasTrafficLights || !TrafficLightPrefab.IsValid())
			return;

		BuildTrafficLights();

		// Fire initial state so scripts attached to the lights get the correct colour on spawn.
		foreach (var (obj, group) in m_TrafficLightObjects)
		{
			if (!obj.IsValid())
				continue;

			if (group == m_TrafficPhase)
				ITrafficLightEvents.PostToGameObject(obj, x => x.OnTrafficLightGoesGreen());
			else
				ITrafficLightEvents.PostToGameObject(obj, x => x.OnTrafficLightGoesRed());
		}
	}



	private void RemoveTrafficLights()
	{
		m_TrafficLightObjects.Clear();
		m_TrafficPhase = 0;
		m_TrafficPhaseTimer = 0.0f;

		GameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == "TrafficLights");

		if (containerObject.IsValid())
			containerObject.Destroy();
	}



	private void UpdateTrafficLights()
	{
		if (m_DoesTrafficLightsNeedRebuild)
		{
			CreateTrafficLights();
			m_DoesTrafficLightsNeedRebuild = false;
		}

		UpdateTrafficLightPhase();
	}



	private void UpdateTrafficLightPhase()
	{
		if (!HasTrafficLights || m_TrafficLightObjects.Count == 0)
			return;

		m_TrafficPhaseTimer += Time.Delta;
		float halfCycle = MathF.Max(1.0f, TrafficLightCycleDuration) * 0.5f;

		if (m_TrafficPhaseTimer < halfCycle)
			return;

		m_TrafficPhaseTimer -= halfCycle;
		m_TrafficPhase = 1 - m_TrafficPhase;

		foreach (var (obj, group) in m_TrafficLightObjects)
		{
			if (!obj.IsValid())
				continue;

			if (group == m_TrafficPhase)
				ITrafficLightEvents.PostToGameObject(obj, x => x.OnTrafficLightGoesGreen());
			else
				ITrafficLightEvents.PostToGameObject(obj, x => x.OnTrafficLightGoesRed());
		}
	}



	/// <summary>
	/// Returns true if vehicles approaching from <paramref name="_ApproachDir"/> should proceed.
	/// Always true when traffic lights are disabled (the caller handles priority-to-right).
	/// </summary>
	public bool IsApproachGreen(Vector3 _ApproachDir)
	{
		if (!HasTrafficLights)
			return true;

		// Exits aligned with WorldForward (North/South) are group 0; perpendicular (East/West) are group 1.
		float dot = MathF.Abs(Vector3.Dot(_ApproachDir.WithZ(0).Normal, WorldRotation.Forward.WithZ(0).Normal));
		int group = dot > 0.5f ? 0 : 1;
		return group == m_TrafficPhase;
	}



	private void BuildTrafficLights()
	{
		// Only rectangle intersections support traffic lights for now.
		if (Shape != IntersectionShape.Rectangle)
			return;

		GameObject containerObject = new GameObject(GameObject, true, "TrafficLights");
		containerObject.Flags |= GameObjectFlags.NotSaved;

		Vector3 up = WorldRotation.Up;
		float sidewalkOffset = SidewalkWidth;

		EnsureRectangleExits();

		var roads = Scene.GetAll<RoadComponent>().ToList();

		foreach (var exit in Exits)
		{
			if (exit is null)
				continue;

			// Only light an exit that traffic ARRIVES from: a road leaving the intersection here (or no connected road)
			// gets no light. Matched against the snap point (sidewalk edge), where a connecting road's endpoint sits.
			Transform worldExit = GetRectangleExitTransform(exit.Side, true, exit.Offset);
			float connectRadius = exit.Width * 0.5f + SidewalkWidth;

			if (!roads.Any(road => road.IsValid() && !road.ExcludeTraffic && road.HasIncomingTrafficAt(worldExit.Position, connectRadius)))
				continue;

			Transform exitTransform = GetRectangleExitLocalTransform(exit.Side, false, exit.Offset);

			Vector3 exitRight = exitTransform.Rotation.Right;
			Vector3 exitForward = exitTransform.Rotation.Forward;

			float exitRoadWidth = exit.Width;
			float halfRoadWidth = exitRoadWidth * 0.5f;

			float placementDistance = TrafficLightPlacementSystem == TrafficLightSystem.US ? -sidewalkOffset - exitRoadWidth : sidewalkOffset;
			placementDistance += TrafficLightOffsetFromRoadY;

			float sideMultiplier = DrivingSystem == DrivingSide.Left ? 1.0f : -1.0f;

			Vector3 position = exitTransform.Position
				+ exitForward * placementDistance
				+ exitRight * sideMultiplier * (halfRoadWidth + TrafficLightOffsetFromRoadX)
				+ up * (TrafficLightHeightOffset + SidewalkHeight);

			Rotation rotation = exitTransform.Rotation * Rotation.FromYaw(TrafficLightRotationOffset);

			// North/South exits share the WorldForward axis → group 0; East/West → group 1.
			int group = (exit.Side == RectangleExit.North || exit.Side == RectangleExit.South) ? 0 : 1;

			GameObject lightObj = CreateTrafficLight(containerObject, position, rotation);

			if (lightObj.IsValid())
				m_TrafficLightObjects.Add((lightObj, group));
		}
	}



	private float GetExitRoadWidth(RectangleExit _Exit)
	{
		return _Exit switch
		{
			RectangleExit.North or RectangleExit.South => Width,
			RectangleExit.East or RectangleExit.West => Length,
			_ => Width
		};
	}



	private GameObject CreateTrafficLight(GameObject _Parent, Vector3 _Position, Rotation _Rotation)
	{
		if (!TrafficLightPrefab.IsValid())
			return null;

		GameObject trafficLightObject = TrafficLightPrefab.Clone(_Parent, _Position, _Rotation, Vector3.One);

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

		trafficLightObject.BreakFromPrefab();

		trafficLightObject.Flags |= GameObjectFlags.NotSaved;
		trafficLightObject.LocalPosition = _Position;
		trafficLightObject.LocalRotation = _Rotation;

		return trafficLightObject;
	}
}