DiamondPanel.Score.cs
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	readonly DiamondScoreLayout fallbackScoreLayout = new();
	static Color ScoreColor( float phase, int step = 1 )
	{
		int index = ((int)phase * step) % Palette.Length;
		float blend = phase - MathF.Floor( phase );
		blend = blend * blend * (3 - 2 * blend);
		return Color.Lerp( Palette[index], Palette[(index + step) % Palette.Length], blend );
	}

	void DrawScore( Painter p, float center )
	{
		var effects = ScoreEffects;
		var origin = new Vector2( Left, Top + boardOffsetY );
		var target = new Vector2( center, NextPreviewCenterY );
		if ( effects is not null ) effects.Target = target - origin;
		var color = ScoreColor( effects?.ColorPhase ?? 0 );
		var layout = effects?.Layout ?? fallbackScoreLayout;
		// Use the actual letterbox, including extra space on wide viewports.
		// Keep 16 units clear on each side; never shrink the digits to fit.
		layout.MaxWidth = (Left - center) * 2 - 32;
		layout.Update( effects?.DisplayedScore ?? Board.Score );
		float scoreScale = effects?.Scale ?? 1;
		for ( int i = 0; i < layout.Rows.Count; i++ )
			DrawScoreNumber( p, layout.Rows[i].Text, target + new Vector2( 0, i * DiamondScoreLayout.RowSpacing * scoreScale ),
				DiamondScoreLayout.FontSize * scoreScale, color );
		if ( effects is null ) return;
		foreach ( var pickup in effects.Pickups )
		{
			var position = origin + pickup.Position;
			var tint = Palette[pickup.ColorIndex % Palette.Length];
			float launch = Math.Clamp( pickup.Age / 0.12f, 0, 1 );
			float pop = 1 + MathF.Sin( launch * MathF.PI ) * 0.28f;
			float scale = MathF.Min( 2, DiamondScoreEffects.ValueScale( pickup.Points ) * pop );
			float pickupSize = DiamondScoreEffects.PickupFontSize * scale * (1 - pickup.PullProgress * 0.45f);
			if ( pickup.PullProgress > 0 )
			{
				var tail = pickup.Velocity.Normal * MathF.Min( 45, pickup.Velocity.Length * 0.018f );
				p.Stroke = Stroke.Solid( tint.WithAlpha( 0.24f ), 2 );
				p.Line( position - tail, position );
			}
			DrawScoreNumber( p, $"+{pickup.Points}", position, pickupSize, tint );
		}
		foreach ( var spark in effects.Sparks )
		{
			float fade = 1 - spark.Age / spark.Lifetime;
			var position = origin + spark.Position;
			var tint = Color.Lerp( Palette[spark.ColorIndex % Palette.Length], Color.White, 0.55f );
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade * fade ), 1.8f * fade );
			p.Line( position - spark.Velocity * 0.024f, position );
		}
	}

	/// <summary>Draw at <paramref name="center"/>; <paramref name="align"/> 0 places the text's left edge there instead.</summary>
	static void DrawScoreNumber( Painter p, string value, Vector2 center, float fontSize, Color color, float align = 0.5f, bool outline = true )
	{
		using var scope = p.Scope();
		p.Translate( center );
		p.Scale( fontSize / DiamondScoreEffects.PickupFontSize );
		p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, DiamondScoreEffects.PickupFontSize, color )
		{
			Alignment = TextFlag.Center,
			Outline = new TextRendering.Outline { Enabled = outline, Color = Color.Black.WithAlpha( 0.8f ), Size = 1.5f }
		};
		// Keep layout at a fixed size; Painter transforms animate the cached glyphs.
		var bounds = p.MeasureText( value ) + new Vector2( 4 );
		p.Text( value, new Rect( new Vector2( -bounds.x * align, -bounds.y * 0.5f ), bounds ) );
	}

	// Advances at PickupFontSize, measured on first use.
	static float[] timeAdvances;

	/// <summary>
	/// A character's cell width in a time. Enixe's numerals are proportional only because 1 is narrow,
	/// so every digit takes the widest numeral's cell and a ticking time keeps its width and positions.
	/// </summary>
	static float TimeAdvance( char c )
	{
		if ( c >= 128 ) return DiamondScoreEffects.MeasureNumber( c.ToString() ).x;
		if ( timeAdvances is null )
		{
			float digit = 0;
			for ( char d = '0'; d <= '9'; d++ ) digit = MathF.Max( digit, DiamondScoreEffects.MeasureNumber( d.ToString() ).x );
			// Don't keep widths measured before the font is available.
			if ( digit <= 0 ) return 0;
			timeAdvances = new float[128];
			for ( char d = '0'; d <= '9'; d++ ) timeAdvances[d] = digit;
		}
		if ( timeAdvances[c] <= 0 ) timeAdvances[c] = DiamondScoreEffects.MeasureNumber( c.ToString() ).x;
		return timeAdvances[c];
	}

	static float MeasureTimeNumber( string value, float fontSize )
	{
		float width = 0;
		foreach ( char c in value ) width += TimeAdvance( c );
		return width * fontSize / DiamondScoreEffects.PickupFontSize;
	}

	/// <summary>Draw a time like <see cref="DrawScoreNumber"/>, but with each glyph centered in a fixed-width cell.</summary>
	static void DrawTimeNumber( Painter p, string value, Vector2 center, float fontSize, Color color, float align = 0.5f, bool outline = true )
	{
		float scale = fontSize / DiamondScoreEffects.PickupFontSize;
		float x = center.x - MeasureTimeNumber( value, fontSize ) * align;
		foreach ( char c in value )
		{
			float advance = TimeAdvance( c ) * scale;
			DrawScoreNumber( p, c.ToString(), new Vector2( x + advance * 0.5f, center.y ), fontSize, color, outline: outline );
			x += advance;
		}
	}
}