Traffic-related part of the RoadComponent. Defines lane data structures, manages lane arrays and line definitions, computes lane layouts and centerline samples, and determines if a road has incoming traffic at a given point.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>One drivable lane of a road: its signed lateral offset from the centerline (+ = right side) and travel direction.</summary>
public readonly struct TrafficLaneSlot
{
public float Offset { get; init; }
public bool Forward { get; init; }
}
/// <summary>Travel direction of a single road lane, relative to the spline's start→end direction.</summary>
public enum LaneDirection
{
Forward,
Backward
}
public partial class RoadComponent
{
/// <summary>
/// When enabled, this road is ignored by the traffic system: no vehicles spawn on it and no AI will route through it.
/// </summary>
[Property, Feature("General"), Category("Traffic")] public bool ExcludeTraffic { get; set; } = false;
/// <summary>Speed limit for traffic on this road, in km/h.</summary>
[Property, Feature("General"), Category("Traffic"), Range(5.0f, 130.0f)] public float SpeedLimit { get; set; } = 50.0f;
/// <summary>How far this road's dead-end U-turn loop is held back from the road end, in units (larger = the bulb sits further back up the road).</summary>
[Property, Feature("General"), Category("Traffic"), Range(-1000.0f, 1000.0f)] public float UTurnClearance { get; set; } = 100.0f;
/// <summary>
/// The road's lanes in order across its width (−right edge → +right edge). Each entry is one lane plus its travel
/// direction, so the list length IS the lane count. This is the source of truth: the line separators (and their
/// RoadLineDefinition textures) are derived from it. Empty until first enabled, then seeded with a normal 2-lane road.
/// </summary>
[Property, Feature("General"), Category("Traffic"), Change] public LaneDirection[] Lanes { get; set; } = [];
private void OnLanesChanged(LaneDirection[] _OldValue, LaneDirection[] _NewValue)
{
if (_NewValue.Length > 0)
{
_NewValue[^1] = new LaneDirection();
ResizeLineDefinitions(_NewValue.Length - 1);
}
else
{
ResizeLineDefinitions(0);
}
IsDirty = true;
}
/// <summary>Flips every lane's direction at once — swaps which side traffic flows on (right-hand ↔ left-hand drive).</summary>
[Property, Feature("General"), Category("Traffic")] public bool InvertDirection { get; set { field = value; IsDirty = true; } } = false;
/// <summary>Number of lane-dividing lines: one between each pair of lanes (lanes − 1).</summary>
public int TrafficLineCount => Math.Max(0, (Lanes?.Length ?? 1) - 1);
/// <summary>
/// Initialises <see cref="Lanes"/> the first time it's needed. A brand-new road defaults to a 2-lane (one each way)
/// road; a road saved under the old "lines define the lane count" model is migrated to the equivalent lane list with
/// the old direction split. Once <see cref="Lanes"/> holds anything it is never overwritten, so authored data is safe.
/// </summary>
public void EnsureLanes()
{
if (Lanes != null && Lanes.Length > 0)
return;
int count = (LineDefinitions != null && LineDefinitions.Length > 0) ? LineDefinitions.Length + 1 : 2;
Lanes = BuildDefaultLanes(count);
ResizeLineDefinitions(Lanes.Length - 1);
}
/// <summary>The legacy split: lanes shared as evenly as possible, the odd lane forward, forward lanes on the +right side.</summary>
private static LaneDirection[] BuildDefaultLanes(int _Count)
{
_Count = Math.Max(1, _Count);
int forwardCount = (_Count + 1) / 2;
var lanes = new List<LaneDirection>(_Count);
for (int k = 0; k < _Count; k++)
lanes.Add(k >= _Count - forwardCount ? LaneDirection.Forward : LaneDirection.Backward);
return lanes.ToArray();
}
/// <summary>Keeps the per-separator line array exactly one shorter than the lane count, preserving existing entries.</summary>
private void ResizeLineDefinitions(int _Count)
{
_Count = Math.Max(0, _Count);
int current = LineDefinitions?.Length ?? 0;
if (current == _Count)
return;
var resized = new RoadLineDefinition[_Count];
if (LineDefinitions != null)
Array.Copy(LineDefinitions, resized, Math.Min(current, _Count));
LineDefinitions = resized;
}
/// <summary>
/// Lane layout straight from <see cref="Lanes"/>: equal-width lanes across the road, each carrying its authored
/// direction (optionally flipped by <see cref="InvertDirection"/>). Forward = the spline's start→end direction.
/// </summary>
public List<TrafficLaneSlot> GetTrafficLaneLayout()
{
EnsureLanes();
int totalLanes = Lanes.Length;
float laneWidth = RoadWidth / totalLanes;
var slots = new List<TrafficLaneSlot>(totalLanes);
for (int k = 0; k < totalLanes; k++)
{
float offset = -RoadWidth * 0.5f + (k + 0.5f) * laneWidth;
bool forward = (Lanes[k] == LaneDirection.Forward) ^ InvertDirection;
slots.Add(new TrafficLaneSlot { Offset = offset, Forward = forward });
}
return slots;
}
/// <summary>
/// Whether this road has at least one lane travelling INTO the given world point (an intersection exit). Used to
/// decide if that exit needs a traffic light — a road that only leaves the intersection there doesn't (you don't
/// stop cars driving away). Returns false if neither spline endpoint is within <paramref name="_Threshold"/>.
/// </summary>
public bool HasIncomingTrafficAt(Vector3 _WorldPos, float _Threshold)
{
EnsureLanes();
float length = Spline.Length;
if (length <= 1.0f)
return false;
Vector3 startPos = WorldTransform.PointToWorld(Spline.SampleAtDistance(0.0f).Position);
Vector3 endPos = WorldTransform.PointToWorld(Spline.SampleAtDistance(length).Position);
float thresholdSq = _Threshold * _Threshold;
bool atEnd;
if (endPos.DistanceSquared(_WorldPos) <= thresholdSq)
atEnd = true;
else if (startPos.DistanceSquared(_WorldPos) <= thresholdSq)
atEnd = false;
else
return false; // this road doesn't connect at that point
// A "Forward" lane runs spline start→end: it arrives at the END endpoint and leaves the START. So a lane is
// incoming exactly when its forward-ness matches which end connects here.
foreach (var lane in Lanes)
if (((lane == LaneDirection.Forward) ^ InvertDirection) == atEnd)
return true;
return false;
}
/// <summary>
/// Samples the road centerline in WORLD space at roughly even spacing.
/// Fills <paramref name="_Positions"/> with surface points and <paramref name="_Rights"/> with the matching
/// right vector at each point (so the traffic graph can offset lanes to one side). Both lists are cleared first.
/// </summary>
public void GetTrafficCenterline(float _Spacing, List<Vector3> _Positions, List<Vector3> _Rights)
{
_Positions.Clear();
_Rights.Clear();
float length = Spline.Length;
if (length <= 1.0f)
return;
int count = Math.Max(2, (int)Math.Ceiling(length / Math.Max(1.0f, _Spacing)) + 1);
for (int i = 0; i < count; i++)
{
float t = (float)i / (count - 1);
float distance = t * length;
var sample = Spline.SampleAtDistance(distance);
Vector3 worldPos = WorldTransform.PointToWorld(sample.Position);
Vector3 worldTangent = (WorldRotation * sample.Tangent).Normal;
if (worldTangent.IsNearZeroLength)
worldTangent = Vector3.Forward;
Vector3 right = Rotation.LookAt(worldTangent, Vector3.Up).Right;
_Positions.Add(worldPos);
_Rights.Add(right);
}
}
}