Components/Camera/CameraManager.cs

CameraManager component that selects and drives the scene camera. It collects CameraBehaviour instances from the GameObject and its children, picks the highest-priority behaviour that wants control, handles activation and cross-fading between behaviours, applies shake from CameraNoiseSystem, and writes position, rotation and FOV to Scene.Camera.

using Machines.Systems;

namespace Machines.Components;

/// <summary>
/// Sole writer of <see cref="Scene.Camera"/>; picks the active behaviour, cross-fades, and applies shake.
/// </summary>
public sealed class CameraManager : Component
{
	private readonly List<CameraBehaviour> _behaviours = new();

	private CameraBehaviour _active;
	private CameraPose _pose;
	private bool _hasPose;

	// Cross-fade state
	private bool _blending;
	private float _blendElapsed;
	private float _blendDuration;
	private CameraPose _blendFrom;

	/// <summary>
	/// The behaviour currently driving the camera, or null before the first update.
	/// </summary>
	public CameraBehaviour ActiveBehaviour => _active;

	protected override void OnStart()
	{
		// Gather behaviours from this GameObject and children once.
		_behaviours.Clear();
		foreach ( var behaviour in Components.GetAll<CameraBehaviour>( FindMode.EverythingInSelfAndChildren ) )
			_behaviours.Add( behaviour );
	}

	protected override void OnUpdate()
	{
		var next = SelectBehaviour();
		if ( next is null )
			return;

		// Seed from the live camera the first time so behaviours fall back here, not the origin.
		if ( !_hasPose )
		{
			var cam = Scene.Camera;
			_pose = new CameraPose( cam.WorldPosition, cam.WorldRotation, cam.FieldOfView );
			_hasPose = true;
		}

		if ( next != _active )
		{
			var isFirst = _active is null;

			_active?.SetActive( false );
			next.SetActive( true );
			_active = next;

			next.OnActivated( _pose );

			// Cross-fade from the held pose; first behaviour snaps (nothing to blend from).
			_blendFrom = _pose;
			_blendElapsed = 0f;
			_blendDuration = isFirst ? 0f : next.BlendInTime;
			_blending = _blendDuration > 0f;
		}

		var target = next.Evaluate( _pose );

		CameraPose pose;
		if ( _blending )
		{
			_blendElapsed += Time.Delta;
			var progress = _blendDuration > 0f ? MathX.Clamp( _blendElapsed / _blendDuration, 0f, 1f ) : 1f;

			// Ease-out.
			var t = 1f - (1f - progress) * (1f - progress);
			pose = CameraPose.Lerp( _blendFrom, target, t );

			if ( progress >= 1f )
				_blending = false;
		}
		else
		{
			pose = target;
		}

		// Store noise-free pose so shake doesn't feed into the next blend.
		_pose = pose;

		Apply( pose );
	}

	private void Apply( CameraPose pose )
	{
		var cam = Scene.Camera;

		cam.WorldPosition = pose.Position;

		// Shake layered on top of the blended rotation.
		CameraNoiseSystem.Update();
		cam.WorldRotation = CameraNoiseSystem.Apply( pose.Rotation );

		cam.FieldOfView = pose.FieldOfView;
	}

	/// <summary>
	/// Highest-priority enabled behaviour that wants control.
	/// </summary>
	private CameraBehaviour SelectBehaviour()
	{
		CameraBehaviour best = null;

		foreach ( var behaviour in _behaviours )
		{
			if ( !behaviour.Enabled || !behaviour.WantsControl )
				continue;

			if ( best is null || behaviour.Priority > best.Priority )
				best = behaviour;
		}

		return best;
	}
}