MotionPath.cs
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Move an object around a path.
/// </summary>
[Title( "Motion Path" )]
public class MotionPath : Component, Component.ExecuteInEditor
{
	/// <summary>
	/// The object that moves around this path.
	/// </summary>
	[Property]
	public GameObject Target { get; set; }

	/// <summary>
	/// Normalized time between 0 and 1 for how far the target is on this path.
	/// </summary>
	[Property, Range( 0.0f, 1.0f )]
	[HostSync] public float Time { get; set; }

	/// <summary>
	/// Rotate the target using the rotation of points, otherwise use the curvature of the path.
	/// </summary>
	[Property]
	public bool UsePointRotation { get; set; }

	/// <summary>
	/// Time is controlled manually.
	/// </summary>
	[Property]
	public bool Manual { get; set; }

	/// <summary>
	/// How long the path takes to complete.
	/// </summary>
	[Property, ShowIf( nameof( Manual ), false )]
	public float Duration { get; set; } = 10;

	public enum SplineType
	{
		Tcb,
		CatmullRom
	};

	[Title( "Mode" ), Group( "Spline" )]
	[Property] public SplineType SplineMode { get; set; }

	[Title( "Tension" ), Group( "Spline" ), ShowIf( nameof( SplineMode ), SplineType.Tcb )]
	[Property, Range( -1, 1 )] public float SplineTension { get; set; }

	[Title( "Continuity" ), Group( "Spline" ), ShowIf( nameof( SplineMode ), SplineType.Tcb )]
	[Property, Range( -1, 1 )] public float SplineContinuity { get; set; }

	[Title( "Bias" ), Group( "Spline" ), ShowIf( nameof( SplineMode ), SplineType.Tcb )]
	[Property, Range( -1, 1 )] public float SplineBias { get; set; }

	private Vector3[] _previewPoints;

	protected override void OnStart()
	{
		base.OnStart();

		if ( Scene.IsEditor )
		{
			if ( !GameObject.GetAllObjects( true )
				.Select( x => x.Components.Get<MotionPathPoint>() )
				.Where( x => x.IsValid() )
				.Any() )
			{
				var go = new GameObject( true, "Point" );
				go.SetParent( GameObject, false );
				go.Components.Create<MotionPathPoint>( true );
			}
		}
	}

	protected override void DrawGizmos()
	{
		base.DrawGizmos();

		if ( _previewPoints is null )
			return;

		using ( Gizmo.Hitbox.LineScope() )
		{
			for ( var i = 0; i < _previewPoints.Length - 1; i++ )
			{
				var pointA = Transform.World.PointToLocal( _previewPoints[i] );
				var pointB = Transform.World.PointToLocal( _previewPoints[i + 1] );
				Gizmo.Draw.Color = Gizmo.IsSelected ? Gizmo.Colors.Active : Gizmo.IsHovered ? Gizmo.Colors.Hovered : Color.White.WithAlpha( 0.5f );
				Gizmo.Draw.LineThickness = Gizmo.IsHovered || Gizmo.IsSelected ? 2 : 1;
				Gizmo.Draw.Line( pointA, pointB );
			}
		}

		if ( Target.IsValid() )
		{
			Gizmo.Draw.Color = Gizmo.Colors.Forward;
			var pointA = Transform.World.PointToLocal( Target.Transform.Position );
			var pointB = Transform.World.NormalToLocal( Target.Transform.Rotation.Forward ) * 5;
			Gizmo.Draw.SolidCone( pointA, pointB, 2 );
		}
	}

