Code/SubZero-Studios/Shared/CameraDolly.cs
using Sandbox;
using System;

namespace Sandbox.SubZero
{
	/// <summary>
	/// Camera dolly: moves the camera toward or away from a target in a straight line.
	/// Good for push-in / pull-back shots. Add to a GameObject that has a CameraComponent.
	/// </summary>
	[Title( "Camera Dolly" )]
	[Category( "Camera" )]
	[Icon( "videocam" )]
	public sealed class CameraDolly : Component
	{
		[Property, Group( "1. Target" )]
		[Title( "Target" )]
		[Description( "Object to move toward or away from" )]
		public GameObject Target { get; set; }

		[Property, Group( "2. Dolly" )]
		[Title( "Speed (units/sec)" )]
		[Description( "Positive = pull back (away from target). Negative = push in (toward target)." )]
		[Range( -500f, 500f )]
		public float Speed { get; set; } = 50f;

		[Property, Group( "2. Dolly" )]
		[Title( "Min Distance" )]
		[Description( "Stop moving in when this close to target" )]
		[Range( 10f, 5000f )]
		public float MinDistance { get; set; } = 50f;

		[Property, Group( "2. Dolly" )]
		[Title( "Max Distance" )]
		[Description( "Stop moving out when this far from target" )]
		[Range( 50f, 10000f )]
		public float MaxDistance { get; set; } = 2000f;

		[Property, Group( "2. Dolly" )]
		[Title( "Pause" )]
		[Description( "Stop moving" )]
		public bool Pause { get; set; } = false;

		[Property, Group( "3. Video / Editor" )]
		[Title( "Camera FOV" )]
		[Description( "Field of view (0 = leave camera default)" )]
		[Range( 0f, 120f )]
		public float CameraFOV { get; set; } = 60f;

		[Property, Group( "3. Video / Editor" )]
		[Title( "Look At Target" )]
		public bool LookAtTarget { get; set; } = true;

		[Property, Group( "3. Video / Editor" )]
		[Title( "Smooth Rotation" )]
		public bool SmoothRotation { get; set; } = true;

		[Property, Group( "3. Video / Editor" )]
		[Title( "Rotation Smoothing" )]
		[Range( 1f, 20f )]
		[ShowIf( nameof( SmoothRotation ), true )]
		public float RotationSmoothing { get; set; } = 8f;

		private float _currentSpeed;
		private Rotation _currentLookRotation;
		private bool _lookRotationInitialized;
		const float MaxDeltaTime = 0.1f;

		Vector3 GetTargetPosition()
		{
			if ( Target != null && Target.IsValid )
				return Target.WorldPosition;
			return GameObject.WorldPosition;
		}

		protected override void OnStart()
		{
			_currentSpeed = Pause ? 0f : Speed;
			var targetPos = GetTargetPosition();
			if ( LookAtTarget )
			{
				_currentLookRotation = Rotation.LookAt( (targetPos - GameObject.WorldPosition).Normal, Vector3.Up );
				_lookRotationInitialized = true;
			}
			if ( CameraFOV > 0f )
			{
				var cam = Components.Get<CameraComponent>();
				if ( cam != null && cam.IsValid )
					cam.FieldOfView = CameraFOV;
			}
		}

		protected override void OnUpdate()
		{
			var dt = Math.Min( Time.Delta, MaxDeltaTime );
			var targetPos = GetTargetPosition();
			var toCamera = GameObject.WorldPosition - targetPos;
			var distance = toCamera.Length;
			if ( distance < 0.001f )
				distance = 0.001f;
			var direction = toCamera / distance;

			// Smooth speed so pause/direction change don't jerk
			var targetSpeed = Pause ? 0f : Speed;
			var speedT = Math.Clamp( dt * 4f, 0f, 1f );
			_currentSpeed = _currentSpeed + (targetSpeed - _currentSpeed) * speedT;

			// Move along line toward/away from target
			var newDistance = distance + _currentSpeed * dt;
			newDistance = Math.Clamp( newDistance, MinDistance, MaxDistance );
			GameObject.WorldPosition = targetPos + direction * newDistance;

			if ( LookAtTarget )
			{
				var toTarget = (targetPos - GameObject.WorldPosition).Normal;
				var targetRot = Rotation.LookAt( toTarget, Vector3.Up );
				if ( !_lookRotationInitialized )
				{
					_currentLookRotation = targetRot;
					_lookRotationInitialized = true;
				}
				else if ( SmoothRotation && RotationSmoothing > 0f )
				{
					var t = 1f - MathF.Exp( -RotationSmoothing * dt );
					_currentLookRotation = Rotation.Lerp( _currentLookRotation, targetRot, Math.Clamp( t, 0f, 1f ) );
				}
				else
				{
					_currentLookRotation = targetRot;
				}
				GameObject.WorldRotation = _currentLookRotation;
			}
		}

		[Button( "Push In" ), Group( "Dolly Control" )]
		public void PushIn() { Pause = false; Speed = -Math.Abs( Speed ); }

		[Button( "Pull Back" ), Group( "Dolly Control" )]
		public void PullBack() { Pause = false; Speed = Math.Abs( Speed ); }

		[Button( "Stop" ), Group( "Dolly Control" )]
		public void Stop() => Pause = true;
	}
}