DiamondHaptics.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace Diamonds;

/// <summary>
/// Controller vibration, mixed like Block Party's Haptics. <see cref="Input.TriggerHaptics(float,float,float,float,int)"/>
/// holds both motors at a constant level and each call replaces the last, so one-shot <see cref="Pulse"/>s are
/// summed here every frame into a single command. The left motor is the heavy,
/// low-frequency weight (thuds) and the right the light, high-frequency one (buzzes); a pulse's tone biases between them.
/// Only rumbles while the player is on a controller, so mouse clicks and keyboard play never buzz a pad on the desk.
/// </summary>
public static class DiamondHaptics
{
	public const float Heavy = 0, Neutral = 0.5f, Crisp = 1;

	// Tone biases energy between the motors without ever silencing one, so a short pulse is always felt.
	const float ToneDepth = 0.6f;
	// Handed to SDL each frame, a little longer than a hitch; Tick re-sends while anything is live, so the envelope is ours.
	const int KeepAliveMs = 120;

	struct Voice
	{
		public float Strength, Duration, Elapsed, Tone, Curve;
	}

	static readonly List<Voice> voices = new();
	static bool active, usingController, lastUsingController, lastStick;

	/// <summary>The Options Controller Rumble slider, scaling every rumble. Zero disables vibration.</summary>
	static float Strength => DiamondSettings.Current.ControllerRumble;

	/// <summary>True while the most recent input came from a controller.</summary>
	public static bool UsingController => usingController;

	/// <summary>The heavy (left) and light (right) motor levels sent by the last <see cref="Tick"/>.</summary>
	public static (float Left, float Right) Motors { get; private set; }

	/// <summary>
	/// A one-shot rumble decaying from <paramref name="strength"/> to nothing over <paramref name="duration"/> seconds.
	/// <paramref name="curve"/> is the decay exponent: 1 is linear, higher values punch then tail off.
	/// </summary>
	public static void Pulse( float strength, float duration, float tone = Neutral, float curve = 3 )
	{
		if ( !usingController || Strength <= 0 || strength <= 0 || duration <= 0 ) return;
		voices.Add( new Voice
		{
			Strength = Math.Clamp( strength, 0, 1 ),
			Duration = duration,
			Tone = Math.Clamp( tone, 0, 1 ),
			Curve = MathF.Max( 0.1f, curve ),
		} );
		// A burst of simultaneous contacts saturates the motors anyway; keep the mix bounded.
		if ( voices.Count > 16 ) voices.RemoveAt( 0 );
	}

	/// <summary>A tiny crisp tick for stepping a menu selection.</summary>
	public static void MenuStep() => Pulse( 0.2f, 0.035f, Crisp, 1 );

	/// <summary>The sharp, heavy-leaning hit of a vertical damaging impact.</summary>
	public static void DamageHit( float strength ) => Pulse( strength, 0.11f, 0.25f, 4 );

	/// <summary>A short, sharp tap for activating a button.</summary>
	public static void MenuConfirm() => Pulse( 0.5f, 0.065f, 0.8f, 5 );

	/// <summary>
	/// Track whether the player is on a controller: a controller button or a new stick push switches to it,
	/// and a keyboard or mouse button (or a keyboard action pressed after stick-only play) switches back.
	/// Call once per frame, after <see cref="DiamondGamepad.Update"/>.
	/// </summary>
	public static void UpdateSource()
	{
		bool engine = Input.UsingController;
		bool stick = DiamondGamepad.Left || DiamondGamepad.Right || DiamondGamepad.Up || DiamondGamepad.Down;
		// Sticks never set the engine flag, so a stick player's first keyboard key would leave it false and unchanged.
		// A controller button sets it before its action fires, so an action pressed without it came from the keyboard.
		bool keyboard = !engine && AnyActionPressed();
		if ( engine || stick && !lastStick ) usingController = true;
		else if ( lastUsingController || keyboard ) usingController = false;
		lastUsingController = engine;
		lastStick = stick;
	}

	static readonly string[] Actions =
	{
		"MoveLeft", "MoveLeftAlt", "MoveRight", "MoveRightAlt", "Rotate", "RotateAlt", "SoftDrop", "SoftDropAlt",
		"Restart", "CloseMenu", "MenuUp", "MenuUpAlt", "MenuDown", "MenuDownAlt", "MenuConfirm", "MenuConfirmAlt",
	};

	static bool AnyActionPressed()
	{
		foreach ( var action in Actions ) if ( Input.Pressed( action ) ) return true;
		return false;
	}

	/// <summary>Mix every live pulse into one motor command. Call once per rendered frame.</summary>
	public static void Tick( float delta )
	{
		if ( !usingController || Strength <= 0 )
		{
			StopAll();
			return;
		}
		float left = 0, right = 0;
		for ( int i = voices.Count - 1; i >= 0; i-- )
		{
			var voice = voices[i];
			// Sample before advancing, so even a pulse shorter than a frame reaches the motors at full strength.
			float level = voice.Strength * MathF.Pow( Math.Clamp( 1 - voice.Elapsed / voice.Duration, 0, 1 ), voice.Curve );
			voice.Elapsed += delta;
			if ( voice.Elapsed >= voice.Duration ) voices.RemoveAt( i );
			else voices[i] = voice;
			Add( level, voice.Tone );
		}

		left = Math.Clamp( left * Strength, 0, 1 );
		right = Math.Clamp( right * Strength, 0, 1 );
		Motors = (left, right);
		if ( left <= 0.001f && right <= 0.001f )
		{
			StopMotors();
			return;
		}
		Input.TriggerHaptics( left, right, 0, 0, KeepAliveMs );
		active = true;

		void Add( float level, float tone )
		{
			left += level * (1 - tone * ToneDepth);
			right += level * (1 - (1 - tone) * ToneDepth);
		}
	}

	/// <summary>Drop every pulse and stop the motors.</summary>
	public static void StopAll()
	{
		voices.Clear();
		Motors = default;
		StopMotors();
	}

	// TriggerHaptics( 0, 0 ) never stops a live rumble, so send one explicit stop when the mix falls silent.
	static void StopMotors()
	{
		if ( !active ) return;
		Input.StopAllHaptics();
		active = false;
	}
}