GolfRotator.cs

A component for rotating an entity around a local axis at a steady rate, used for windmill-style blades. It updates rotation in editor and in play; when a Rigidbody is present it applies the rotation via keyframed angular velocity during fixed updates and, on the host, manually pushes nearby golf ball components by waking them, imparting surface velocity at contacts and nudging them out of overlapping box arms.

File Access
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Spins the object it sits on round one of its own axes at a steady rate, on every machine and
/// in the editor too (nothing is networked: every machine turns it the same way from the same
/// start, and a prop that is a little out of step between machines does not matter). Made for
/// the windmill's blades, whose axle is their local Y.
///
/// With a Rigidbody on the same object (motion off, so the body is keyframed in play) the turn
/// goes through the physics body with a matching angular velocity, and on the host the box
/// colliders on the object also push golf balls by hand: a ball inside the sweep is kept awake,
/// and a ball an arm box overlaps is given the arm's surface speed at the contact point, so
/// resting balls get swatted instead of being passed through.
/// </summary>
[Title( "Golf Rotator" ), Icon( "sync" ), Category( "Golf" )]
public sealed class GolfRotator : Component, Component.ExecuteInEditor
{
	/// <summary>The local axis to turn round. The windmill blades turn round their local Y.</summary>
	[Property]
	public Vector3 Axis { get; set; } = new Vector3( 0f, 1f, 0f );

	/// <summary>Turning speed in degrees per second; negative turns the other way.</summary>
	[Property, Title( "Degrees Per Second" ), Range( -360f, 360f )]
	public float DegreesPerSecond { get; set; } = 30f;

	Rigidbody _body;
	BoxCollider[] _arms = Array.Empty<BoxCollider>();
	float _reach;
	bool _loggedContact;

	protected override void OnStart()
	{
		_body = GetComponent<Rigidbody>( true );
		_arms = GetComponents<BoxCollider>( true ).ToArray();
		_reach = _arms.Length == 0 ? 0f : _arms.Max( a => a.Center.Length + a.Scale.Length * 0.5f );
	}

	protected override void OnUpdate()
	{
		// In play with a physics body the fixed step below does the turning.
		if ( !Scene.IsEditor && _body.IsValid() ) return;
		Turn( Time.Delta );
	}

	protected override void OnFixedUpdate()
	{
		if ( Scene.IsEditor || !_body.IsValid() ) return;
		var next = Turn( Time.Delta );
		if ( next is null ) return;

		// The keyframed body is rotated with the velocity that gets it there this step, so a ball
		// in the blade's path is pushed rather than finding the blade somewhere else next step.
		_body.SmoothRotate( next.Value, Time.Delta, Time.Delta );

		if ( Networking.IsHost ) PushBalls();
	}

	/// <summary>Turns the object by one step and returns the new world rotation, or null when there is nothing to do.</summary>
	Rotation? Turn( float seconds )
	{
		var axis = Axis;
		if ( axis.IsNearlyZero() || DegreesPerSecond == 0f || seconds <= 0f ) return null;
		LocalRotation *= Rotation.FromAxis( axis.Normal, DegreesPerSecond * seconds );
		return WorldRotation;
	}

	/// <summary>
	/// Host only. Every ball within the sweep is kept awake (a sleeping ball ignores a keyframed
	/// body sliding into it), and a ball that an arm box overlaps gets at least the arm's surface
	/// velocity along the contact normal and is lifted out of the box.
	/// </summary>
	void PushBalls()
	{
		if ( _arms.Length == 0 || DegreesPerSecond == 0f ) return;

		var hub = WorldPosition;
		var omega = WorldRotation * Axis.Normal * DegreesPerSecond.DegreeToRadian();
		var transform = WorldTransform;

		foreach ( var ball in Scene.GetAllComponents<IGolfCourseBall>() )
		{
			if ( !ball.GameObject.IsValid() || ball.IsHoled || !ball.Rigidbody.IsValid() ) continue;

			var pos = ball.WorldPosition;
			if ( pos.Distance( hub ) > _reach + ball.Radius + 4f ) continue;

			ball.Rigidbody.Sleeping = false;

			foreach ( var arm in _arms )
			{
				if ( !arm.IsValid() || !arm.Enabled ) continue;

				// Closest point of the arm box to the ball's centre, in the box's own frame.
				var local = transform.PointToLocal( pos ) - arm.Center;
				var half = arm.Scale * 0.5f;
				var clamped = new Vector3( Math.Clamp( local.x, -half.x, half.x ), Math.Clamp( local.y, -half.y, half.y ), Math.Clamp( local.z, -half.z, half.z ) );
				var closest = transform.PointToWorld( clamped + arm.Center );
				var gap = pos - closest;
				var dist = gap.Length;
				if ( dist > ball.Radius ) continue;

				var surface = Vector3.Cross( omega, closest - hub );
				var normal = dist > 0.05f ? gap.Normal : surface.Normal;
				if ( normal.IsNearlyZero() ) continue;

				// Match the arm's speed into the ball; a ball already moving away is left alone.
				var velocity = ball.Rigidbody.Velocity;
				var relative = (velocity - surface).Dot( normal );
				if ( relative < 0f ) ball.Rigidbody.Velocity = velocity - normal * relative;

				// Lift it out of the box so the next step starts from a clean contact.
				if ( dist < ball.Radius - 0.1f ) ball.WorldPosition = pos + normal * (ball.Radius - dist);

				if ( !_loggedContact )
				{
					_loggedContact = true;
					Log.Info( $"GolfRotator: {GameObject.Name} first touched {ball.OwnerName}'s ball at {closest}, arm surface speed {surface.Length:0}, ball {velocity.Length:0} -> {ball.Rigidbody.Velocity.Length:0} u/s" );
				}

				break;
			}
		}
	}
}