Conveyance/Seesawtuner.cs
using Sandbox;
/// <summary>
/// Tunes a HingeJoint to behave like a physical seesaw: limited swing angle, optional friction,
/// and an optional gentle return-to-level torque so the plank settles back to horizontal when
/// nothing's on it.
///
/// Place this on the SAME GameObject as the HingeJoint (i.e. the child of the plank, not the plank itself).
/// All values are applied in OnStart so the joint is fully constructed by then; tweak in the inspector
/// and use the Apply Now button to retune at edit time.
///
/// IMPORTANT: this script does NOT change the HingeJoint's Motor mode. Set it once in the editor
/// (the editor exposes it as Disabled / Target Angle / Target Velocity radio buttons) and leave it.
/// For seesaw behaviour with the return-to-level option, set the editor Motor to "Target Angle".
/// For a free-flopping seesaw, you can either set editor Motor to "Disabled" or just uncheck
/// ReturnToLevel here — the script zeroes the torque so the motor has no effect either way.
///
/// Why a return-to-level torque rather than just leaving the seesaw to flop?
/// A passive seesaw with no return force will sit at whichever angle it was nudged to and just
/// stay there — which feels dead. A small return-to-level spring makes it feel weighted and
/// alive. Keep ReturnStrength low (0.05–0.5) — too high turns the seesaw into a slap-the-box-off
/// catapult, which is funny once but not what this stage is for.
/// </summary>
public sealed class SeesawTuner : Component
{
/// <summary>
/// The HingeJoint this tuner controls. Defaults to one on the same GameObject if not set.
/// </summary>
[Property] public HingeJoint Hinge { get; set; }
/// <summary>
/// How far the seesaw can tilt down on the negative side, in degrees. -30 means the plank
/// can drop 30° below horizontal before hitting its stop. Negative number.
/// </summary>
[Property, Range( -90, 0 )] public float MinAngle { get; set; } = -30f;
/// <summary>
/// How far the seesaw can tilt up on the positive side, in degrees. +30 means the plank
/// can rise 30° above horizontal before hitting its stop. Positive number.
/// </summary>
[Property, Range( 0, 90 )] public float MaxAngle { get; set; } = 30f;
/// <summary>
/// Hinge friction — resistance to motion regardless of angle. Low values let the seesaw
/// swing freely; higher values make it feel heavy and sluggish. 0–5 is a useful range.
/// </summary>
[Property, Range( 0, 10 )] public float Friction { get; set; } = 0.5f;
/// <summary>
/// If true, a soft motor pulls the seesaw back to level (0°) when disturbed.
/// Disable for a free-swinging plank that holds its tilted angle.
/// REQUIRES the HingeJoint's Motor mode to be set to "Target Angle" in the editor.
/// </summary>
[Property] public bool ReturnToLevel { get; set; } = true;
/// <summary>
/// Stiffness of the return-to-level spring (motor frequency in Hz, roughly).
/// 0.3–1.0 feels weighted; above 2 starts to feel springy/bouncy. Only used when ReturnToLevel is on.
/// </summary>
[Property, Range( 0.1f, 3f )] public float ReturnStrength { get; set; } = 0.5f;
/// <summary>
/// Damping ratio of the return spring. 1 = critically damped (no overshoot, no oscillation),
/// below 1 oscillates, above 1 is sluggish. 1 is almost always what you want.
/// </summary>
[Property, Range( 0.1f, 2f )] public float ReturnDamping { get; set; } = 1f;
/// <summary>
/// Max torque the return spring can apply. Too low and a heavy box stays sitting on the
/// tilted plank forever; too high and the seesaw rejects boxes instead of carrying them.
/// Tune in combination with the mass of the boxes being delivered.
/// </summary>
[Property, Range( 0, 50000 )] public float ReturnMaxTorque { get; set; } = 2000f;
protected override void OnStart()
{
// Auto-find the hinge on the same GameObject if the user didn't drag one in.
// This is the common case: tuner and joint live together on the same child object.
if ( Hinge is null )
Hinge = GetComponent<HingeJoint>();
Apply();
}
/// <summary>
/// Pushes all tuner values onto the underlying HingeJoint. Call this if you change values
/// at runtime; the editor calls OnStart automatically when entering play mode.
/// </summary>
[Button( "Apply Now" )]
public void Apply()
{
if ( Hinge is null )
{
Log.Warning( $"{nameof( SeesawTuner )} on {GameObject.Name}: no HingeJoint assigned or found." );
return;
}
// Angle limits and friction are simple values, always safe to write.
Hinge.MinAngle = MinAngle;
Hinge.MaxAngle = MaxAngle;
Hinge.Friction = Friction;
// We deliberately don't touch Hinge.Motor — the user sets that in the inspector to
// "Target Angle" once. The trick: TargetAngle + MaxTorque = 0 means the motor exists
// but applies no force, which is functionally identical to "Disabled". So we toggle
// behaviour purely with the numeric parameters and never reference the MotorMode enum
// from code (it lives in some namespace that's not obviously importable).
if ( ReturnToLevel )
{
// Soft spring pulling the plank back to horizontal.
Hinge.TargetAngle = 0f;
Hinge.Frequency = ReturnStrength;
Hinge.DampingRatio = ReturnDamping;
Hinge.MaxTorque = ReturnMaxTorque;
}
else
{
// Zero torque = motor produces no force, so the hinge swings freely.
Hinge.MaxTorque = 0f;
}
}
}