Rendering/ArenaCamera.cs

A camera component that frames the entire arena in an orthographic view. It computes orthographic height each frame from arena size, aspect ratio, cell size and padding, and applies brief zoom "punch" and positional "shake" effects that decay over time.

Native Interop
namespace Coilgarden;

/// <summary>
/// Frames the whole arena, always. Fixed, orthographic, no scrolling.
/// <para>
/// Orthographic is not a stylistic choice here: it keeps every cell the same size on
/// screen, so judging a gap near the edge of the arena is exactly as reliable as judging
/// one in the middle. In a game about threading a gap, a perspective arena would quietly
/// make the corners harder than the centre.
/// </para>
/// <para>
/// The framing is recomputed every frame from the live aspect ratio, so a window resize is
/// handled by construction rather than by an event.
/// </para>
/// </summary>
public sealed class ArenaCamera : Component
{
	[Property] public GameSession Session { get; set; }

	[Property] public CameraComponent Camera { get; set; }

	/// <summary>How far back the camera sits. Orthographic, so this only has to be clear of the geometry.</summary>
	[Property] public float Distance { get; set; } = GameConfig.CameraDistance;

	/// <summary>
	/// Multiplier on the arena size, so the walls are not flush against the screen edge and
	/// the HUD has somewhere to sit.
	/// </summary>
	[Property, Range( 1f, 2f )] public float Padding { get; set; } = GameConfig.CameraPadding;

	[Property] public float CellSize { get; set; } = GameConfig.CellSize;

	protected override void OnAwake()
	{
		Camera ??= Components.GetOrCreate<CameraComponent>();
	}

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	/// <summary>Zoom punch, 0..1 of its duration. Decays every frame.</summary>
	private float punchAmount;
	private float punchAge;

	private float shakeAmount;
	private float shakeAge;

	/// <summary>
	/// Asks for a brief zoom-in. Used for eating, because the board coming momentarily closer
	/// reads as a reward, where a shake would read as damage however small it was.
	/// </summary>
	public void Punch( float amount )
	{
		if ( amount <= 0f ) return;

		// Strongest wins. Two apples in quick succession is one punch, not a double-length one.
		if ( amount < punchAmount && punchAge < GameConfig.CameraPunchDuration ) return;

		punchAmount = MathF.Min( amount, MaxPunch );
		punchAge = 0f;
	}

	/// <summary>Asks for a positional kick. Death only.</summary>
	public void Shake( float amount )
	{
		if ( amount <= 0f ) return;
		if ( amount < shakeAmount && shakeAge < GameConfig.CameraShakeDuration ) return;

		shakeAmount = MathF.Min( amount, MaxShake );
		shakeAge = 0f;
	}

	/// <summary>
	/// Hard ceilings, so no call site can produce something absurd whatever it asks for. The
	/// clamp lives here rather than at the caller because there is no version of this game in
	/// which a bigger kick than this is correct.
	/// </summary>
	private const float MaxPunch = 0.05f;

	private const float MaxShake = 14f;

	/// <summary>
	/// Framing is applied in <see cref="OnPreRender"/> rather than in update, so it is
	/// always the last word before the frame is drawn and can never be a frame stale.
	/// </summary>
	protected override void OnPreRender()
	{
		if ( !Camera.IsValid() ) return;

		var arena = Session?.Run?.Arena;

		// Falling back to the configured size keeps the editor's edit-mode view framed
		// correctly, where no run exists yet.
		var columns = arena?.Width ?? GameConfig.GridWidth;
		var rows = arena?.Height ?? GameConfig.GridHeight;

		var needVertical = rows * CellSize * Padding;
		var needHorizontal = columns * CellSize * Padding;

		var aspect = MathF.Max( Screen.Aspect, 0.2f );
		var framed = MathF.Max( needVertical, needHorizontal / aspect );

		Camera.Orthographic = true;

		// A punch shrinks the framed height, which zooms in. Pulse ends exactly at zero, so the
		// framing always returns to precisely where it was rather than drifting.
		Camera.OrthographicHeight = framed * (1f - CurrentPunch());

		// Looking down +X with no rotation of its own, which is the orientation the views lay
		// the arena out for.
		WorldPosition = new Vector3( -Distance, 0f, 0f ) + CurrentShake();
		WorldRotation = Rotation.Identity;
	}

	private float CurrentPunch()
	{
		if ( punchAmount <= 0f ) return 0f;

		punchAge += Time.Delta;

		var t = Ease.Progress( punchAge, GameConfig.CameraPunchDuration );

		if ( t >= 1f )
		{
			punchAmount = 0f;
			return 0f;
		}

		return punchAmount * Ease.Pulse( t );
	}

	private Vector3 CurrentShake()
	{
		if ( shakeAmount <= 0f ) return Vector3.Zero;

		shakeAge += Time.Delta;

		var t = Ease.Progress( shakeAge, GameConfig.CameraShakeDuration );

		if ( t >= 1f )
		{
			shakeAmount = 0f;
			return Vector3.Zero;
		}

		var strength = shakeAmount * (1f - Ease.OutCubic( t ));

		// Two mismatched frequencies read as random over the fraction of a second a shake
		// lasts, with no noise source and no per-frame allocation. Only in the tray plane -
		// shaking along the view axis would change the apparent scale of everything.
		return new Vector3(
			0f,
			MathF.Sin( Time.Now * 97f ) * strength,
			MathF.Sin( Time.Now * 61f + 1.7f ) * strength );
	}
}