Ball/BallAnimator.cs

A visual-only component for a ball model. It orients and spins the mesh to match movement direction, applies squash-and-stretch on sudden direction changes, and does not affect physics or gameplay.

Native Interop
using Sandbox;
using System;

namespace Breakout;

/// <summary>
/// Purely visual. Spins the ball model so it looks like it's rolling in its travel direction, and
/// adds a quick squash-and-stretch when the ball suddenly changes direction (a bounce). None of
/// this touches gameplay; the Ball component does the actual movement and physics.
/// </summary>
public sealed class BallAnimator : Component
{
	[Property, RequireComponent] Ball Ball { get; set; }
	[Property, Range( 0f, 3f )] public float RollSpeedScale { get; set; } = 1f;
	[Property, Range( 1f, 30f )] public float Smoothing { get; set; } = 12f;
	[Property, Range( 0f, 0.6f )] public float SquashAmount { get; set; } = 0.28f;
	[Property, Range( 0.05f, 0.6f )] public float SquashRecover { get; set; } = 0.16f;

	const float HitThreshold = 0.12f;

	Vector3 moveDir = Vector3.Up;
	float moveSpeed = 0f;
	float rollAngle = 0f;

	Vector3 prevRaw = Vector3.Zero;
	Vector3 squashDir = Vector3.Up;
	float squashT = 0f;
	float squashStrength = 0f;

	protected override void OnEnabled()
	{
		rollAngle = 0f;
	}

	protected override void OnUpdate()
	{
		if ( !Ball.IsValid() )
			return;

		var targetDir = Ball.Direction.WithY( 0f );
		if ( targetDir.Length > 0.001f )
			moveDir = Vector3.Lerp( moveDir, targetDir.Normal, Time.Delta * Smoothing ).Normal;

		float targetSpeed = Ball.Stuck ? 0f : Ball.Speed;
		moveSpeed += (targetSpeed - moveSpeed) * MathF.Min( Time.Delta * Smoothing, 1f );

		DetectHit();
		squashT = MathF.Max( 0f, squashT - Time.Delta / MathF.Max( SquashRecover, 0.01f ) );

		ApplyTransform();
	}

	// Spot a sudden change in travel direction (a bounce) and kick off a squash toward it.
	void DetectHit()
	{
		var raw = Ball.Direction.WithY( 0f );
		if ( raw.Length < 0.1f || Ball.Stuck )
			return;
		raw = raw.Normal;

		if ( prevRaw.Length > 0.1f )
		{
			float change = (raw - prevRaw).Length;
			if ( change > HitThreshold )
			{
				squashStrength = MathF.Min( 1f, change / 1.4f );
				squashT = 1f;
				squashDir = raw;
			}
		}

		prevRaw = raw;
	}

	// Spin the model so it looks like it's rolling the way it moves, and squash it: stretch it
	// along the direction it's travelling while squeezing it in the other direction.
	void ApplyTransform()
	{
		var frameDir = squashT > 0.001f ? squashDir : moveDir;

		var axis = new Vector3( -frameDir.z, 0f, frameDir.x );
		axis = axis.Length < 0.001f ? Vector3.Forward : axis.Normal;

		if ( moveSpeed > 1f )
		{
			float radius = MathF.Max( Ball.Radius, 1f );
			rollAngle += (moveSpeed / radius) * RollSpeedScale * (180f / MathF.PI) * Time.Delta;
		}

		var up = MathF.Abs( axis.z ) > 0.99f ? Vector3.Forward : Vector3.Up;
		LocalRotation = Rotation.LookAt( axis, up ) * Rotation.FromAxis( Vector3.Forward, rollAngle );

		float s = SquashAmount * squashStrength * squashT;
		LocalScale = new Vector3( 1f + s * 0.5f, 1f - s, 1f - s );
	}
}