Conveyance/BasculeLeaf.cs
using Sandbox;
/// <summary>
/// Controls a single leaf of a bascule (drawbridge) — one motorized HingeJoint that swings
/// the leaf between a closed angle and an open angle on command.
///
/// This is the per-leaf worker. For a double-leaf bridge, you'll have two of these (one per
/// leaf) coordinated by a single BasculeBridge component. Build and prove ONE leaf first.
///
/// HOW IT WORKS:
/// The HingeJoint runs in "Target Angle" motor mode (set this in the editor — don't try to
/// set the MotorMode enum from C#, it doesn't resolve cleanly; see the dev tips page).
/// In that mode the motor acts like a spring pulling the hinge toward TargetAngle. So
/// "opening the bridge" is literally just: change TargetAngle from ClosedAngle to OpenAngle.
/// The physics motor animates the swing — we don't interpolate anything per-frame ourselves.
///
/// WEIGHT-BEARING:
/// When closed, the leaf rests against the hinge's MinAngle hard stop (set MinAngle = 0 in
/// the editor, matching ClosedAngle here). The hard stop — NOT the motor — bears the static
/// weight of anything sitting on the closed leaf. The motor only needs enough torque to
/// hold the leaf against load-induced bounce and to lift the leaf when opening. This is why
/// MaxTorque can be moderate rather than enormous.
///
/// GEOMETRY (per the dev tips page, lessons 8 & 9):
/// - Build the leaf rigidbody + its hinge child at identity rotation, internally
/// consistent with whatever axis the hinge naturally uses.
/// - Keep the leaf rigidbody's scale uniform-ish; attach visual detail as child objects.
/// - Rotate a PARENT object to place the whole leaf in the world.
///
/// SETUP:
/// - Put this on the SAME GameObject as the leaf's HingeJoint.
/// - In the editor: set the HingeJoint's Motor mode to "Target Angle". Set MinAngle and
/// MaxAngle to bracket the leaf's travel (e.g. MinAngle 0 = closed, MaxAngle 85 = open).
/// - The two leaves of a double bridge rotate toward each other to close, so their hinge
/// motors run in opposite directions. Use FlipDirection on one of them if it swings the
/// wrong way.
/// </summary>
public sealed class BasculeLeaf : Component
{
/// <summary>
/// The HingeJoint that swings this leaf. Auto-found on the same GameObject if not set.
/// </summary>
[Property] public HingeJoint Hinge { get; set; }
/// <summary>
/// The hinge angle (degrees) when the leaf is fully CLOSED. Usually 0 — matching the
/// hinge's MinAngle hard stop, so the closed position is physically backed by the stop
/// and therefore exactly repeatable.
/// </summary>
[Property, Range( -10, 30 )] public float ClosedAngle { get; set; } = 0f;
/// <summary>
/// The hinge angle (degrees) when the leaf is fully OPEN. ~80–85 gives a steep,
/// dramatic raise without going fully vertical (fully vertical can be unstable as the
/// leaf's centre of mass passes over the hinge).
/// </summary>
[Property, Range( 30, 90 )] public float OpenAngle { get; set; } = 82f;
/// <summary>
/// Flips which rotational direction counts as "open". The two leaves of a double bridge
/// face opposite ways, so one of them will need this checked. Toggle it if the leaf
/// swings down/away when it should swing up, or vice versa.
/// </summary>
[Property] public bool FlipDirection { get; set; } = false;
/// <summary>
/// Motor stiffness — how hard the motor-spring pulls toward the target angle.
/// Too low: a loaded leaf won't lift, or sags. Too high: the leaf slams shut hard
/// enough to crush whatever's under it. Start around 4–6 and tune against the weight
/// of the boxes/players that will be on the bridge.
/// </summary>
[Property, Range( 0.5f, 20f )] public float MotorStrength { get; set; } = 5f;
/// <summary>
/// Damping ratio of the motor-spring. 1 = critically damped (reaches the target with no
/// overshoot or oscillation), which is almost always what you want for a bridge leaf —
/// you don't want the leaf bouncing past its target angle.
/// </summary>
[Property, Range( 0.1f, 2f )] public float MotorDamping { get; set; } = 1f;
/// <summary>
/// Maximum torque the motor can apply. Needs to be enough to lift the leaf (plus any
/// load) when opening, but the closed-position weight is borne by the hinge's MinAngle
/// hard stop, not this. Start moderate and raise if the leaf won't open under load.
/// </summary>
[Property, Range( 0, 100000 )] public float MaxTorque { get; set; } = 20000f;
/// <summary>
/// True if the leaf is currently commanded open. Note this reflects the COMMAND, not the
/// physical angle — the leaf takes time to physically swing there. Use IsFullyOpen /
/// IsFullyClosed to check actual physical state.
/// </summary>
public bool IsOpenCommanded { get; private set; } = false;
/// <summary>How close (degrees) the leaf must be to a target angle to count as "fully" there.</summary>
private const float AngleTolerance = 3f;
/// <summary>The hinge angle we're currently commanding, accounting for FlipDirection.</summary>
private float CurrentTargetAngle => (IsOpenCommanded ? OpenAngle : ClosedAngle) * (FlipDirection ? -1f : 1f);
protected override void OnStart()
{
if ( Hinge is null )
Hinge = GetComponent<HingeJoint>();
if ( Hinge is null )
{
Log.Warning( $"{nameof( BasculeLeaf )} on {GameObject.Name}: no HingeJoint assigned or found." );
return;
}
// Push the motor tuning onto the hinge once. We do NOT set the Motor mode here —
// that's set to "Target Angle" in the editor (the MotorMode enum doesn't resolve
// cleanly from C#; see dev tips lesson 12). We only ever write numeric properties.
Hinge.Frequency = MotorStrength;
Hinge.DampingRatio = MotorDamping;
Hinge.MaxTorque = MaxTorque;
// Start in whatever state IsOpenCommanded says (defaults closed).
ApplyTargetAngle();
}
/// <summary>Command the leaf to swing to its open angle.</summary>
public void Open()
{
IsOpenCommanded = true;
ApplyTargetAngle();
}
/// <summary>Command the leaf to swing to its closed angle.</summary>
public void Close()
{
IsOpenCommanded = false;
ApplyTargetAngle();
}
/// <summary>Flip between open and closed.</summary>
public void Toggle()
{
IsOpenCommanded = !IsOpenCommanded;
ApplyTargetAngle();
}
/// <summary>
/// Write the current target angle to the hinge. The motor does the rest — it springs
/// the leaf toward this angle at the configured stiffness/damping.
/// </summary>
private void ApplyTargetAngle()
{
if ( Hinge is null ) return;
Hinge.TargetAngle = CurrentTargetAngle;
}
/// <summary>
/// Re-pushes the motor tuning. Call this if you change MotorStrength/MotorDamping/
/// MaxTorque at runtime (e.g. while tuning). The editor calls OnStart on play, so you
/// don't normally need this.
/// </summary>
[Button( "Apply Tuning" )]
public void ApplyTuning()
{
if ( Hinge is null ) return;
Hinge.Frequency = MotorStrength;
Hinge.DampingRatio = MotorDamping;
Hinge.MaxTorque = MaxTorque;
}
/// <summary>True if the leaf has physically reached (close to) its open angle.</summary>
public bool IsFullyOpen
{
get
{
if ( Hinge is null ) return false;
float openTarget = OpenAngle * (FlipDirection ? -1f : 1f);
return AngleDifference( Hinge.Angle, openTarget ) <= AngleTolerance;
}
}
/// <summary>True if the leaf has physically reached (close to) its closed angle.</summary>
public bool IsFullyClosed
{
get
{
if ( Hinge is null ) return false;
float closedTarget = ClosedAngle * (FlipDirection ? -1f : 1f);
return AngleDifference( Hinge.Angle, closedTarget ) <= AngleTolerance;
}
}
/// <summary>Absolute difference between two angles, as a plain magnitude in degrees.</summary>
private static float AngleDifference( float a, float b )
{
float d = a - b;
return d < 0f ? -d : d;
}
}