Utility/MoveHelper.cs

A move helper struct that implements collide-and-slide character movement for the Scene System. It performs swept capsule traces via a caller-supplied sweep function, slides along surfaces, steps up small ledges, and can try to unstick an embedded position.

NetworkingFile Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace BrickJam;

/// <summary>
/// Collide-and-slide character mover for the Scene System — a re-implementation of the legacy
/// Entity-System <c>Sandbox.MoveHelper</c> (which has no Scene-System equivalent). No bounce; slides
/// along surfaces, steps over small ledges, and can unstick. The owner supplies a <paramref name="sweep"/>
/// closure that runs a capsule trace (with the right tags/ignores) between two foot positions.
/// </summary>
public struct MoveHelper
{
	public Vector3 Position;
	public Vector3 Velocity;
	public float MaxStandableAngle;
	public bool HitWall;

	private readonly Func<Vector3, Vector3, SceneTraceResult> sweep;

	public MoveHelper( Vector3 position, Vector3 velocity, Func<Vector3, Vector3, SceneTraceResult> sweep )
	{
		Position = position;
		Velocity = velocity;
		MaxStandableAngle = 70f;
		HitWall = false;
		this.sweep = sweep;
	}

	public readonly SceneTraceResult TraceDirection( Vector3 direction ) => sweep( Position, Position + direction );

	private readonly bool IsFloor( Vector3 normal ) => Vector3.GetAngle( Vector3.Up, normal ) <= MaxStandableAngle;

	private static Vector3 ClipVelocity( Vector3 velocity, Vector3 normal )
	{
		var backoff = Vector3.Dot( velocity, normal );
		return velocity - normal * backoff;
	}

	/// <summary>Slide the body by <c>Velocity * timestep</c>, clipping against everything it hits.</summary>
	public float TryMove( float timestep )
	{
		var planes = new List<Vector3>( 5 );
		var primalVelocity = Velocity;
		var timeLeft = timestep;
		var traveled = 0f;
		HitWall = false;

		for ( var bump = 0; bump < 4; bump++ )
		{
			if ( Velocity.IsNearlyZero( 0.001f ) )
				break;

			var tr = sweep( Position, Position + Velocity * timeLeft );

			if ( tr.Fraction > 0f )
			{
				Position = tr.EndPosition;
				traveled += tr.Fraction;
				planes.Clear();
			}

			if ( tr.Fraction >= 1f )
				break;

			timeLeft -= timeLeft * tr.Fraction;

			if ( !IsFloor( tr.Normal ) )
				HitWall = true;

			if ( planes.Count >= 5 )
			{
				Velocity = Vector3.Zero;
				break;
			}

			planes.Add( tr.Normal );

			// Clip against every plane we've touched this move so we slide along creases.
			var clipped = Velocity;
			foreach ( var plane in planes )
				clipped = ClipVelocity( clipped, plane );

			Velocity = clipped;

			// Reversing direction means we're wedged - stop to avoid jitter.
			if ( Vector3.Dot( Velocity, primalVelocity ) <= 0f )
			{
				Velocity = Vector3.Zero;
				break;
			}
		}

		return traveled;
	}

	/// <summary>Move, but also try stepping up <paramref name="stepSize"/> and keep whichever goes further.</summary>
	public float TryMoveWithStep( float timestep, float stepSize )
	{
		var startPos = Position;
		var startVel = Velocity;

		// Plain move.
		var fraction = TryMove( timestep );
		var flatPos = Position;
		var flatVel = Velocity;
		var flatDist = (flatPos - startPos).WithZ( 0 ).Length;

		// Stepped move: up -> across -> down onto a floor.
		Position = startPos;
		Velocity = startVel;

		var up = sweep( startPos, startPos + Vector3.Up * stepSize );
		Position = up.EndPosition;
		TryMove( timestep );

		var down = sweep( Position, Position + Vector3.Down * stepSize );
		var stepValid = down.Hit && IsFloor( down.Normal );
		if ( stepValid )
			Position = down.EndPosition;

		var stepDist = stepValid ? (Position - startPos).WithZ( 0 ).Length : -1f;

		// Keep the stepped result only if it made more horizontal progress.
		if ( !stepValid || flatDist >= stepDist )
		{
			Position = flatPos;
			Velocity = flatVel;
		}
		else
		{
			Velocity = flatVel; // preserve horizontal speed when stepping up
		}

		return fraction;
	}

	/// <summary>Nudge out of geometry if we started embedded. Returns true if free.</summary>
	public bool TryUnstuck()
	{
		if ( !sweep( Position, Position ).StartedSolid )
			return true;

		for ( var i = 1; i <= 20; i++ )
		{
			var offset = (Vector3.Random.WithZ( Vector3.Random.z.Clamp( 0f, 1f ) )).Normal * i;
			if ( !sweep( Position + offset, Position + offset ).StartedSolid )
			{
				Position += offset;
				return true;
			}
		}

		return false;
	}
}