Track/SplinePropScatter.cs

Editor and runtime component that scatters prop GameObjects along a Spline. It samples the spline into vertices, computes offset polylines to left/right at an edge offset, then places props at intervals using seeded RNG, spawning GameObjects with Prop and Rigidbody components and network-spawning them on the host.

NetworkingFile Access
using Sandbox.Diagnostics;
using System;
using System.Linq;

namespace Sandbox;

public sealed class PropEntry
{
	[Property] public Model Model { get; set; }

	[Property, Range( 0.1f, 5f )] public float ScaleMin { get; set; } = 1f;
	[Property, Range( 0.1f, 5f )] public float ScaleMax { get; set; } = 1f;
	[Property] public bool RandomYaw { get; set; } = false;
	[Property, Range( -180f, 180f )] public float YawOffset { get; set; } = 0f;
	[Property, Range( 0f, 180f )] public float RandomYawLimit { get; set; } = 0f;
	[Property] public float RandomForwardOffset { get; set; } = 0f;
	[Property] public bool MassOverride { get; set; } = false;
	[Property, Range( 0f, 10000f ), ShowIf( "MassOverride", true )] public float Mass { get; set; } = 100f;

}

[Title( "Spline Prop Scatter" )]
[Category( "Spline" )]
public sealed class SplinePropScatter : Component, Component.ExecuteInEditor
{
	[Property, Category( "Spline" )]
	public SplineComponent Spline { get; set; }

	[Property, Category( "Props" )]
	public List<PropEntry> Props { get; set; } = new();

	[Property, Category( "Props" )]
	public RangedFloat Gap { get; set; } = new RangedFloat( 20f );

	[Property, Category( "Props" )]
	public float EdgeOffset { get; set; } = 400f;

	[Property, Category( "Props" )]
	public bool SpawnLeft { get; set; } = true;

	[Property, Category( "Props" )]
	public bool SpawnRight { get; set; } = true;

	[Property, Category( "Props" )]
	public int Seed { get; set; } = 1337;

	[Property, Category( "Props" ), Range( 0f, 45f )]
	public float CornerAngle { get; set; } = 0f;

	private readonly List<GameObject> _spawned = new();
	private bool _dirty = true;

	protected override void DrawGizmos()
	{
		if ( !Spline.IsValid() ) return;

		Gizmo.Transform = new Transform( 0 );
		Gizmo.Draw.IgnoreDepth = true;

		var spline = Spline.Spline;
		var splineXform = Spline.WorldTransform;
		float length = spline.Length;
		if ( length <= 0f ) return;

		var vertices = ExtractVertices( spline, splineXform, length );
		if ( vertices.Count < 2 ) return;

		if ( SpawnLeft )
		{
			var path = ComputeOffsetPolyline( vertices, -1f );
			Gizmo.Draw.Color = Color.Red;
			for ( int i = 0; i < path.Count - 1; i++ )
				Gizmo.Draw.Line( path[i], path[i + 1] );
		}

		if ( SpawnRight )
		{
			var path = ComputeOffsetPolyline( vertices, 1f );
			Gizmo.Draw.Color = Color.Blue;
			for ( int i = 0; i < path.Count - 1; i++ )
				Gizmo.Draw.Line( path[i], path[i + 1] );
		}
	}

	protected override void OnEnabled()
	{
		if ( Game.IsPlaying && !Networking.IsHost ) return;

		SubscribeSpline();
		_dirty = true;
	}

	protected override void OnDisabled()
	{
		if ( Game.IsPlaying && !Networking.IsHost ) return;

		UnsubscribeSpline();
		ClearSpawned();
	}

	protected override void OnValidate()
	{
		if ( Game.IsPlaying && !Networking.IsHost ) return;

		UnsubscribeSpline();
		SubscribeSpline();
		_dirty = true;
	}

	protected override void OnUpdate()
	{
		if ( !_dirty ) return;
		_dirty = false;

		if ( Game.IsPlaying && !Networking.IsHost ) return;

		Rebuild();
	}

	private void SubscribeSpline()
	{
		if ( Spline.IsValid() )
			Spline.Spline.SplineChanged += OnSplineChanged;
	}

	private void UnsubscribeSpline()
	{
		if ( Spline.IsValid() )
			Spline.Spline.SplineChanged -= OnSplineChanged;
	}

	private void OnSplineChanged() => _dirty = true;

	private void ClearSpawned()
	{
		foreach ( var child in GameObject.Children.ToList() )
			if ( child.IsValid() ) child.Destroy();
		_spawned.Clear();
	}

	private void Rebuild()
	{
		ClearSpawned();

		if ( !Spline.IsValid() || Props is null || Props.Count == 0 )
			return;

		var validProps = Props.Where( e => e?.Model.IsValid() == true ).ToList();
		if ( validProps.Count == 0 ) return;

		var spline = Spline.Spline;
		var splineXform = Spline.WorldTransform;
		float length = spline.Length;
		if ( length <= 0f ) return;

		var rng = new Random( Seed );

		var vertices = ExtractVertices( spline, splineXform, length );
		if ( vertices.Count < 2 ) return;

		if ( SpawnLeft )
		{
			var offsetPath = ComputeOffsetPolyline( vertices, -1f );
			PlaceAlongPath( offsetPath, validProps, rng, isLeft: true );
		}

		if ( SpawnRight )
		{
			var offsetPath = ComputeOffsetPolyline( vertices, 1f );
			PlaceAlongPath( offsetPath, validProps, rng, isLeft: false );
		}
	}

