PushVolume.cs

A scene component that implements a push volume trigger. It converts colliders to triggers, computes a force from configured direction and magnitude, and applies impulses to Rigidbodies or velocity to CharacterController objects either continuously on FixedUpdate or once on trigger enter.

Networking
using Sandbox;
using System.Collections.Generic;

namespace LegacyEntityPack;

/// <summary>
/// A volume that pushes things touching it. Modern scene-system port of the legacy
/// <c>push_volume</c> entity.
///
/// Put this on a GameObject that has a Collider (e.g. a BoxCollider) - the collider is
/// turned into a trigger automatically. Pushes <see cref="Rigidbody"/> props and
/// <see cref="CharacterController"/> players; the listener callback and trigger collider
/// must live on the same GameObject.
/// </summary>
[Title( "Push Volume" )]
[Category( "Triggers" )]
[Icon( "deblur" )]
public class PushVolume : Component, Component.ITriggerListener
{
	/// <summary>How strong the push is, in units per second.</summary>
	[Property] public float Force { get; set; } = 500f;

	/// <summary>Direction of the force.</summary>
	[Property] public Angles ForceDirection { get; set; }

	/// <summary>If set, applies one second of force once when something enters, instead of pushing continuously.</summary>
	[Property] public bool OnlyPushOnEnter { get; set; } = false;

	/// <summary>Objects already pushed this tick, so a multi-collider object isn't pushed twice.</summary>
	protected readonly HashSet<GameObject> PushedThisTick = new();

	/// <summary>The force vector for the given timeslice. Use this in <see cref="Push"/> overrides.</summary>
	protected Vector3 GetForce( float time ) => Rotation.From( ForceDirection ).Forward * Force * time;

	protected override void OnAwake()
	{
		// The legacy push_volume was a Solid trigger, so make our colliders triggers.
		foreach ( var collider in Components.GetAll<Collider>( FindMode.EverythingInSelfAndDescendants ) )
			collider.IsTrigger = true;
	}

	protected override void OnFixedUpdate()
	{
		if ( OnlyPushOnEnter )
			return;

		var trigger = Components.Get<Collider>( FindMode.EverythingInSelfAndDescendants );
		if ( trigger is null )
			return;

		PushedThisTick.Clear();
		foreach ( var other in trigger.Touching )
		{
			if ( other.IsValid() )
				Push( other, Time.Delta );
		}
	}

	public void OnTriggerEnter( Collider other )
	{
		if ( !OnlyPushOnEnter || !other.IsValid() )
			return;

		PushedThisTick.Clear();
		Push( other, 1f );
	}

	/// <summary>
	/// Push a single touching collider. Override to support custom controllers, calling
	/// <c>base.Push</c> to fall back to the default Rigidbody / CharacterController handling.
	/// </summary>
	protected virtual void Push( Collider other, float time )
	{
		var force = GetForce( time );

		var rb = other.Rigidbody;
		if ( rb.IsValid() )
		{
			if ( !PushedThisTick.Add( rb.GameObject ) )
				return;
			if ( Networking.IsActive && rb.Network.IsProxy )
				return;

			// Multiply by mass so everything accelerates equally, matching the original.
			rb.ApplyImpulse( force * rb.Mass );
			return;
		}

		var controller = other.Components.Get<CharacterController>( FindMode.EverythingInSelfAndAncestors );
		if ( controller.IsValid() )
		{
			if ( !PushedThisTick.Add( controller.GameObject ) )
				return;
			if ( Networking.IsActive && controller.Network.IsProxy )
				return;

			if ( force.z > 1f )
				controller.GroundObject = null;

			controller.Velocity += force;
		}
	}
}