Audio/Haptics.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Keys for the continuous vibration channels driven by <see cref="Haptics.Sustain"/>. One
/// per ongoing gameplay state, so re-asserting that state each tick tops up the same rumble instead
/// of stacking a new one.</summary>
public enum HapticChannel
{
/// <summary>Blinker winding up a blink.</summary>
BlinkPrepare,
/// <summary>Mimic picking its next form during the choice slow-motion.</summary>
MimicChoice,
/// <summary>Solar starved of sunlight.</summary>
SolarShade,
/// <summary>Grappler being reeled in by one or more lines.</summary>
GrapplePull,
/// <summary>Twin2 holding crouch to charge Harden.</summary>
HardenCharge,
/// <summary>Twin1 mid-dash.</summary>
TwinDash,
/// <summary>Winding up a charged jump.</summary>
ChargeJump,
/// <summary>Swapper hovering.</summary>
SwapperHover,
}
/// <summary>
/// Controller-vibration facade, a sibling to <see cref="Audio"/>. Gameplay code fires one-shot
/// <see cref="Pulse"/>s (strength + duration + ease-out + pan + tone) and holds continuous
/// <see cref="Sustain"/> channels (a level re-asserted for as long as some state lasts); this class
/// mixes every live pulse and channel into a single per-frame rumble command and pushes it to the
/// current gamepad.
///
/// <para><b>Why a mixer?</b> sbox exposes <see cref="Sandbox.Input.TriggerHaptics(float,float,float,float,int)"/>,
/// which drives the two gamepad motors at a CONSTANT strength for a fixed duration — there is no
/// built-in envelope, and each call REPLACES the previous rumble (only one rumble state exists per
/// controller). So to get an ease-out decay and to layer simultaneous events (a slam while bouncing)
/// we keep a list of active "voices", evaluate each voice's envelope every rendered frame in
/// <see cref="Tick"/>, sum them, and send ONE combined command. <see cref="Tick"/> must be called
/// once per frame from <see cref="GameManager.OnUpdate"/> — before any early-out — so vibration runs
/// in live play, replays, menus and while paused alike.</para>
///
/// <para><b>Frequency / "tone".</b> A gamepad has no Hz knob you set per effect. The two motors ARE
/// the only frequency control: the LEFT motor is a heavy low-frequency weight (a dull thud) and the
/// RIGHT motor is a light high-frequency weight (a crisp buzz). So instead of a frequency parameter,
/// each pulse takes a <c>tone</c> in [0,1]: <see cref="TONE_HEAVY"/> (0) favours the low motor for
/// slams/bounces, <see cref="TONE_CRISP"/> (1) favours the high motor for taps/menu blips,
/// <see cref="TONE_NEUTRAL"/> (0.5) drives both. Because those same two motors are also physically
/// left/right, <c>tone</c> and <c>pan</c> both compete for them — we apply each as a partial bias
/// (never fully killing a motor) so both remain audible; a hard-left heavy slam and a hard-right
/// crisp tap therefore differ in character as well as side. That coupling is a hardware limit, not a
/// bug: you cannot pan a pure low-frequency rumble hard-right, because the right motor is the light one.</para>
///
/// <para><b>Xbox trigger haptics</b> (the extra two motors on <c>TriggerHaptics</c>) are Xbox-One-only
/// and intentionally unused here so the feel is identical across pads.</para>
/// </summary>
public static class Haptics
{
/// <summary>Tone = drive the low-frequency (left) motor: dull, heavy thud. Slams, bounces.</summary>
public const float TONE_HEAVY = 0f;
/// <summary>Tone = drive both motors evenly.</summary>
public const float TONE_NEUTRAL = 0.5f;
/// <summary>Tone = drive the high-frequency (right) motor: crisp, light buzz. Taps, menu blips.</summary>
public const float TONE_CRISP = 1f;
// Sustain smoothing (see Sustain). The attack is quick enough that a level change reads as
// immediate; the release is slow enough that a channel ending — or a sim step arriving late —
// fades out instead of clicking off.
private const float SUSTAIN_ATTACK_TIME = 0.05f;
private const float SUSTAIN_RELEASE_TIME = 0.1f;
// How many sim-step intervals a channel may go un-re-asserted before it counts as ended. Above 1
// so an ordinary frame that runs zero sim steps (render rate above tick rate) never cuts one.
private const float SUSTAIN_HOLD_STEPS = 1.6f;
private const float SUSTAIN_HOLD_MIN = 0.05f;
// How hard pan/tone bias energy between the two motors. Kept below 1 so neither knob ever fully
// silences a motor — a short pulse must always be felt regardless of how it's panned/toned.
private const float PAN_DEPTH = 0.75f;
private const float TONE_DEPTH = 0.6f;
// The duration we hand SDL each frame. A touch longer than a frame so a brief render hitch holds
// the motor instead of cutting it out; re-sent every Tick while a voice is alive, so the real
// envelope comes from us, not from this value.
private const int KEEP_ALIVE_MS = 120;
// A single active vibration. Its amplitude eases from `strength` to 0 over `duration` using `ease`.
private struct Voice
{
public float Strength; // peak amplitude, 0..1
public float Duration; // seconds
public float Elapsed; // seconds since fired
public float Pan; // -1 (left) .. +1 (right)
public float Tone; // 0 (heavy/low) .. 1 (crisp/high)
public EasingType Ease;
}
private static readonly List<Voice> _voices = new();
// One continuous vibration channel (see Sustain). Unlike a Voice it has no duration of its own: it
// lives as long as gameplay keeps re-asserting it, and eases out once the asserts stop.
private struct Channel
{
public float Requested; // level asked for, 0..1 (the loudest assert since the last Tick)
public bool AssertedThisFrame;
public float Pan;
public float Tone;
public float SinceRefresh; // real seconds since the last Sustain call
public float Level; // smoothed amplitude actually driving the motors
}
private static readonly Channel[] _channels = new Channel[System.Enum.GetValues<HapticChannel>().Length];
// Real seconds between fixed sim steps, pushed in by GameManager (see SetSimStepInterval). Gameplay
// asserts its channels from sim ticks, so this is how long one may stay quiet before it has clearly
// ended rather than merely being between steps.
private static float _simStepInterval = 1f / 60f;
// True while we're actively driving the motors, so we send exactly one StopAllHaptics when the
// last voice ends (TriggerHaptics with 0,0 is a no-op in the engine and won't stop a live rumble).
private static bool _active;
// User setting (0..1), pushed in from GameSettings like the audio volumes. 0 disables vibration.
private static float _strengthScale = 1f;
/// <summary>While true, <see cref="Pulse"/> is a no-op. Set around a replay's silent re-sim (a
/// backward seek / click-jump) exactly like <see cref="Audio.SuppressSfx"/>, so the burst of
/// pulses those re-simulated steps would fire doesn't reach the controller. Normal replay playback
/// leaves it false, so a watched run vibrates just like a live one.</summary>
public static bool Suppress { get; set; }
/// <summary>Push the user's vibration strength (0..1). 0 disables all vibration.</summary>
public static void SetStrength( float scale ) => _strengthScale = System.Math.Clamp( scale, 0f, 1f );
/// <summary>
/// Fire a one-shot vibration that eases out from <paramref name="strength"/> to 0.
/// </summary>
/// <param name="strength">Peak amplitude, 0..1 (before the user strength setting is applied).</param>
/// <param name="durationSec">How long the pulse takes to decay to nothing.</param>
/// <param name="pan">Stereo bias: -1 = left motor, 0 = centred, +1 = right motor.</param>
/// <param name="tone">Motor character: <see cref="TONE_HEAVY"/>..<see cref="TONE_CRISP"/>.</param>
/// <param name="ease">Decay curve of the amplitude envelope (an ease-out curve gives a punchy drop then tail).</param>
public static void Pulse( float strength, float durationSec, float pan = 0f, float tone = TONE_NEUTRAL, EasingType ease = EasingType.CubicEaseOut )
{
if ( Suppress ) return;
if ( _strengthScale <= 0f ) return;
if ( strength <= 0f || durationSec <= 0f ) return;
_voices.Add( new Voice
{
Strength = System.Math.Clamp( strength, 0f, 1f ),
Duration = durationSec,
Elapsed = 0f,
Pan = System.Math.Clamp( pan, -1f, 1f ),
Tone = System.Math.Clamp( tone, 0f, 1f ),
Ease = ease,
} );
}
/// <summary>A quick, light, crisp tick for menu navigation and slider adjustment.</summary>
public static void MenuBlip() => Pulse( 0.22f, 0.04f, 0f, TONE_CRISP, EasingType.Linear );
/// <summary>A slightly firmer tick for confirming/activating a menu item (a touch above <see cref="MenuBlip"/>).</summary>
public static void MenuConfirm() => Pulse( 0.4f, 0.07f, 0f, TONE_CRISP, EasingType.ExpoEaseOut );
/// <summary>
/// Hold a CONTINUOUS vibration for as long as some gameplay state lasts — the counterpart to
/// <see cref="Pulse"/>'s one-shots. Call it every tick the state holds, with the level it should
/// rumble at right now (a constant for a steady state, a ramp for one that winds up); stop calling
/// it and the channel eases out by itself.
///
/// <para>That "stop calling it" is deliberate: there is no matching Stop that every death,
/// character-change, replay-seek and run-reset path would then have to remember, so a channel can
/// never be stranded on. <see cref="ReleaseSustain"/> exists only to cut one short early.</para>
///
/// <para>Callers assert from fixed sim ticks, which are NOT evenly spaced in real time — slow
/// motion stretches them (Mimic's choice runs the sim at 0.15x) and hit-stop halts them outright —
/// so "has this channel ended?" is measured in sim steps, not wall clock (see
/// <see cref="SetSimStepInterval"/>).</para>
///
/// <para>Two bodies may assert one channel in the same frame (a Swarm group, both Twins): the
/// loudest wins outright rather than summing, so a crowd never rumbles harder than a soloist.</para>
/// </summary>
/// <param name="channel">Which continuous effect this is; one channel per ongoing state.</param>
/// <param name="strength">Level to hold right now, 0..1 (before the user strength setting).</param>
/// <param name="pan">Stereo bias: -1 = left motor, 0 = centred, +1 = right motor.</param>
/// <param name="tone">Motor character: <see cref="TONE_HEAVY"/>..<see cref="TONE_CRISP"/>.</param>
public static void Sustain( HapticChannel channel, float strength, float pan = 0f, float tone = TONE_NEUTRAL )
{
if ( Suppress ) return;
if ( _strengthScale <= 0f ) return;
if ( strength <= 0f ) return;
ref Channel c = ref _channels[(int)channel];
strength = System.Math.Clamp( strength, 0f, 1f );
// Loudest-wins within a frame; the first assert of a new frame always replaces the old level.
if ( c.AssertedThisFrame && strength <= c.Requested )
{
c.SinceRefresh = 0f;
return;
}
c.Requested = strength;
c.AssertedThisFrame = true;
c.Pan = System.Math.Clamp( pan, -1f, 1f );
c.Tone = System.Math.Clamp( tone, 0f, 1f );
c.SinceRefresh = 0f;
}
/// <summary>End a <see cref="Sustain"/> channel now rather than waiting for it to go stale. Only
/// needed where the stale tail would be wrong — a state ending straight into its own impulse.</summary>
public static void ReleaseSustain( HapticChannel channel )
{
ref Channel c = ref _channels[(int)channel];
c.Requested = 0f;
c.AssertedThisFrame = false;
}
/// <summary>Tell the mixer how far apart fixed sim steps currently are in REAL seconds, so
/// <see cref="Sustain"/> can tell a channel that ended from one merely between steps. Pushed every
/// frame by the game loop, since slow motion changes it.</summary>
public static void SetSimStepInterval( float seconds )
=> _simStepInterval = System.Math.Clamp( seconds, 1f / 240f, 1f );
/// <summary>Advance every active voice, mix them into the two motors, and push one rumble command.
/// Call once per rendered frame (see class remarks). Safe to call with no controller connected —
/// the engine no-ops.</summary>
public static void Tick( float dt )
{
if ( Suppress || _strengthScale <= 0f )
{
_voices.Clear();
System.Array.Clear( _channels );
StopIfActive();
return;
}
float left = 0f, right = 0f;
for ( int i = _voices.Count - 1; i >= 0; i-- )
{
var v = _voices[i];
v.Elapsed += dt;
if ( v.Elapsed >= v.Duration )
{
_voices.RemoveAt( i );
continue;
}
_voices[i] = v;
// Amplitude envelope: strength -> 0 over the voice's lifetime, shaped by its easing.
float env = Utils.Map( v.Elapsed, 0f, v.Duration, v.Strength, 0f, true, v.Ease );
// Start centred on both motors so the pulse is always felt, then bias by pan and tone.
// pan quiets the OPPOSITE motor; tone quiets the "wrong-character" motor. Both are partial
// (see *_DEPTH) so neither fully silences a motor.
float l = env, r = env;
if ( v.Pan > 0f ) l *= 1f - v.Pan * PAN_DEPTH; // panned right -> quiet left
else if ( v.Pan < 0f ) r *= 1f + v.Pan * PAN_DEPTH; // panned left -> quiet right
l *= 1f - v.Tone * TONE_DEPTH; // crisp -> quiet the low (left) motor
r *= 1f - (1f - v.Tone) * TONE_DEPTH; // heavy -> quiet the high (right) motor
left += l;
right += r;
}
// Continuous channels. Each holds its level while gameplay keeps asserting it and eases to
// nothing once the asserts stop; the same pan/tone bias as a voice then splits it per motor.
float holdTime = System.Math.Max( SUSTAIN_HOLD_MIN, _simStepInterval * SUSTAIN_HOLD_STEPS );
for ( int i = 0; i < _channels.Length; i++ )
{
ref Channel c = ref _channels[i];
if ( c.Level <= 0f && c.Requested <= 0f ) continue;
c.SinceRefresh += dt;
float target = c.SinceRefresh <= holdTime ? c.Requested : 0f;
c.AssertedThisFrame = false;
if ( target > c.Level )
c.Level = System.Math.Min( target, c.Level + dt / SUSTAIN_ATTACK_TIME );
else
c.Level = System.Math.Max( target, c.Level - dt / SUSTAIN_RELEASE_TIME );
if ( c.Level <= 0f )
{
c = default;
continue;
}
float cl = c.Level, cr = c.Level;
if ( c.Pan > 0f ) cl *= 1f - c.Pan * PAN_DEPTH;
else if ( c.Pan < 0f ) cr *= 1f + c.Pan * PAN_DEPTH;
cl *= 1f - c.Tone * TONE_DEPTH;
cr *= 1f - (1f - c.Tone) * TONE_DEPTH;
left += cl;
right += cr;
}
left = System.Math.Clamp( left * _strengthScale, 0f, 1f );
right = System.Math.Clamp( right * _strengthScale, 0f, 1f );
if ( left <= 0.001f && right <= 0.001f )
{
StopIfActive();
return;
}
Input.TriggerHaptics( left, right, 0f, 0f, KEEP_ALIVE_MS );
_active = true;
}
/// <summary>Immediately stop all vibration and drop any queued pulses.</summary>
public static void StopAll()
{
_voices.Clear();
System.Array.Clear( _channels );
StopIfActive();
}
private static void StopIfActive()
{
if ( !_active ) return;
Input.StopAllHaptics();
_active = false;
}
}