	private List<Vector3> ExtractVertices( Spline spline, Transform splineXform, float length )
	{
		var vertices = new List<Vector3>();
		const float step = 2f;
		float threshold = MathF.Cos( CornerAngle * (MathF.PI / 180f) );

		var startSample = spline.SampleAtDistance( 0f );
		vertices.Add( splineXform.PointToWorld( startSample.Position ) );
		var prevTangent = (splineXform.Rotation * startSample.Tangent).Normal;

		for ( float d = step; d <= length; d += step )
		{
			var sample = spline.SampleAtDistance( d );
			var tangent = (splineXform.Rotation * sample.Tangent).Normal;

			float dot = Vector3.Dot( tangent, prevTangent );
			if ( dot < threshold )
			{
				vertices.Add( splineXform.PointToWorld( sample.Position ) );
			}

			prevTangent = tangent;
		}

		var endSample = spline.SampleAtDistance( length );
		var endPos = splineXform.PointToWorld( endSample.Position );
		if ( endPos.Distance( vertices[^1] ) > 1f )
		{
			vertices.Add( endPos );
		}

		return vertices;
	}

	private List<Vector3> ComputeOffsetPolyline( List<Vector3> vertices, float side )
	{
		int n = vertices.Count;
		if ( n < 2 ) return new List<Vector3>();

		var directions = new List<Vector3>();
		var rights = new List<Vector3>();

		for ( int i = 0; i < n - 1; i++ )
		{
			var dir = (vertices[i + 1] - vertices[i]).Normal;
			directions.Add( dir );
			var right = Vector3.Cross( dir, Vector3.Up ).Normal;
			if ( right.LengthSquared < 0.001f )
				right = Vector3.Cross( dir, Vector3.Forward ).Normal;
			rights.Add( right );
		}

		var result = new List<Vector3>();

		result.Add( vertices[0] + rights[0] * side * EdgeOffset );

		for ( int i = 1; i < n - 1; i++ )
		{
			var P1 = vertices[i] + rights[i - 1] * side * EdgeOffset;
			var D1 = directions[i - 1];
			var P2 = vertices[i] + rights[i] * side * EdgeOffset;
			var D2 = directions[i];

			var miter = LineLineIntersection( P1, D1, P2, D2 );
			result.Add( miter ?? (P1 + P2) * 0.5f );
		}

		result.Add( vertices[n - 1] + rights[n - 2] * side * EdgeOffset );

		return result;
	}

	private static Vector3? LineLineIntersection( Vector3 P1, Vector3 D1, Vector3 P2, Vector3 D2 )
	{
		var cross = Vector3.Cross( D1, D2 );
		float denom = cross.LengthSquared;
		if ( denom < 0.0001f ) return null;

		var diff = P2 - P1;
		float t = Vector3.Dot( Vector3.Cross( diff, D2 ), cross ) / denom;
		return P1 + D1 * t;
	}

	private void PlaceAlongPath( List<Vector3> path, List<PropEntry> validProps, Random rng, bool isLeft )
	{
		if ( path.Count < 2 ) return;

		float carried = 0f;

		for ( int i = 0; i < path.Count - 1; i++ )
		{
			var segStart = path[i];
			var segEnd = path[i + 1];
			var segDir = (segEnd - segStart).Normal;
			float segLen = segStart.Distance( segEnd );

			if ( segLen < 0.1f ) continue;

			var faceDir = isLeft
				? Vector3.Cross( segDir, Vector3.Up ).Normal
				: -Vector3.Cross( segDir, Vector3.Up ).Normal;

			if ( faceDir.LengthSquared < 0.001f )
				faceDir = isLeft
					? Vector3.Cross( segDir, Vector3.Forward ).Normal
					: -Vector3.Cross( segDir, Vector3.Forward ).Normal;

			float d = carried;

			while ( d < segLen )
			{
				var pos = segStart + segDir * d;
				var entry = validProps[rng.Next( validProps.Count )];
				float scale = MathX.Lerp( entry.ScaleMin, entry.ScaleMax, (float)rng.NextDouble() );
				SpawnProp( pos, faceDir, entry, scale, rng );

				// Seeded rng keeps rebuilds deterministic.
				float gap = MathX.Lerp( Gap.Min, Gap.Max, (float)rng.NextDouble() );
				d += Footprint( entry.Model, scale ) + gap;
			}

			carried = d - segLen;
		}
	}

	private static float Footprint( Model model, float scale )
	{
		var s = model.Bounds.Size;
		return MathF.Max( s.x, s.y ) * scale;
	}

	private void SpawnProp( Vector3 worldPos, Vector3 forward, PropEntry entry, float scale, Random rng )
	{
		Assert.True( Networking.IsHost, "Only the host can spawn scatter props" );

		Rotation rotation;

		if ( entry.RandomYaw )
		{
			rotation = Rotation.FromYaw( (float)rng.NextDouble() * 360f );
		}
		else
		{
			float yaw = entry.YawOffset;
			if ( entry.RandomYawLimit > 0f )
				yaw += ((float)rng.NextDouble() * 2f - 1f) * entry.RandomYawLimit;
			rotation = Rotation.LookAt( forward, Vector3.Up ) * Rotation.FromYaw( yaw );
		}

		if ( entry.RandomForwardOffset > 0f )
		{
			float offset = ((float)rng.NextDouble() * 2f - 1f) * entry.RandomForwardOffset;
			worldPos += forward * offset;
		}

		var go = new GameObject( entry.Model.Name );
		go.Tags.Add( "spline_prop" );
		go.Parent = GameObject;
		go.WorldTransform = new Transform( worldPos, rotation, scale );
		go.AddComponent<Prop>().Model = entry.Model;

		if ( entry.MassOverride )
			go.GetComponent<Rigidbody>()?.MassOverride = entry.Mass;

		// Spawn last: network snapshot captures state at spawn time.
		go.NetworkSpawn();

		_spawned.Add( go );
	}
}