Conveyance/JointAnchorMover.cs
using Sandbox;
using System.Collections.Generic;
/// <summary>
/// Moves a Rigidbody between waypoints in a way the physics system — and therefore any
/// joints attached to it — can actually see.
///
/// WHY THIS EXISTS (and why PatrolMover can't do this job):
/// PatrolMover moves things with a direct `WorldPosition +=` transform write. That works
/// fine for a patrolling base, because the base IS the moving object and nothing is
/// constrained to it. But a joint's constraint solver only reads PHYSICS BODY state, not
/// raw transforms. So when PatrolMover slides a joint's anchor around, the solver never
/// learns the anchor moved — the joint stays welded to wherever the anchor started.
/// (This is also why wheel joints work: a car's chassis is a real Rigidbody moved BY the
/// simulation, so the solver always sees its current state.)
///
/// The fix is `Rigidbody.SmoothMove`, which the docs describe as moving the body "in a way
/// that cooperates with the physics system." It updates the physics body, so joints track it.
///
/// FEATURES:
/// - Waypoint movement with PingPong or Loop.
/// - Dwell delay: pauses at each waypoint for a configurable time, so passengers can
/// (dis)embark before the car moves off.
/// - Stop/Go switch: freeze the car anywhere — at a station or mid-span — under
/// gameplay or editor control. While stopped the car holds its position so anything
/// jointed to it stays put rather than sagging/swinging.
///
/// SETUP:
/// - Put this on a GameObject that HAS a Rigidbody component.
/// - On that Rigidbody: set `Gravity` = false (we don't want the anchor to fall), and
/// bump `LinearDamping` / `AngularDamping` high (say 10+) so reaction forces from the
/// jointed body don't cause the anchor to drift. Leave `MotionEnabled` = true — the
/// anchor must remain a live physics body for the joint to see it.
/// - Assign Waypoints (two or more empty GameObjects).
/// - Anything jointed to this GameObject (BallJoint, SpringJoint, etc.) will now be
/// dragged along as the anchor moves.
/// </summary>
public sealed class JointAnchorMover : Component
{
/// <summary>
/// The waypoints to move between, in order. Need at least 2. The anchor travels from
/// waypoint 0 toward the last, then (in PingPong mode) back again.
/// </summary>
[Property] public List<GameObject> Waypoints { get; set; } = new();
/// <summary>
/// Movement speed in units per second.
/// </summary>
[Property, Range( 10, 1000 )] public float Speed { get; set; } = 150f;
/// <summary>
/// How close (in units) the anchor must get to a waypoint before it's considered
/// "arrived" — at which point the dwell timer starts.
/// </summary>
[Property, Range( 1, 100 )] public float ArrivalRadius { get; set; } = 16f;
/// <summary>
/// If true, the anchor reverses direction at the end of the waypoint list and walks
/// back (A→B→A→B...). If false, it loops (A→B→C→A→B→C...).
/// </summary>
[Property] public bool PingPong { get; set; } = true;
/// <summary>
/// Seconds the car pauses at each waypoint before moving to the next. This is the
/// (dis)embark window. The car holds its position during the dwell so jointed bodies
/// stay put. Set to 0 for no pause.
/// </summary>
[Property, Range( 0, 30 )] public float DwellTime { get; set; } = 4f;
/// <summary>
/// Master stop/go switch. When false, the car freezes wherever it currently is —
/// at a station OR mid-span — and holds position. Toggle from the editor for setup,
/// or from gameplay via Stop() / Go() / ToggleRunning() (e.g. a lever the player pulls).
///
/// Note: stopping does NOT skip a dwell. If you Stop() the car at a station and later
/// Go(), any unfinished dwell time still has to elapse before it departs — the safety
/// wait is respected.
/// </summary>
[Property] public bool Running { get; set; } = true;
/// <summary>
/// Look-ahead time fed to SmoothMove. SmoothMove eases the body toward the target as if
/// it should arrive in this many seconds. Small values (0.1–0.3) give responsive,
/// fairly direct motion; larger values give floatier, laggier motion. This is the dial
/// to turn if the anchor feels too snappy or too sluggish.
/// </summary>
[Property, Range( 0.05f, 1f )] public float SmoothTime { get; set; } = 0.2f;
// Cached rigidbody we're driving.
private Rigidbody body;
// Index of the waypoint we're currently heading toward (or dwelling at).
private int targetIndex = 0;
// Direction through the waypoint list: +1 forward, -1 backward (only flips in PingPong).
private int direction = 1;
// True while we're sitting at a waypoint waiting out the dwell timer.
private bool isDwelling = false;
// Time since the current dwell started. Only meaningful while isDwelling is true.
private TimeSince timeSinceDwellStarted = 0;
/// <summary>
/// True if the car is currently parked at a waypoint (dwelling). Handy for other
/// scripts — e.g. a station UI that only lets players board while the car is here.
/// </summary>
public bool IsAtStation => isDwelling;
protected override void OnStart()
{
body = GetComponent<Rigidbody>();
if ( body is null )
{
Log.Warning( $"{nameof( JointAnchorMover )} on {GameObject.Name}: no Rigidbody component. " +
$"This mover needs a Rigidbody so joints can track it — add one and set Gravity off." );
return;
}
if ( body.Gravity )
{
Log.Warning( $"{nameof( JointAnchorMover )} on {GameObject.Name}: Rigidbody has Gravity enabled. " +
$"The anchor will fall. Set Gravity = false on the Rigidbody unless you want that." );
}
targetIndex = 0;
}
protected override void OnFixedUpdate()
{
// Physics runs on the fixed tick — driving the body here keeps SmoothMove in step
// with the solver that's reading it.
if ( body is null ) return;
if ( Waypoints is null || Waypoints.Count < 2 ) return;
var target = Waypoints[targetIndex];
if ( !target.IsValid() ) return;
var targetPos = target.WorldPosition;
// --- Stopped: hold position wherever we are, do nothing else. ---
// We still SmoothMove to our own current position so the body stays physics-driven
// and anything jointed to it stays put instead of sagging on the joint.
if ( !Running )
{
body.SmoothMove( body.WorldPosition, SmoothTime, Time.Delta );
return;
}
// --- Dwelling: parked at a waypoint, waiting out the (dis)embark timer. ---
if ( isDwelling )
{
// Hold station precisely at the waypoint.
body.SmoothMove( targetPos, SmoothTime, Time.Delta );
if ( timeSinceDwellStarted >= DwellTime )
{
// Dwell complete — pick the next waypoint and start travelling.
isDwelling = false;
AdvanceWaypoint();
}
return;
}
// --- Travelling toward the current waypoint. ---
var toTarget = targetPos - body.WorldPosition;
if ( toTarget.Length <= ArrivalRadius )
{
// Arrived. Begin the dwell (even if DwellTime is 0 — the dwell branch will
// immediately elapse next tick and advance, which keeps the logic in one path).
isDwelling = true;
timeSinceDwellStarted = 0;
return;
}
// Carrot-on-a-stick: feed SmoothMove a point Speed*SmoothTime units ahead rather
// than the final waypoint. This way Speed sets the pace and SmoothMove just handles
// the physics-cooperative easing to that nearby moving point. If we handed it the
// final waypoint, SmoothTime alone would govern journey speed and Speed would do
// nothing.
var step = Speed * SmoothTime;
var moveTarget = body.WorldPosition + toTarget.Normal * step;
// The line that makes joints actually follow — SmoothMove updates the physics body,
// unlike a raw WorldPosition write.
body.SmoothMove( moveTarget, SmoothTime, Time.Delta );
}
/// <summary>
/// Move targetIndex to the next waypoint, handling PingPong reversal and looping.
/// </summary>
private void AdvanceWaypoint()
{
if ( PingPong )
{
if ( targetIndex + direction < 0 || targetIndex + direction >= Waypoints.Count )
{
direction = -direction;
}
targetIndex += direction;
}
else
{
targetIndex = (targetIndex + 1) % Waypoints.Count;
}
}
// --- Public control surface (call these from triggers, levers, station buttons...) ---
/// <summary>Freeze the car wherever it is. Any in-progress dwell is preserved.</summary>
public void Stop() => Running = false;
/// <summary>Resume the car. If it was mid-dwell at a station, the remaining dwell still elapses first.</summary>
public void Go() => Running = true;
/// <summary>Flip the stop/go state. Convenient for a single-button lever.</summary>
public void ToggleRunning() => Running = !Running;
}