Code/GolfRotator.cs

A Scene component that continuously rotates its GameObject around a local axis and, on the host with a keyframed Rigidbody, pushes nearby golf ball components by applying surface velocity and lifting them out of overlapping box collider arms.

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