Weapons/ToolGun/Modes/Hoverball/HoverballEntity.cs
[Alias( "hoverball" )]
public sealed class HoverballEntity : Component, IPlayerControllable
{
	/// <summary>
	/// Is the hoverball on?
	/// </summary>
	[Property, Sync, ClientEditable]
	public bool IsEnabled { get; private set; } = true;

	/// <summary>
	/// The world Z position the hoverball is trying to maintain.
	/// </summary>
	[Property, Sync]
	public float TargetZ { get; private set; }

	/// <summary>
	/// How fast the target height changes when inputs are held.
	/// </summary>
	[Property, Sync, ClientEditable, Range( 0, 20 )]
	public float Speed { get; set; } = 1f;

	/// <summary>
	/// Horizontal air resistance applied while hovering. Also increases vertical damping.
	/// </summary>
	[Property, Sync, ClientEditable, Range( 0, 10 )]
	public float AirResistance { get; set; } = 0f;

	/// <summary>
	/// While held, raises the hover target.
	/// </summary>
	[Property, Sync, ClientEditable]
	public ClientInput Up { get; set; }

	/// <summary>
	/// While held, lowers the hover target.
	/// </summary>
	[Property, Sync, ClientEditable]
	public ClientInput Down { get; set; }

	/// <summary>
	/// Toggles the hoverball on/off
	/// </summary>
	[Property, Sync, ClientEditable]
	public ClientInput Toggle { get; set; }

	[Property]
	public GameObject OnEffect { get; set; }

	[Property, ClientEditable, Metadata( SoundDefinition.Hoverball )] public SoundDefinition EnableSound { get; set; }
	[Property, ClientEditable, Metadata( SoundDefinition.Hoverball )] public SoundDefinition DisableSound { get; set; }

	private float _zVelocity;
	private bool _toggleWasHeld;

	protected override void OnStart()
	{
		if ( !Networking.IsHost ) return;

		TargetZ = WorldPosition.z;

		var rb = GetComponent<Rigidbody>();
		if ( rb.IsValid() )
			rb.Gravity = !IsEnabled;
	}

	protected override void OnUpdate()
	{
		if ( OnEffect.IsValid() )
			OnEffect.Enabled = IsEnabled;
	}

	public void OnStartControl() { }

	public void OnEndControl()
	{
		_zVelocity = 0f;
	}

	public void OnControl()
	{
		var toggleHeld = Toggle.GetAnalog() > 0.5f;
		if ( toggleHeld && !_toggleWasHeld )
		{
			DoToggle();
		}

		_toggleWasHeld = toggleHeld;

		// Accumulate velocity
		var upAnalog = Up.GetAnalog();
		var downAnalog = Down.GetAnalog();
		var zDir = upAnalog - downAnalog;
		_zVelocity = zDir != 0f ? zDir * Time.Delta * 5000f : 0f;
	}

	protected override void OnFixedUpdate()
	{
		if ( !Networking.IsHost ) return;

		var rb = GetComponent<Rigidbody>();
		if ( !rb.IsValid() ) return;

		if ( !IsEnabled ) return;

		// Shift target height from held inputs
		if ( _zVelocity != 0f )
		{
			TargetZ += _zVelocity * Time.Delta * Speed;
		}

		var pos = WorldPosition;
		var vel = rb.Velocity;
		var distance = TargetZ - pos.z;

		// Drive Z velocity toward a target proportional to distance
		var targetVelZ = Math.Clamp( distance * 20f, -400f, 400f );
		var newVelZ = vel.z + (targetVelZ - vel.z) * Math.Min( Time.Delta * 15f * (AirResistance + 1f), 1f );

		var newVel = vel.WithZ( newVelZ );

		// Horizontal air resistance
		if ( AirResistance > 0f )
		{
			var drag = Math.Min( AirResistance * Time.Delta * 5f, 1f );
			newVel = newVel.WithX( vel.x * (1f - drag) ).WithY( vel.y * (1f - drag) );
		}

		rb.Velocity = newVel;
	}

	private void DoToggle()
	{
		IsEnabled = !IsEnabled;

		if ( IsEnabled )
			EnableSound?.Play( WorldPosition );
		else
			DisableSound?.Play( WorldPosition );

		var rb = GetComponent<Rigidbody>();
		if ( !rb.IsValid() ) return;

		if ( IsEnabled )
		{
			TargetZ = WorldPosition.z;
			rb.Gravity = false;
		}
		else
		{
			rb.Gravity = true;
		}
	}
}