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