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;
}
}