OrbitalCameraController.cs

A component that controls an orbital camera following a player or vehicle. It reads input to adjust pitch and yaw, traces scene geometry to avoid clipping, auto-zooms based on vehicle speed and grounded state, and provides a ShakeCamera coroutine to add roll shake.

NetworkingFile Access
using Sandbox;
using Sandbox.UI;
using System;
using System.Threading.Tasks;

public sealed class OrbitalCameraController : Component
{
	[Property, Description("Camera to control")] CameraComponent MainCamera;
	RotationControl RotationControl;
	[Property, Description("Player to follow")] GameObject Player;
	[Property] Rigidbody CarBody;


	[Property, Description( "Starting distance" )] int TargetDistanceToPlayer = 200;
	[Property, Description("Starting distance")] int DistanceToPlayer = 200;
	[Property, Description( "Vertical Offset" )] int VerticalOffset = 170;
	[Property] float HorizontalOffset = 80;
	[Property, Description( "Minimal distance to player" )] int MinDistanceToPlayer = 200;
	[Property, Description( "Maximal distance to player" )] int MaxDistanceToPlayer = 500;
	[Property, Description( "Units one step zooms" )] int ZoomStrength = 0;
	[Property, Description( "Units one step zooms" )] int AutoZoomStrength = 1;

	float crosshairPitch = 0;
	float Pitch = 30;
	float Yaw = 180;
	float Roll = 0;

	bool IsShaking = false;
	TimeSince SinceShake;

	protected override void OnStart() 
	{ 
		RotationControl = GameObject.GetComponent<RotationControl>();
		Yaw = Rotation.LookAt(CarBody.WorldRotation.Backward, Vector3.Up).Yaw();
		MainCamera.FieldOfView = 90;
	}

	protected override void OnUpdate()
	{
		// Capture mouse and add to pitch and yaw angles
		Angles mouseMove = Input.AnalogLook;
		// Pitch = (Pitch + mouseMove.pitch ).Clamp( 20, -20 );
		crosshairPitch = (crosshairPitch + mouseMove.pitch ).Clamp( 50, -75 ); // Up&Down clamped in degrees
		Pitch = (crosshairPitch / 1) + 10;
		Yaw = Yaw + mouseMove.yaw;
		Rotation rotation = Rotation.From( Pitch, Yaw, Roll );
		 
		SceneTraceResult checkingSightline = Scene.Trace
			.Ray( Player.WorldPosition + Vector3.Up * VerticalOffset + Player.WorldRotation.Forward * HorizontalOffset, MainCamera.WorldPosition + MainCamera.WorldRotation.Forward * 10 )
			.Radius(1)
			.IgnoreGameObjectHierarchy(GameObject) // Ignores itself. Use tags depending on your setup
			.WithoutTags("enemy", "dead")
			.Run();
		// DebugOverlay.Trace( checkingSightline );

		// Checks for objects around cam to avoid stuttering
		SceneTraceResult checkingBehind = Scene.Trace
			.Sphere( AutoZoomStrength, MainCamera.WorldPosition, MainCamera.WorldPosition )
			.IgnoreGameObjectHierarchy( GameObject ) // Ignores itself. Use tags depending on your setup
			.Run();
		// DebugOverlay.Trace( checkingBehind );

		//Zoom
		if ( Input.MouseWheel.y < 0 ) { TargetDistanceToPlayer = Math.Min( TargetDistanceToPlayer + ZoomStrength, MaxDistanceToPlayer ); }
		if ( Input.MouseWheel.y > 0 ) { TargetDistanceToPlayer = Math.Max( TargetDistanceToPlayer - ZoomStrength, MinDistanceToPlayer); }

		// Zoom out when GroundCheck fails
		if ( !RotationControl.IsGrounded ) 
		{
			TargetDistanceToPlayer = (int)CarBody.Velocity.Length.Remap( 0, 4000, MinDistanceToPlayer, MaxDistanceToPlayer );
			MainCamera.FieldOfView = 90 + (CarBody.Velocity.Length.Remap( 0, 4000, 0, 20 ));
			VerticalOffset = Math.Min( VerticalOffset + AutoZoomStrength, 200 );

			//TargetDistanceToPlayer = MaxDistanceToPlayer;
			//VerticalOffset = Math.Max( VerticalOffset - AutoZoomStrength, 100 ); ;
		}
		// When on ground zoom depending on speed
		else 
		{
			TargetDistanceToPlayer = (int)CarBody.Velocity.Length.Remap( 0, 4000, MinDistanceToPlayer, MaxDistanceToPlayer );
			MainCamera.FieldOfView = 90 + (CarBody.Velocity.Length.Remap( 0, 4000, 0, 20 ) );
			VerticalOffset = Math.Min(VerticalOffset + AutoZoomStrength, 200); 
		}
		

		switch ((TargetDistanceToPlayer, checkingSightline.Distance, checkingSightline.Hit ))
		{
			default: break;
			// Nur wenn checkingBehind false soll distance mit autozoom größer werden
			case( > 0, > 0, false ) when TargetDistanceToPlayer > checkingSightline.Distance + AutoZoomStrength:
				DistanceToPlayer += AutoZoomStrength;
				break;
			case ( > 0, > 0, false) when TargetDistanceToPlayer < checkingSightline.Distance - AutoZoomStrength:
				DistanceToPlayer -= AutoZoomStrength;
				break;
		} 
		if ( checkingSightline.Hit ) // If sightline is blocked change distance to be in front of hit collider
		{
			DistanceToPlayer = (int)(checkingSightline.HitPosition - (Player.WorldPosition + Vector3.Up * VerticalOffset + Player.WorldRotation.Forward * HorizontalOffset)).Length; // -AutoZoomStrength cheats it infront
		}

		// Apply Position and Rotation
		MainCamera.WorldPosition = Player.WorldPosition + Vector3.Up * VerticalOffset + Player.WorldRotation.Forward * HorizontalOffset - rotation.Forward * DistanceToPlayer; 
		MainCamera.WorldRotation = rotation;
		

		// 1000 is distance of crosshair to player, VerticalOffset/2
		Rotation rot = rotation.Angles().WithPitch( crosshairPitch );
		Vector3 crosshairDir = Player.WorldPosition + Vector3.Up * VerticalOffset/2 + new Vector3(rot.Forward.x, rot.Forward.y, rot.Forward.z ).Normal * 1500;
		SceneTraceResult crosshairCheck = Scene.Trace
			.Ray( Player.WorldPosition + Vector3.Up * VerticalOffset / 2,  crosshairDir )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();
	}

	[Button]
	public async Task ShakeCamera(float duration = 0.1f) 
	{
		if ( IsShaking ) { return; }
		
		SinceShake = 0;
		IsShaking = true;

		while ( SinceShake < duration ) 
		{ 
			Roll = (float)Math.Sin(Time.Now * 25f) * 10;
			await Task.FrameEnd();
		}

		IsShaking = false;
		Roll = 0;
	}
}