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

namespace Sandbox.SubZero
{
	/// <summary>
	/// Camera orbit component: orbits the camera around a target object.
	/// Useful for cinematic shots and editor video recording. Add to a GameObject that has a CameraComponent.
	/// </summary>
	[Title( "Camera Orbit" )]
	[Category( "Camera" )]
	[Icon( "videocam" )]
	public sealed class CameraOrbit : Component
	{
		// ═══════════════════════════════════════════════════════════════
		// TARGET & ORBIT
		// ═══════════════════════════════════════════════════════════════

		[Property, Group( "1. Target" )]
		[Title( "Target" )]
		[Description( "Object to orbit around (leave empty to use current position as center)" )]
		public GameObject Target { get; set; }

		[Property, Group( "2. Orbit" )]
		[Title( "Orbit Radius (units)" )]
		[Description( "Horizontal distance from target" )]
		[Range( 10f, 2000f )]
		public float OrbitRadius { get; set; } = 200f;

		[Property, Group( "2. Orbit" )]
		[Title( "Orbit Height (Z offset)" )]
		[Description( "Height above/below target center (Z-up)" )]
		[Range( -500f, 500f )]
		public float OrbitHeight { get; set; } = 80f;

		[Property, Group( "2. Orbit" )]
		[Title( "Orbit Speed (degrees/sec)" )]
		[Description( "How fast the camera orbits" )]
		[Range( 0.5f, 180f )]
		public float OrbitSpeed { get; set; } = 15f;

		[Property, Group( "2. Orbit" )]
		[Title( "Clockwise" )]
		[Description( "Orbit direction" )]
		public bool Clockwise { get; set; } = true;

		[Property, Group( "2. Orbit" )]
		[Title( "Initial Angle (degrees)" )]
		[Description( "Starting angle around target (0 = +X)" )]
		[Range( 0f, 360f )]
		public float InitialAngleDegrees { get; set; } = 0f;

		[Property, Group( "Orbit Control" )]
		[Title( "Angle (degrees)" )]
		[Description( "Type an angle here, then click Set Angle to jump the camera there (0 = +X)" )]
		[Range( 0f, 360f )]
		public float AngleDegrees { get; set; } = 0f;

		// ═══════════════════════════════════════════════════════════════
		// VIDEO / EDITOR
		// ═══════════════════════════════════════════════════════════════

		[Property, Group( "3. Video / Editor" )]
		[Title( "Pause Orbit" )]
		[Description( "Pause orbiting (useful when framing a shot in editor)" )]
		public bool PauseOrbit { get; set; } = false;

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

		[Property, Group( "3. Video / Editor" )]
		[Title( "Look At Target" )]
		[Description( "Camera always looks at target center" )]
		public bool LookAtTarget { get; set; } = true;

		[Property, Group( "3. Video / Editor" )]
		[Title( "Smooth Rotation" )]
		[Description( "Smoothly rotate camera toward target (reduces jerkiness for video)" )]
		public bool SmoothRotation { get; set; } = true;

		[Property, Group( "3. Video / Editor" )]
		[Title( "Rotation Smoothing" )]
		[Description( "How quickly camera rotation follows target (higher = snappier)" )]
		[Range( 1f, 20f )]
		[ShowIf( nameof( SmoothRotation ), true )]
		public float RotationSmoothing { get; set; } = 8f;

		// ─────────────────────────────────────────────────────────────
		// STATE
		// ─────────────────────────────────────────────────────────────

		private float _orbitAngleRad;
		private float _orbitSpeedCurrent; // degrees/sec, signed (positive = CCW, negative = CW)
		private Rotation _currentLookRotation;
		private bool _lookRotationInitialized;

		/// <summary>Max delta time per frame to avoid jerks from spikes (e.g. editor pause/resume).</summary>
		const float MaxDeltaTime = 0.1f;

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

		protected override void OnStart()
		{
			_orbitAngleRad = MathX.DegreeToRadian( InitialAngleDegrees );
			var dir = Clockwise ? -1f : 1f;
			_orbitSpeedCurrent = OrbitSpeed * dir;
			var center = GetOrbitCenter();
			UpdatePosition( center );
			if ( LookAtTarget )
			{
				_currentLookRotation = Rotation.LookAt( (center - 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 center = GetOrbitCenter();

			// Smooth orbit speed toward target so pausing and direction change don't jerk
			var targetSpeed = PauseOrbit ? 0f : (OrbitSpeed * (Clockwise ? -1f : 1f));
			var speedT = Math.Clamp( dt * 4f, 0f, 1f );
			_orbitSpeedCurrent = _orbitSpeedCurrent + (targetSpeed - _orbitSpeedCurrent) * speedT;
			_orbitAngleRad += MathX.DegreeToRadian( _orbitSpeedCurrent ) * dt;

			UpdatePosition( center );

			if ( LookAtTarget )
			{
				var toTarget = (center - GameObject.WorldPosition).Normal;
				var targetRot = Rotation.LookAt( toTarget, Vector3.Up );
				if ( !_lookRotationInitialized )
				{
					_currentLookRotation = targetRot;
					_lookRotationInitialized = true;
				}
				else if ( SmoothRotation && RotationSmoothing > 0f )
				{
					// Exponential decay: frame-rate independent, no overshoot
					var t = 1f - MathF.Exp( -RotationSmoothing * dt );
					_currentLookRotation = Rotation.Lerp( _currentLookRotation, targetRot, Math.Clamp( t, 0f, 1f ) );
				}
				else
				{
					_currentLookRotation = targetRot;
				}
				GameObject.WorldRotation = _currentLookRotation;
			}
		}

		void UpdatePosition( Vector3 center )
		{
			float x = OrbitRadius * MathF.Cos( _orbitAngleRad );
			float y = OrbitRadius * MathF.Sin( _orbitAngleRad );
			GameObject.WorldPosition = center + new Vector3( x, y, OrbitHeight );
		}

		[Button( "Set Angle" ), Group( "Orbit Control" )]
		[Description( "Jump orbit to the Angle (degrees) value above" )]
		public void SetAngle()
		{
			_orbitAngleRad = MathX.DegreeToRadian( AngleDegrees );
		}

		[Button( "Pause" ), Group( "Orbit Control" )]
		public void Pause() => PauseOrbit = true;

		[Button( "Resume" ), Group( "Orbit Control" )]
		public void Resume() => PauseOrbit = false;
	}
}