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