DiamondScoreEffects.cs
using System;
using System.Collections.Generic;
using Sandbox;
namespace Diamonds;
/// <summary>Cosmetic score delivery, independent of shard physics and gameplay randomness.</summary>
public sealed class DiamondScoreEffects
{
// Board-relative coordinates; the HUD and pickups share one unshaken canvas.
public Vector2 Target { get; set; } = new( -160, 208 );
readonly DiamondScoreLayout layout = new();
public DiamondScoreLayout Layout { get { layout.Update( DisplayedScore ); return layout; } }
public const string ScoreFont = "Enixe";
// Enixe's capitals occupy about 57% of its em; 40px preserves the old 23px glyph height.
public const float PickupFontSize = 40;
const float NormalArrivalSpacing = 0.07f;
// Preserve the readable rhythm for small bursts, but drain crowded screens faster.
static float ArrivalSpacing( int count ) => MathF.Max( 0.025f, NormalArrivalSpacing / MathF.Sqrt( MathF.Max( 1, count / 10f ) ) );
public static float ValueScale( long points ) => 1 + (Math.Clamp( points, 1, 100 ) - 1) / 99f;
public static Vector2 MeasureNumber( string value ) =>
new TextRendering.Scope( value, Color.White, PickupFontSize, ScoreFont ).Measure();
public sealed class Pickup
{
public Vector2 Position, Velocity, PullStart, PullVelocity;
public Vector2 TargetSample = new( 0.5f );
public long Points;
public int ColorIndex;
public float Age, FloatDuration, PullDuration, Gravity, HorizontalRadius;
public float ArrivalInterval = NormalArrivalSpacing;
public float PullProgress
{
get
{
if ( Age <= FloatDuration ) return 0;
if ( Age >= FloatDuration + PullDuration ) return 1;
float elapsed = Age - FloatDuration;
// Exponentially ramp travel speed in real seconds. A queued flight
// still finishes with a fast snap instead of scaling all its speed down.
// Negative exponents keep long arrival queues numerically bounded.
const float acceleration = 5;
return MathF.Exp( -acceleration * (PullDuration - elapsed) )
* (1 - MathF.Exp( -acceleration * elapsed )) / (1 - MathF.Exp( -acceleration * PullDuration ));
}
}
}
public sealed class Spark
{
public Vector2 Position, Velocity;
public int ColorIndex;
public float Age, Lifetime;
}
readonly Random random;
readonly List<Pickup> pickups = new();
readonly List<Spark> sparks = new();
DiamondBoard currentBoard;
int resetVersion;
float bounceAge = 1, colorHeat, effectTime, nextArrivalTime;
float bounceSpeed = 1;
public long DisplayedScore { get; private set; }
public float ColorPhase { get; private set; }
public float ColorSpeed { get; private set; } = 0.28f;
public float Scale
{
get
{
// Straight ramps and hard corners: compress immediately on every hit,
// punch out, hold briefly, then cut back with a tiny undershoot.
if ( bounceAge < 0.035f ) return 0.92f + 0.38f * (bounceAge / 0.035f);
if ( bounceAge < 0.05f ) return 1.30f;
if ( bounceAge < 0.12f ) return 1.30f - 0.34f * ((bounceAge - 0.05f) / 0.07f);
if ( bounceAge < 0.155f ) return 0.96f + 0.04f * ((bounceAge - 0.12f) / 0.035f);
return 1;
}
}
// Not readonly: a hotloaded instance may predate this field.
List<long> arrivals = new();
/// <summary>Point values delivered into the score during the latest update.</summary>
public IReadOnlyList<long> Arrivals => arrivals ??= new();
public IReadOnlyList<Pickup> Pickups => pickups;
public IReadOnlyList<Spark> Sparks => sparks;
public DiamondScoreEffects( int? seed = null ) => random = seed.HasValue ? new Random( seed.Value ) : new Random();
public void Clear()
{
pickups.Clear();
sparks.Clear();
arrivals?.Clear();
currentBoard = null;
DisplayedScore = 0;
bounceAge = 1;
bounceSpeed = 1;
colorHeat = ColorPhase = effectTime = nextArrivalTime = 0;
ColorSpeed = 0.28f;
}
public void Update( DiamondBoard board, IReadOnlyList<DiamondDebris.CrushAward> awards, float delta )
{
(arrivals ??= new()).Clear();
if ( board != currentBoard || board.ResetVersion != resetVersion )
{
Clear();
currentBoard = board;
resetVersion = board.ResetVersion;
DisplayedScore = board.Score;
foreach ( var award in awards ) DisplayedScore -= award.Points;
}
if ( board.Paused ) return;
// Finish earned deliveries even if the final placement ended the game.
float remaining = Math.Clamp( delta, 0, 0.1f );
// Advance once per rendered update. An arrival below holds its compressed
// scale for that frame rather than spending the punch inside substeps.
bounceAge = MathF.Min( 1, bounceAge + remaining * bounceSpeed );
while ( remaining > 0 )
{
float step = MathF.Min( remaining, 1 / 120f );
Advance( step );
remaining -= step;
}
foreach ( var award in awards )
{
// Match the font's actual width, including the plus, outline and capped launch pop.
float maxScale = MathF.Min( 2, ValueScale( award.Points ) * 1.28f );
float radius = (MeasureNumber( $"+{award.Points}" ).x * 0.5f + 2) * maxScale;
pickups.Add( new Pickup { Position = award.Position.WithX( Math.Clamp( award.Position.x, radius, DiamondBoard.BoardWidth - radius ) ),
Points = award.Points, ColorIndex = award.ColorIndex, HorizontalRadius = radius,
Velocity = new Vector2( Range( -240, 240 ), Range( -470, -330 ) ), Gravity = Range( 240, 460 ),
FloatDuration = Range( 0.8f, 1.2f ), PullDuration = Range( 0.32f, 0.46f ) } );
}
}
void Advance( float delta )
{
effectTime += delta;
colorHeat *= MathF.Exp( -delta * 1.7f );
ColorSpeed += (0.28f + colorHeat - ColorSpeed) * (1 - MathF.Exp( -delta * 5 ));
ColorPhase = (ColorPhase + ColorSpeed * delta) % DiamondBoard.ColorCount;
for ( int i = sparks.Count - 1; i >= 0; i-- )
{
var spark = sparks[i];
spark.Age += delta;
spark.Velocity += new Vector2( 0, 150 * delta );
spark.Position += spark.Velocity * delta;
if ( spark.Age >= spark.Lifetime ) sparks.RemoveAt( i );
}
for ( int i = pickups.Count - 1; i >= 0; i-- )
{
var pickup = pickups[i];
float floatStep = Math.Clamp( pickup.FloatDuration - pickup.Age, 0, delta );
if ( floatStep > 0 )
{
pickup.Position += pickup.Velocity * floatStep + new Vector2( 0, pickup.Gravity * floatStep * floatStep * 0.5f );
pickup.Velocity += new Vector2( 0, pickup.Gravity * floatStep );
// Reflect overshoot as well as velocity, only during the free flight.
// Once pulling, numbers can cross the wall into the score's letterbox.
float left = pickup.HorizontalRadius, right = DiamondBoard.BoardWidth - left;
if ( pickup.Position.x < left )
{
pickup.Position = pickup.Position.WithX( left + (left - pickup.Position.x) );
pickup.Velocity = pickup.Velocity.WithX( MathF.Abs( pickup.Velocity.x ) * 0.82f );
}
else if ( pickup.Position.x > right )
{
pickup.Position = pickup.Position.WithX( right - (pickup.Position.x - right) );
pickup.Velocity = pickup.Velocity.WithX( -MathF.Abs( pickup.Velocity.x ) * 0.82f );
}
// Keep the number readable even when a shard is crushed near the ceiling.
if ( pickup.Position.y < 22 ) { pickup.Position = pickup.Position.WithY( 22 ); pickup.Velocity = pickup.Velocity.WithY( MathF.Max( 0, pickup.Velocity.y ) ); }
pickup.PullStart = pickup.Position;
pickup.PullVelocity = pickup.Velocity;
if ( pickup.Age + delta >= pickup.FloatDuration )
{
pickup.TargetSample = new Vector2( Range( 0, 1 ), Range( 0, 1 ) );
// Give closely packed deliveries separate visible hits. Stretch the
// pull, never the ballistic phase, so waiting numbers stay on screen.
float pullStartTime = effectTime - delta + floatStep;
float arrivalTime = MathF.Max( pullStartTime + pickup.PullDuration, nextArrivalTime );
pickup.PullDuration = arrivalTime - pullStartTime;
pickup.ArrivalInterval = ArrivalSpacing( pickups.Count );
nextArrivalTime = arrivalTime + pickup.ArrivalInterval;
}
}
pickup.Age += delta;
if ( pickup.Age <= pickup.FloatDuration ) continue;
float t = pickup.PullProgress;
var previous = pickup.Position;
// A short curved pull accelerates into the score; preserve the launch tangent.
var control = pickup.PullStart + pickup.PullVelocity * MathF.Min( pickup.PullDuration, 0.46f ) * 0.5f;
var destination = Target + Layout.ArrivalOffset( pickup.TargetSample ) * Scale;
pickup.Position = pickup.PullStart * ((1 - t) * (1 - t)) + control * (2 * (1 - t) * t) + destination * (t * t);
pickup.Velocity = (pickup.Position - previous) / delta;
if ( t < 1 ) continue;
DisplayedScore += pickup.Points;
arrivals.Add( pickup.Points );
bounceAge = 0;
bounceSpeed = NormalArrivalSpacing / MathF.Max( 0.025f, pickup.ArrivalInterval );
colorHeat = MathF.Min( 5, colorHeat + 0.42f );
for ( int s = 0; s < 9; s++ )
{
float angle = Range( 0, MathF.PI * 2 );
sparks.Add( new Spark { Position = pickup.Position, ColorIndex = pickup.ColorIndex, Lifetime = Range( 0.22f, 0.42f ),
Velocity = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * Range( 65, 210 ) } );
}
pickups.RemoveAt( i );
}
if ( sparks.Count > 360 ) sparks.RemoveRange( 0, sparks.Count - 360 );
}
float Range( float min, float max ) => min + (max - min) * (float)random.NextDouble();
}