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

namespace Diamonds;

/// <summary>Speed-driven crystal audio and short, bounded board impulses.</summary>
public sealed class DiamondFeedback
{
	[SkipHotload] static SoundFile crushSound;
	[SkipHotload] static SoundFile rotateBumpSound;
	const float RotateBumpVolume = 0.3f;
	readonly Random random = new();
	readonly List<SoundHandle> impacts = new();
	// Not readonly: a hotloaded instance may predate this field.
	List<SoundHandle> scoreTicks = new();
	SoundHandle gameOverSound;
	bool gameOverPlayed;
	const float ScoreTickMaxPitch = 2.6f;
	// The game-over cleanup knocks the stack apart well below real play; the game-over rumble itself stays full.
	const float CleanupRumble = 0.2f;
	const float ScoreTickPitchPlateau = 100;
	readonly DiamondSlideAudio slideAudio = new();
	float shakeTime;
	float shakeStrength;
	Vector2 shakeDirection;
	int resetVersion = -1;
	const float RotationShakeStrength = 7;
	const float RotationShakeDuration = 0.10f;
	int rotationSpawnVersion;
	float rotationTime = 1;
	Vector2 rotationDirection;
	double lastCrushTime = double.NegativeInfinity;
	public Vector2 ShakeOffset { get; private set; }
	public Vector2 PieceShakeOffset { get; private set; }
	public bool IsMuted { get; private set; }

	/// <summary>Small, cosmetic feedback for a successful player rotation.</summary>
	public void Rotate( DiamondBoard board )
	{
		rotationSpawnVersion = board.SpawnVersion;
		rotationTime = 0;
		float angle = (float)random.NextDouble() * MathF.Tau;
		rotationDirection = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
		PieceShakeOffset = rotationDirection * RotationShakeStrength;
		DiamondHaptics.Pulse( 0.1f, 0.03f, DiamondHaptics.Crisp, 1 );
		if ( IsMuted ) return;
		var sound = PlayImpact( "sfx/diamond_rotate.sound", 1, 1 );
		if ( sound is not null ) impacts.Add( sound );
		if ( impacts.Count > 8 ) { impacts[0].Stop( 0.015f ); impacts.RemoveAt( 0 ); }
	}

	/// <summary>A quick, deep error buzz when a turn is refused and bumps.</summary>
	public void RotateBump()
	{
		if ( IsMuted ) return;
		PrepareRotateBump();
		var sound = Play( rotateBumpSound, RotateBumpVolume, 0.96f + (float)random.NextDouble() * 0.08f );
		if ( sound is not null ) impacts.Add( sound );
		if ( impacts.Count > 8 ) { impacts[0].Stop( 0.015f ); impacts.RemoveAt( 0 ); }
	}

	/// <summary>A short tick per number reaching the score. Awards count +1, +2, +3...
	/// within a turn, so each successive delivery steps the pitch up.</summary>
	public void ScoreArrivals( IReadOnlyList<long> arrivals )
	{
		if ( IsMuted || arrivals.Count == 0 ) return;
		scoreTicks ??= new();
		scoreTicks.RemoveAll( h => !h.IsValid );
		foreach ( var points in arrivals )
		{
			float pitch = ScoreTickPitch( points );
			var sound = PlayImpact( "sfx/score_add.sound", 1, pitch );
			if ( sound is not null ) scoreTicks.Add( sound );
			// A crystalline layer swells in over long crush runs: silent at +1.
			float shimmer = ScoreShimmerVolume( points );
			if ( shimmer > 0 )
			{
				var layer = PlayImpact( "sfx/score_shimmer.sound", shimmer, pitch );
				if ( layer is not null ) scoreTicks.Add( layer );
			}
			while ( scoreTicks.Count > 10 ) { scoreTicks[0].Stop( 0.015f ); scoreTicks.RemoveAt( 0 ); }
		}
	}

	/// <summary>Rises ~0.032 per award at first, easing out quadratically to flatten at +100.</summary>
	public static float ScoreTickPitch( long points )
	{
		float remaining = 1 - Math.Clamp( (points - 1) / (ScoreTickPitchPlateau - 1), 0, 1 );
		return 1 + (ScoreTickMaxPitch - 1) * (1 - remaining * remaining);
	}

	/// <summary>0 at +1, half at +30, full from +50, linear between.</summary>
	public static float ScoreShimmerVolume( long points ) => points <= 30
		? Math.Clamp( (points - 1) / 29f, 0, 1 ) * 0.5f
		: MathF.Min( 1, 0.5f + (points - 30) / 20f * 0.5f );

	public void ToggleMute()
	{
		IsMuted = !IsMuted;
		if ( IsMuted ) StopAudio();
	}

	public void Prepare()
	{
		PrepareCrush();
		PrepareRotateBump();
	}

