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