	private Vector3 GetPointOnSpline( Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float delta )
	{
		if ( SplineMode == SplineType.Tcb )
			return Vector3.TcbSpline( p0, p1, p2, p3, SplineTension, SplineContinuity, SplineBias, delta );
		else if ( SplineMode == SplineType.CatmullRom )
			return Vector3.CatmullRomSpline( p0, p1, p2, p3, delta );

		return default;
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		var motionPoints = GameObject.GetAllObjects( true )
			.Select( x => x.Components.Get<MotionPathPoint>() )
			.Where( x => x.IsValid() )
			.ToArray();

		var points = motionPoints.Select( x => x.Transform.Position )
			.ToArray();

		if ( SplineMode == SplineType.Tcb )
			_previewPoints = points.TcbSpline( 32, SplineTension, SplineContinuity, SplineBias ).ToArray();
		else if ( SplineMode == SplineType.CatmullRom )
			_previewPoints = points.CatmullRomSpline( 32 ).ToArray();

		if ( points.Length < 2 )
			return;

		if ( points.Length == 2 )
			_previewPoints = new Vector3[2] { points[0], points[1] };

		if ( Target.IsValid() )
		{
			var pointCount = points.Length;

			if ( !Manual && !Duration.AlmostEqual( 0.0f ) )
				Time = Sandbox.Time.Now % Duration / Duration;

			var time = Time.Clamp( 0.0f, 1.0f );

			var segmentIndex = (int)((pointCount - 1) * time);
			segmentIndex = Math.Min( segmentIndex, pointCount - 2 );
			var delta = ((pointCount - 1) * time) - segmentIndex;

			var p0 = segmentIndex > 0 ? points[segmentIndex - 1] : points[0];
			var p1 = points[segmentIndex];
			var p2 = segmentIndex < pointCount - 1 ? points[segmentIndex + 1] : points[pointCount - 1];
			var p3 = segmentIndex < pointCount - 2 ? points[segmentIndex + 2] : points[pointCount - 1];

			var offset = time.AlmostEqual( 1.0f ) ? -0.01f : 0.01f;
			var positionA = GetPointOnSpline( p0, p1, p2, p3, delta );

			if ( UsePointRotation )
			{
				var r1 = motionPoints[segmentIndex];
				var r2 = segmentIndex < pointCount - 1 ? motionPoints[segmentIndex + 1] : motionPoints[pointCount - 1];
				var rotationStart = r1.Transform.Rotation;
				var rotationEnd = r2.Transform.Rotation;

				if ( r1.LookAt.IsValid() )
					rotationStart = Rotation.LookAt( (r1.LookAt.Transform.Position - r1.Transform.Position).Normal );

				if ( r2.LookAt.IsValid() )
					rotationEnd = Rotation.LookAt( (r2.LookAt.Transform.Position - r2.Transform.Position).Normal );

				Target.Transform.Rotation = Rotation.Slerp( rotationStart, rotationEnd, delta );
			}
			else
			{
				var positionB = GetPointOnSpline( p0, p1, p2, p3, delta + offset );
				var forward = (positionB - positionA).Normal * MathF.Sign( offset );
				var right = forward.Cross( Vector3.Up ).Normal;
				var up = right.Cross( forward ).Normal;

				Target.Transform.Rotation = Rotation.LookAt( forward, up );
			}

			Target.Transform.Position = positionA;
		}
	}
}

public class MotionPathPoint : Component, Component.ExecuteInEditor
{
	/// <summary>
	/// Look at this object.
	/// </summary>
	[Property]
	public GameObject LookAt { get; set; }

	protected override void DrawGizmos()
	{
		base.DrawGizmos();

		const float radius = 2.0f;
		Gizmo.Hitbox.DepthBias = 0.1f;
		Gizmo.Hitbox.Sphere( new Sphere( 0, radius ) );
		Gizmo.Draw.Color = Gizmo.IsSelected ? Gizmo.Colors.Active : Gizmo.IsHovered ? Gizmo.Colors.Hovered : Color.White;
		Gizmo.Draw.SolidSphere( 0, radius );

		if ( Gizmo.IsSelected || Gizmo.IsHovered )
		{
			var end = LookAt.IsValid() ? Transform.World.PointToLocal( LookAt.Transform.Position ) : Vector3.Forward * 6;
			Gizmo.Draw.Color = Gizmo.Colors.Forward;
			Gizmo.Draw.Arrow( 0, end, 2, 1 );
		}
	}
}