Conveyance/KartController.cs
/// <summary>
/// Drives all WheelEntity components on a kart chassis from a single seated player.
/// Attach this to the chassis root alongside a BaseChair (or on the same prefab root).
///
/// Wheel naming convention expected in the prefab:
/// wheel_fl – front-left (steers + drives)
/// wheel_fr – front-right (steers + drives)
/// wheel_rl – rear-left (drives only)
/// wheel_rr – rear-right (drives only)
///
/// ControlSystem already scopes ClientInput to the seated player before calling OnControl(),
/// so we read Connection.Down() directly – same pattern as WheelEntity.
/// </summary>
public class KartController : Component, IPlayerControllable
{
// ── Tuning ────────────────────────────────────────────────────────────────
[Property, Range( 0f, 1f ), Title( "Drive Power" )]
public float DrivePower { get; set; } = 0.7f;
[Property, Range( 0f, 1f ), Title( "Drive Speed" )]
public float DriveSpeed { get; set; } = 0.65f;
[Property, Range( 1f, 60f ), Title( "Max Steer Angle (degrees)" )]
public float MaxSteerAngle { get; set; } = 28f;
[Property, Range( 0f, 1f ), Title( "Brake Power" )]
public float BrakePower { get; set; } = 1f;
/// <summary>
/// Damps chassis pitch and roll each frame while driving to resist tipping.
/// 0 = no damping, 2 = recommended for go-kart stability.
/// </summary>
[Property, Range( 0f, 5f ), Title( "Stability Damping" )]
public float StabilityDamping { get; set; } = 2f;
// ── Input actions (match your project's input map) ────────────────────────
[Property, Group( "Input Actions" )] public string ThrottleAction { get; set; } = "Forward";
[Property, Group( "Input Actions" )] public string BrakeAction { get; set; } = "Back";
[Property, Group( "Input Actions" )] public string SteerLeftAction { get; set; } = "Left";
[Property, Group( "Input Actions" )] public string SteerRightAction { get; set; } = "Right";
[Property, Group( "Input Actions" )] public string HandbrakeAction { get; set; } = "Jump";
// ── Wheel references (auto-discovered by child GameObject name if not set) ─
[Property, Group( "Wheels" ), Title( "Front Left" )] public WheelEntity WheelFL { get; set; }
[Property, Group( "Wheels" ), Title( "Front Right" )] public WheelEntity WheelFR { get; set; }
[Property, Group( "Wheels" ), Title( "Rear Left" )] public WheelEntity WheelRL { get; set; }
[Property, Group( "Wheels" ), Title( "Rear Right" )] public WheelEntity WheelRR { get; set; }
// ── Internal ──────────────────────────────────────────────────────────────
Rigidbody _body;
protected override void OnStart()
{
_body = GetComponent<Rigidbody>();
// Auto-discover by GameObject name so the prefab works without manual wiring
WheelFL ??= FindWheelByName( "wheel_fl" );
WheelFR ??= FindWheelByName( "wheel_fr" );
WheelRL ??= FindWheelByName( "wheel_rl" );
WheelRR ??= FindWheelByName( "wheel_rr" );
}
WheelEntity FindWheelByName( string goName )
{
foreach ( var go in GameObject.GetAllObjects( true ) )
{
if ( string.Equals( go.Name, goName, StringComparison.OrdinalIgnoreCase ) )
{
var w = go.GetComponent<WheelEntity>();
if ( w != null ) return w;
}
}
return null;
}
// ── IPlayerControllable ───────────────────────────────────────────────────
public void OnStartControl() { }
public void OnEndControl()
{
// Neutralise all wheels so the kart doesn't drive off without a pilot
ApplyToAllWheels( ( joint, _ ) =>
{
joint.EnableSpinMotor = true;
joint.SpinMotorSpeed = 0f;
joint.MaxSpinTorque = 20000f; // light hold, not a brick wall
} );
}
public void OnControl()
{
// ClientInput.Current is the seated player; read their connection's inputs
var conn = ClientInput.Current?.Network?.Owner;
float throttle = AnalogDown( ThrottleAction, conn );
float brake = AnalogDown( BrakeAction, conn );
float steerLeft = AnalogDown( SteerLeftAction, conn );
float steerRight = AnalogDown( SteerRightAction, conn );
bool handbrake = IsDown( HandbrakeAction, conn );
float drive = MathX.Clamp( throttle - brake, -1f, 1f );
float steer = MathX.Clamp( steerRight - steerLeft, -1f, 1f );
SetDrive( WheelFL, drive, handbrake );
SetDrive( WheelFR, drive, handbrake );
SetDrive( WheelRL, drive, handbrake );
SetDrive( WheelRR, drive, handbrake );
SetSteer( WheelFL, steer );
SetSteer( WheelFR, steer );
SetSteer( WheelRL, 0f ); // rear wheels don't steer
SetSteer( WheelRR, 0f );
ApplyStabilityDamping();
}
// ── Wheel control helpers ─────────────────────────────────────────────────
void SetDrive( WheelEntity wheel, float drive, bool handbrake )
{
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 )
{
// Coasting – let it roll freely
joint.EnableSpinMotor = false;
}
else
{
joint.EnableSpinMotor = true;
joint.SpinMotorSpeed = -2000f * drive * DriveSpeed;
joint.MaxSpinTorque = 200000f * DrivePower;
}
}
void SetSteer( WheelEntity wheel, float steer )
{
if ( wheel == null ) return;
var joint = wheel.GetComponentInChildren<WheelJoint>();
if ( !joint.IsValid() ) return;
joint.EnableSteering = true;
joint.SteeringDampingRatio = 1f;
joint.MaxSteeringTorque = 500000f;
joint.SteeringLimits = new Vector2( -MaxSteerAngle, MaxSteerAngle );
joint.TargetSteeringAngle = MaxSteerAngle * steer;
}
void ApplyStabilityDamping()
{
if ( _body == null || StabilityDamping <= 0f ) return;
// Damp pitch (X) and roll (Z) in local space; leave yaw (Y) free for steering feel
var worldAng = _body.PhysicsBody.AngularVelocity;
var localAng = WorldRotation.Inverse * worldAng;
float dampFactor = MathX.Clamp( StabilityDamping * Time.Delta * 10f, 0f, 1f );
localAng.x *= 1f - dampFactor;
localAng.z *= 1f - dampFactor;
_body.PhysicsBody.AngularVelocity = WorldRotation * localAng;
}
// ── Utility ───────────────────────────────────────────────────────────────
void ApplyToAllWheels( System.Action<WheelJoint, WheelEntity> action )
{
foreach ( var wheel in new[] { WheelFL, WheelFR, WheelRL, WheelRR } )
{
if ( wheel == null ) continue;
var joint = wheel.GetComponentInChildren<WheelJoint>();
if ( joint.IsValid() ) action( joint, wheel );
}
}
static float AnalogDown( string action, Connection conn )
{
if ( string.IsNullOrWhiteSpace( action ) || conn == null ) return 0f;
return conn.Down( action ) ? 1f : 0f;
}
static bool IsDown( string action, Connection conn )
{
if ( string.IsNullOrWhiteSpace( action ) || conn == null ) return false;
return conn.Down( action );
}
}