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