Camera/CameraNode.cs
using Clover.Camera;
using Clover.Player;
using Sandbox;

[Category( "Clover/Camera" )]
[Icon( "camera" )]
public sealed class CameraNode : Component
{
	public bool IsCameraActive => CameraMan.Instance.MainCameraNode == this;

	[Property] public int Priority { get; set; } = 0;

	[Property] public float FieldOfView { get; set; } = 90;

	[Property] public bool FollowTargets { get; set; } = true;

	[Property] public bool Static { get; set; } = false;

	[Property] public bool Lerping { get; set; } = true;

	[Property] public bool HasPivotRotation { get; set; } = false;

	[Property] public bool ShouldSyncWithPlayerCameraRotation { get; set; } = false;

	[Property] public bool ShouldSnapInstantly { get; set; } = false;

	[Property] public CameraDollyNode DollyNode { get; set; }

	// private Rotation _wishedPivotRotation = Rotation.Identity;
	public GameObject CameraPivot => HasPivotRotation ? GameObject.Parent : null;

	protected override void OnUpdate()
	{
		if ( !IsCameraActive ) return;

		if ( CameraPivot.IsValid() )
		{
			// Gizmo.Draw.Arrow( CameraPivot.WorldPosition, CameraPivot.WorldPosition + ( CameraPivot.WorldRotation.Forward * 32f ) );

			// CameraPivot.WorldRotation = Rotation.Slerp( CameraPivot.WorldRotation, _wishedPivotRotation, Time.Delta * 5f );
			// CameraPivot.WorldRotation = _wishedPivotRotation;

			// Gizmo.Draw.Text( CameraPivot.WorldRotation.Angles().ToString(), new Transform( CameraPivot.WorldPosition ) );
		}

		if ( DollyNode.IsValid() && CameraPivot.IsValid() )
		{
			// move the camera between dolly nodes to follow player
			var playerPosition = PlayerCharacter.Local.WorldPosition;
			var pathNodes = DollyNode.GetPath();

			if ( pathNodes.Count > 1 )
			{
				var closestNode = pathNodes.OrderBy( x => x.WorldPosition.Distance( playerPosition ) ).First();
				var nextNode = pathNodes.FirstOrDefault( x => x != closestNode );
				if ( nextNode.IsValid() )
				{
					var direction = (nextNode.WorldPosition - closestNode.WorldPosition).Normal;
					var distance = closestNode.WorldPosition.Distance( nextNode.WorldPosition );
					var t = (playerPosition - closestNode.WorldPosition).Dot( direction ) / distance;
					var position = Vector3.Lerp( closestNode.WorldPosition, nextNode.WorldPosition, t );
					CameraPivot.WorldPosition = position;
				}
			}
		}
	}

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

		Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 16f ) );
		Gizmo.Draw.Model( "models/editor/camera.vmdl", new Transform() );
		Gizmo.Draw.Arrow( Vector3.Zero, Vector3.Forward * 64f );
	}

	public void SnapTo()
	{
		var camera = Scene.GetAllComponents<CameraMan>().FirstOrDefault();
		if ( !camera.IsValid() ) return;
		camera.SnapTo( WorldPosition, WorldRotation );
	}

	public void OnActivated()
	{
		Log.Info( $"CameraNode {GameObject.Name} activated" );
		if ( ShouldSyncWithPlayerCameraRotation )
		{
			if ( HasPivotRotation )
			{
				CameraPivot.WorldRotation = PlayerCharacter.Local.CameraController.CameraPivot.WorldRotation;
			}
			else
			{
				Log.Error( "ShouldSyncWithPlayerCameraRotation is true but HasPivotRotation is false" );
			}
		}
	}

	public void OnDeactivated()
	{
		Log.Info( $"CameraNode {GameObject.Name} deactivated" );
	}

	public void RotatePivot( Rotation rotation )
	{
		if ( !HasPivotRotation )
		{
			Log.Error( "RotatePivot called but HasPivotRotation is false" );
			return;
		}


		var newAngles = (CameraPivot.WorldRotation * rotation).Angles();


		newAngles.pitch = newAngles.pitch.SnapToGrid( CameraController.CameraRotateSnapDistance ).Clamp( -45, 30 );
		newAngles.roll = 0;
		newAngles.yaw = newAngles.yaw.SnapToGrid( CameraController.CameraRotateSnapDistance );

		CameraPivot.WorldRotation = Rotation.From( newAngles );

		Log.Info( $"Rotating pivot by {rotation}" );

		if ( ShouldSyncWithPlayerCameraRotation )
		{
			PlayerCharacter.Local.CameraController.CameraPivot.WorldRotation = CameraPivot.WorldRotation;
		}
	}
}