	static void PrepareRotateBump()
	{
		if ( rotateBumpSound is not null && rotateBumpSound.IsValid ) return;
		rotateBumpSound = SoundFile.FromPcm( "diamonds-rotate-bump-v1.vsnd", DiamondSoundSamples.RotateBump(),
			new SoundFile.PcmOptions { Rate = DiamondSoundSamples.Rate } );
	}

	static void PrepareCrush()
	{
		if ( crushSound is not null && crushSound.IsValid ) return;
		crushSound = SoundFile.FromPcm( "diamonds-chip-crush-v1.vsnd", DiamondSoundSamples.Crush(),
			new SoundFile.PcmOptions { Rate = DiamondSoundSamples.Rate } );
	}

	/// <summary>The crunchy voice for crushed chips, louder for more chips at once.</summary>
	public static SoundHandle PlayCrush( int chips, float pitch )
	{
		PrepareCrush();
		return Play( crushSound, MathF.Min( 0.2f, 0.072f + MathF.Sqrt( chips ) * 0.018f ), pitch );
	}

	/// <summary>The impact sound for the lowest health a hit left, or a plain impact when it dealt no damage.</summary>
	public static string ImpactSound( int? lowestDamagedHealth ) => lowestDamagedHealth switch
	{
		0 => "sfx/diamond_break.sound",
		1 => "sfx/diamond_dmg_1.sound",
		2 => "sfx/diamond_dmg_2.sound",
		_ => "sfx/diamond_impact.sound"
	};

	/// <summary>Saturates the biggest chain hits, while preserving the light/heavy distinction.</summary>
	public static float ImpactStrength( float speed ) => 1 - MathF.Exp( -speed / (DiamondBoard.Height * 10) );

	/// <summary>Impact volume from its strength and the moving assembly's size, before any jitter or ducking.</summary>
	public static float ImpactVolume( float strength, int cells ) =>
		Math.Clamp( (0.09f + strength * 0.75f) * Math.Clamp( MathF.Sqrt( cells / 3f ), 0.65f, 1.25f ), 0, 0.88f );

	/// <summary>Maps instantaneous slide velocity onto the slide foley's 0–1 speed.</summary>
	public static float SlideAudioSpeed( float velocity ) => 1 - MathF.Exp( -velocity / (DiamondBoard.Height * 9) );

	public void Update( DiamondBoard board, float delta, int crushedChips = 0 )
	{
#if STANDALONE
#endif
		if ( resetVersion != board.ResetVersion )
		{
			Stop();
			resetVersion = board.ResetVersion;
		}
		// One sting the moment the run ends, before the cleanup cascade plays out.
		if ( !board.GameOver ) gameOverPlayed = false;
		else if ( !gameOverPlayed )
		{
			gameOverPlayed = true;
			if ( !IsMuted ) gameOverSound = PlayImpact( "sfx/gameover_2.sound", 1, 1 );
			DiamondHaptics.Pulse( 0.8f, 0.5f, DiamondHaptics.Heavy, 1.6f );
		}
		if ( board.Paused )
		{
			Suspend();
			return;
		}
		rotationTime += delta;
		if ( board.SpawnVersion != rotationSpawnVersion || board.IsResolving || board.GameOver ) rotationTime = 1;
		// A stronger initial kick, with cubic ease-out recovery to rest in 100 ms.
		float rotationEnvelope = MathF.Max( 0, 1 - rotationTime / RotationShakeDuration );
		PieceShakeOffset = rotationDirection * (RotationShakeStrength * rotationEnvelope * rotationEnvelope * rotationEnvelope * MathF.Cos( rotationTime * 145 ));
		Prepare();
		impacts.RemoveAll( h => !h.IsValid );
		// This update runs once per rendered frame. All substeps/chips share one
		// crunchy voice, respecting the same mute/pause/restart lifecycle as impacts.
		if ( crushedChips > 0 && !IsMuted && lastCrushTime != Time.NowDouble )
		{
			lastCrushTime = Time.NowDouble;
			var sound = PlayCrush( crushedChips, 0.92f + (float)random.NextDouble() * 0.18f );
			if ( sound is not null ) impacts.Add( sound );
			if ( impacts.Count > 8 ) { impacts[0].Stop( 0.015f ); impacts.RemoveAt( 0 ); }
		}
		shakeTime += delta;
		foreach ( var impact in board.Impacts )
		{
			float strength = ImpactStrength( impact.Speed );
			float mass = Math.Clamp( MathF.Sqrt( impact.CellCount / 3f ), 0.65f, 1.25f );
			float volume = ImpactVolume( strength, impact.CellCount );
			if ( board.GameOver && impact.LowestDamagedHealth.HasValue ) volume *= 0.4f;
			float pitch = 0.88f + strength * 0.28f + ((float)random.NextDouble() - 0.5f) * 0.08f;
			if ( !IsMuted )
			{
				var sound = PlayImpact( ImpactSound( impact.LowestDamagedHealth ), volume, pitch );
				if ( sound is not null ) impacts.Add( sound );
			}
			ImpactHaptics( impact, strength, board.GameOver ? CleanupRumble : 1 );
			// Rapid chain contacts have a strict voice limit as well as a shake limit.
			if ( impacts.Count > 8 ) { impacts[0].Stop( 0.015f ); impacts.RemoveAt( 0 ); }
			shakeStrength = MathF.Min( 7, shakeStrength * MathF.Exp( -shakeTime * 19 ) + 0.35f + strength * 5.5f * mass );
			var axis = impact.ShakeAxis;
			shakeDirection = new Vector2( axis.X, axis.Y );
			shakeTime = 0;
		}
		float envelope = shakeStrength * MathF.Exp( -shakeTime * 19 );
		// One oscillator on the contact-aware impact axis: no perpendicular wobble.
		ShakeOffset = envelope < 0.02f ? Vector2.Zero
			: shakeDirection * (envelope * MathF.Cos( shakeTime * 108 ));

		ShatterHaptics( board );

		if ( IsMuted || !board.IsSliding )
		{
			slideAudio.Stop();
			return;
		}
		slideAudio.Update( SlideAudioSpeed( board.SlideSpeed ), delta );
	}

