Conveyance/KartAssembler.cs
/// <summary>
/// Attach this to the kartbase. Drag the four wheel GameObjects into the slots in the editor.
/// Rewrites WheelJoint LocalFrames from actual world positions before physics initialises,
/// so stale baked frames from the toolgun scene are irrelevant.
/// </summary>
public class KartAssembler : Component, IPlayerControllable
{
// ── Wheel slots — drag in from scene hierarchy ────────────────────────────
[Property, Group( "Wheels" )] public GameObject FrontLeft { get; set; }
[Property, Group( "Wheels" )] public GameObject FrontRight { get; set; }
[Property, Group( "Wheels" )] public GameObject RearLeft { get; set; }
[Property, Group( "Wheels" )] public GameObject RearRight { get; set; }
// ── Tuning ────────────────────────────────────────────────────────────────
[Property, Range( 0f, 1f )] public float DrivePower { get; set; } = 0.7f;
[Property, Range( 0f, 1f )] public float DriveSpeed { get; set; } = 0.65f;
[Property, Range( 1f, 60f )] public float MaxSteerAngle { get; set; } = 28f;
[Property, Range( 0f, 1f )] public float BrakePower { get; set; } = 1f;
[Property, Range( 0f, 5f )] public float StabilityDamping { get; set; } = 2f;
// ── Input ─────────────────────────────────────────────────────────────────
[Property, Group( "Input" )] public string ThrottleAction { get; set; } = "Forward";
[Property, Group( "Input" )] public string BrakeAction { get; set; } = "Backward";
[Property, Group( "Input" )] public string SteerLeftAction { get; set; } = "Left";
[Property, Group( "Input" )] public string SteerRightAction { get; set; } = "Right";
[Property, Group( "Input" )] public string HandbrakeAction { get; set; } = "Jump";
// ── Internal ──────────────────────────────────────────────────────────────
Rigidbody _chassis;
// Y offset of the axis child in wheel-local space (from basic.prefab)
const float AxleLocalY = 5.492f;
// Run in OnAwake so we write frames before WheelJoint.OnStart initialises the physics joint
protected override void OnAwake()
{
_chassis = GetComponent<Rigidbody>();
AttachWheel( FrontLeft, steer: true );
AttachWheel( FrontRight, steer: true );
AttachWheel( RearLeft, steer: false );
AttachWheel( RearRight, steer: false );
}
void AttachWheel( GameObject wheel, bool steer )
{
if ( wheel == null ) return;
var joint = wheel.GetComponentInChildren<WheelJoint>();
if ( !joint.IsValid() ) return;
// Disable and re-enable to force the joint to reinitialise with our new frames
joint.Enabled = false;
// ── LocalFrame1: axle anchor in wheel-root local space ────────────────
// The axis child sits at Y=5.492 in wheel-local space.
// We want the spin axis to be wheel local-X, so rotate 90° around Z.
joint.LocalFrame1 = new Transform(
new Vector3( 0f, AxleLocalY, 0f ),
Rotation.FromAxis( Vector3.Forward, 90f )
);
// ── LocalFrame2: attachment point in chassis local space ──────────────
// Take the wheel's actual world position and express it in chassis-local space.
var localPos = _chassis.GameObject.WorldTransform.PointToLocal( wheel.WorldPosition );
// Axle points outward: +X for left-side wheels, -X for right-side wheels
var axleDir = localPos.x >= 0f ? Vector3.Right : Vector3.Left;
joint.LocalFrame2 = new Transform(
localPos,
Rotation.LookAt( axleDir, Vector3.Up )
);
// Switch to manual frames and re-enable so the joint initialises with our values
joint.Attachment = Sandbox.Joint.AttachmentMode.LocalFrames;
joint.Enabled = true;
// ── Steering ──────────────────────────────────────────────────────────
joint.EnableSteering = steer;
joint.EnableSteeringLimit = steer;
if ( steer )
{
joint.SteeringLimits = new Vector2( -MaxSteerAngle, MaxSteerAngle );
joint.SteeringHertz = 10f;
joint.SteeringDampingRatio = 1f;
}
}
// ── IPlayerControllable ───────────────────────────────────────────────────
public void OnStartControl() { }
public void OnEndControl()
{
foreach ( var w in Wheels() ) NeutraliseWheel( w );
}
public void OnControl()
{
var conn = ClientInput.Current?.Network?.Owner;
float drive = MathX.Clamp(
( IsDown( ThrottleAction, conn ) ? 1f : 0f ) - ( IsDown( BrakeAction, conn ) ? 1f : 0f ),
-1f, 1f );
float steer = MathX.Clamp(
( IsDown( SteerRightAction, conn ) ? 1f : 0f ) - ( IsDown( SteerLeftAction, conn ) ? 1f : 0f ),
-1f, 1f );
bool handbrake = IsDown( HandbrakeAction, conn );
DriveWheel( FrontLeft, drive, handbrake, steer * MaxSteerAngle );
DriveWheel( FrontRight, drive, handbrake, steer * MaxSteerAngle );
DriveWheel( RearLeft, drive, handbrake, 0f );
DriveWheel( RearRight, drive, handbrake, 0f );
ApplyStabilityDamping();
}
// ── Wheel driving ─────────────────────────────────────────────────────────
void DriveWheel( GameObject wheel, float drive, bool handbrake, float steerAngle )
{
if ( wheel == null ) return;
var joint = wheel.GetComponentInChildren<WheelJoint>();
if ( !joint.IsValid() ) return;
if ( handbrake )
{
joint.EnableSpinMotor = true;
joint.SpinMotorSpeed = 0f;
joint.MaxSpinTorque = 500000f * BrakePower;
}
else if ( MathF.Abs( drive ) < 0.01f )
{
joint.EnableSpinMotor = false;
}
else
{
joint.EnableSpinMotor = true;
joint.SpinMotorSpeed = -2000f * drive * DriveSpeed;
joint.MaxSpinTorque = 200000f * DrivePower;
}
if ( joint.EnableSteering )
{
joint.MaxSteeringTorque = 500000f;
joint.TargetSteeringAngle = steerAngle;
}
}
void NeutraliseWheel( GameObject wheel )
{
if ( wheel == null ) return;
var joint = wheel.GetComponentInChildren<WheelJoint>();
if ( !joint.IsValid() ) return;
joint.EnableSpinMotor = true;
joint.SpinMotorSpeed = 0f;
joint.MaxSpinTorque = 20000f;
}
void ApplyStabilityDamping()
{
if ( _chassis == null || StabilityDamping <= 0f ) return;
var world = _chassis.PhysicsBody.AngularVelocity;
var local = WorldRotation.Inverse * world;
float damp = MathX.Clamp( StabilityDamping * Time.Delta * 10f, 0f, 1f );
local.x *= 1f - damp;
local.z *= 1f - damp;
_chassis.PhysicsBody.AngularVelocity = WorldRotation * local;
}
// ── Helpers ───────────────────────────────────────────────────────────────
IEnumerable<GameObject> Wheels()
{
if ( FrontLeft != null ) yield return FrontLeft;
if ( FrontRight != null ) yield return FrontRight;
if ( RearLeft != null ) yield return RearLeft;
if ( RearRight != null ) yield return RearRight;
}
static bool IsDown( string action, Connection conn )
=> !string.IsNullOrWhiteSpace( action ) && conn != null && conn.Down( action );
}