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;
}
}