	/// <summary>
	/// Sharp hits for damage (a crisper, grinding buzz when a slide deals it), a heavy slam when the player's piece
	/// hits the floor hard, a tiny tap for gentler player landings, and faint thuds for settling cells.
	/// </summary>
	static void ImpactHaptics( DiamondBoard.Impact impact, float strength, float scale )
	{
		if ( impact.LowestDamagedHealth is int health )
		{
			if ( impact.Slide )
			{
				DiamondHaptics.Pulse( (health <= 1 ? 0.7f : 0.6f) * scale, 0.14f, 0.85f, 1.5f );
				DiamondHaptics.Pulse( 0.3f * scale, 0.05f, DiamondHaptics.Heavy, 4 );
			}
			else DiamondHaptics.DamageHit( (health <= 1 ? 0.85f : 0.7f) * scale );
			return;
		}
		if ( impact.Active )
		{
			if ( impact.Floor && impact.VelocityX == 0 && impact.VelocityY > DiamondBoard.DamageSpeedThreshold )
				DiamondHaptics.Pulse( (0.45f + 0.4f * strength) * scale, 0.1f, DiamondHaptics.Heavy, 4 );
			else DiamondHaptics.Pulse( (0.1f + 0.25f * strength * strength) * scale, 0.045f, DiamondHaptics.Neutral, 2 );
			return;
		}
		DiamondHaptics.Pulse( (0.05f + 0.13f * strength) * scale, 0.04f, DiamondHaptics.Heavy, 2 );
	}

	/// <summary>A longer, heavier rumble as broken diamonds burst, after the damage pause.</summary>
	static void ShatterHaptics( DiamondBoard board )
	{
		int count = board.ShatterBursts.Count;
		if ( count == 0 ) return;
		float scale = board.GameOver ? CleanupRumble : 1;
		DiamondHaptics.Pulse( MathF.Min( 1, 0.6f + 0.12f * MathF.Sqrt( count ) ) * scale,
			0.28f + 0.03f * MathF.Min( count, 5 ), DiamondHaptics.Heavy, 2 );
		DiamondHaptics.Pulse( 0.35f * scale, 0.12f, DiamondHaptics.Crisp, 3 );
	}

	public static SoundHandle PlayImpact( string path, float volume, float pitch )
	{
		var handle = Sound.Play( path );
		if ( handle is null ) return null;
		handle.Volume *= volume * DiamondSettings.EffectsGain;
		handle.Pitch *= pitch;
		handle.SpacialBlend = 0;
		return handle;
	}

	static SoundHandle Play( SoundFile file, float volume, float pitch, float fade = 0 )
	{
		var handle = Sound.PlayFile( file, volume * DiamondSettings.EffectsGain, pitch, fadeInTime: fade );
		if ( handle is null ) return null;
		handle.ListenLocal = true;
		handle.SpacialBlend = 0;
		handle.DistanceAttenuation = false;
		handle.OcclusionEnabled = false;
		handle.ReverbEnabled = false;
		handle.AirAbsorption = false;
		return handle;
	}

	void StopAudio()
	{
		slideAudio.Stop();
		foreach ( var sound in impacts ) sound.Stop();
		impacts.Clear();
		gameOverSound?.Stop();
		gameOverSound = null;
		if ( scoreTicks is null ) return;
		foreach ( var sound in scoreTicks ) sound.Stop();
		scoreTicks.Clear();
	}

	/// <summary>Silence feedback while preserving the paused piece animation.</summary>
	public void Suspend()
	{
		StopAudio();
		shakeStrength = 0;
		ShakeOffset = Vector2.Zero;
	}

	public void Stop()
	{
		Suspend();
		rotationTime = 1;
		PieceShakeOffset = Vector2.Zero;
	}
}