DiamondPanel.Title.cs
using System;
using Sandbox;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	bool titleScreen;
	public bool TitleScreen
	{
		get => titleScreen;
		set
		{
			if ( titleScreen == value ) return;
			titleScreen = value;
			// Every visit to the title starts with PLAY selected.
			titleSelection = 0;
			if ( titleButtonBlend is not null ) Array.Clear( titleButtonBlend );
			TitleAnimationTime = 0;
			titleColorTime = 0;
			titleSparkAccumulator = 0;
			titleEffects.Clear();
			titleDye.Clear();
			StopTitleAudio();
		}
	}
	public Action PlayRequested { get; set; }
	public Action QuitRequested { get; set; }
	public float TitleAnimationTime { get; private set; }
	const float TitleSlideDuration = 0.72f;
	const float TitleShineDuration = 0.24f;
	const float TitleAngle = -51;
	float titleColorTime;
	float titleSparkAccumulator;
	readonly DiamondTitleEffects titleEffects = new();
	readonly DiamondMenuDye titleDye = new();
	Texture titleDyeTexture;
	Color32[] titleDyePixels;
	DyeTextureStamp titleDyeTextureStamp;
	// Only the editor comparison tools enable the retained rectangle reference.
	bool titleDyeReferenceTiles = false;
	public DiamondTitleEffects TitleEffects => titleEffects;
	public DiamondMenuDye TitleDye => titleDye;
	readonly DiamondSlideAudio titleSlideAudio = new();
	SoundHandle titleImpact;
	// Stacked below the title's lowest glyph (y ~650), ending above the canvas bottom.
	static Rect TitleMenuButton( int index ) => new( (CanvasWidth - 360) * 0.5f, 688 + index * 74, 360, 64 );
	static Rect PlayButton => TitleMenuButton( 0 );
	static Rect LeaderboardButton => TitleMenuButton( 1 );
	static Rect InstructionsButton => TitleMenuButton( 2 );
	static Rect QuitButton => TitleMenuButton( 3 );
	// Refilled on use: hotload keeps static values and leaves new fields null.
	Rect[] titleButtons;
	/// <summary>Solid walls for the menu dye and sparks.</summary>
	Rect[] TitleButtons
	{
		get
		{
			titleButtons ??= new Rect[4];
			for ( int i = 0; i < titleButtons.Length; i++ ) titleButtons[i] = TitleMenuButton( i );
			return titleButtons;
		}
	}
	Vector2 TitleShakeOffset => new Vector2( -0.6293f, 0.7771f ) *
		(TitleShake( TitleAnimationTime - TitleSlideDuration ) + TitleShake( TitleAnimationTime - TitleSlideDuration * 2 ));

	public void StopTitleAudio()
	{
		titleSlideAudio.Stop();
		titleImpact?.Stop();
		titleImpact = null;
	}

	public void UpdateTitleScreen( float delta )
	{
		if ( !TitleScreen ) { StopTitleAudio(); return; }
		UpdateTitleButtonRainbow( MathF.Max( 0, delta ) );
		if ( ModalOpen ) { StopTitleAudio(); return; }
		delta = MathF.Max( 0, delta );
		float previous = TitleAnimationTime;
		// Substep cosmetic physics and emission together, including frames spanning a landing.
		float remaining = MathF.Min( delta, MathF.Max( 0, 4.5f - previous ) );
		while ( remaining > 0 )
		{
			float step = MathF.Min( remaining, 1f / 120 );
			float before = TitleAnimationTime;
			TitleAnimationTime = MathF.Min( 4.5f, before + step );
			titleEffects.Update( step, TitleViewport, TitleButtons );
			UpdateTitleDye( step );
			for ( int word = 0; word < 2; word++ )
			{
				float start = word * TitleSlideDuration, hit = start + TitleSlideDuration;
				float sliding = MathF.Max( 0, MathF.Min( TitleAnimationTime, hit ) - MathF.Max( before, start ) );
				titleSparkAccumulator += sliding * 190;
				while ( titleSparkAccumulator >= 1 )
				{
					titleSparkAccumulator--;
					float speed = MathF.Pow( Math.Clamp( (TitleAnimationTime - start) / TitleSlideDuration, 0, 1 ), 1.8f );
					var local = new Vector2( titleEffects.Range( -345, 335 ), 49 );
					var position = TitleWordPoint( word, local, TitleAnimationTime );
					if ( TitleViewport.IsInside( position ) ) titleEffects.EmitSlide( position, TitleWordColor( word, titleColorTime + TitleAnimationTime - previous ), speed );
				}
				if ( before < hit && TitleAnimationTime >= hit )
				{
					var color = TitleWordColor( word, titleColorTime + hit - previous );
					if ( word == 0 ) titleEffects.EmitImpact( TitleContactPoint( word ), color );
					else titleEffects.EmitFaceImpact(
						TitleWordPoint( 1, DiamondTitleGlyphs.Words[1][0][9], hit ),
						TitleWordPoint( 1, DiamondTitleGlyphs.Words[1][0][10], hit ), color );
				}
			}
			remaining -= step;
		}
		float idle = delta - (TitleAnimationTime - previous);
		if ( idle > 0.0001f ) UpdateTitleDye( idle );
		titleColorTime = (titleColorTime + delta) % 100;
		for ( int word = 1; word <= 2; word++ )
		{
			float landing = TitleSlideDuration * word;
			if ( previous >= landing || TitleAnimationTime < landing ) continue;
			titleSlideAudio.Stop();
			if ( IsMuted ) continue;
			titleImpact?.Stop();
			titleImpact = Sound.Play( "sfx/diamond_impact.sound" );
			if ( titleImpact is null ) continue;
			titleImpact.SpacialBlend = 0;
			titleImpact.Volume *= (word == 1 ? 0.72f : 0.85f) * DiamondSettings.EffectsGain;
			titleImpact.Pitch *= word == 1 ? 0.92f : 0.80f;
		}
		if ( IsMuted ) { StopTitleAudio(); return; }
		if ( TitleAnimationTime < TitleSlideDuration * 2 )
		{
			float progress = (TitleAnimationTime % TitleSlideDuration) / TitleSlideDuration;
			// The derivative of the power-2.8 slide drives the same foley as gameplay.
			titleSlideAudio.Update( MathF.Pow( progress, 1.8f ), delta );
		}
		else titleSlideAudio.Stop();
	}

	public void HandleTitleClick( Vector2 mouse, Vector2 size )
	{
		if ( !TitleScreen || ModalOpen || TransitionPending ) return;
		float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
		if ( scale <= 0 ) return;
		var point = (mouse - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		point -= TitleShakeOffset;
		for ( int i = 0; i < TitleButtonCount; i++ )
			if ( TitleMenuButton( i ).IsInside( point ) ) { ActivateTitleButton( i ); return; }
	}

	void DrawTitleScreen( Painter p, float scale )
	{
		using var scope = p.Scope();
		p.Translate( TitleShakeOffset );
		DrawTitleDye( p );
		DrawTitleWord( p, "DIAMOND", 0 );
		DrawTitleWord( p, "GRINDER", 1 );
		var size = Box.Rect.Size;
		var mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		mouse -= TitleShakeOffset;
		// The fluid layer is blurred, so cover its filtered fringe inside the solid buttons.
		p.Stroke = Stroke.None;
		p.Fill = Color.Parse( "#0b1017" ).Value;
		foreach ( var button in TitleButtons ) p.Rect( button );
		// The selection, not the mouse, lights title buttons; hovering one selects it.
		DrawTitleButton( p, PlayButton, "PLAY", mouse, scale, selected: titleSelection == 0, labelColor: TitleButtonLabel( 0 ) );
		DrawTitleButton( p, LeaderboardButton, "LEADERBOARDS", mouse, scale, selected: titleSelection == 1, labelColor: TitleButtonLabel( 1 ) );
		DrawTitleButton( p, InstructionsButton, "INSTRUCTIONS", mouse, scale, selected: titleSelection == 2, labelColor: TitleButtonLabel( 2 ) );
		DrawTitleButton( p, QuitButton, "QUIT", mouse, scale, danger: true, selected: titleSelection == 3, labelColor: TitleButtonLabel( 3 ) );
		DrawTitleEffects( p );
	}

	static float TitleShake( float age ) => age < 0 || age > 0.48f ? 0
		: 11 * MathF.Exp( -age * 10 ) * MathF.Cos( age * 95 );

	static Color TitleWordColor( int word, float time )
	{
		// DIAMOND reaches pure blue exactly as it lands, so its darker face shows the shine.
		float phase = word == 0 ? 1 + (time - TitleSlideDuration) * 0.85f : 3 - time * 1.05f;
		// Wrap the reverse cycle into the palette's positive range before interpolating.
		return ScoreColor( (phase % Palette.Length + Palette.Length) % Palette.Length );
	}

	Rect TitleViewport
	{
		get
		{
			var size = Box.Rect.Size;
			float scale = MathF.Min( size.x / CanvasWidth, size.y / CanvasHeight );
			var extent = scale > 0 ? size / scale : new Vector2( CanvasWidth, CanvasHeight );
			return new Rect( (new Vector2( CanvasWidth, CanvasHeight ) - extent) * 0.5f, extent );
		}
	}

	Vector2 TitleWordPoint( int word, Vector2 local, float time )
	{
		float progress = Math.Clamp( (time - word * TitleSlideDuration) / TitleSlideDuration, 0, 1 );
		float slide = (910 + MathF.Max( 0, -TitleViewport.Top ) / 0.777146f) * (1 - MathF.Pow( progress, 2.8f ));
		// Compensate for D/G's different lowest vertices, aligning the visible tips.
		local += new Vector2( slide + (word == 0 ? -9.72f : 45), word == 0 ? -54 : 54 );
		return new Vector2( CanvasWidth * 0.5f, 374 ) + new Vector2( local.x * 0.62932f + local.y * 0.777146f, -local.x * 0.777146f + local.y * 0.62932f );
	}

	Vector2 TitleContactPoint( int word ) => TitleWordPoint( word,
		word == 0 ? new Vector2( -348.5f, 47.11f ) : new Vector2( -324.64f, 36.14f ), TitleSlideDuration * 2 );

	Vector2 TitleWordCenter( int index )
	{
		float age = TitleAnimationTime - index * TitleSlideDuration;
		float damage = Math.Clamp( (age - TitleSlideDuration) / 0.24f, 0, 1 );
		float magnitude = age < TitleSlideDuration ? 0 : 12 * (1 - damage) * (1 - damage);
		float angle = MathF.Floor( damage * 18 ) * 2.39996f + index * 1.7f;
		return TitleWordPoint( index, Vector2.Zero, TitleAnimationTime ) + new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * magnitude;
	}

	void UpdateTitleDye( float delta ) => titleDye.Update( delta, TitleViewport, TitleButtons,
		TitleWordCenter( 0 ), TitleWordCenter( 1 ) );

	void DrawTitleDye( Painter p )
	{
		if ( HideDye ) return;
		if ( !titleDye.Ready( TitleViewport ) ) UpdateTitleDye( 0 );
		if ( titleDyeReferenceTiles ) { DrawTitleDyeTiles( p ); return; }
		UpdateDyeTexture( titleDye, titleDye.DyeVersion, titleDye.Samples, DiamondMenuDye.Columns, DiamondMenuDye.Rows,
			Color.Parse( "#0b1017" ).Value, "diamonds-menu-dye", Math.Clamp( DyeVibrancy, 0, 1 ), ref titleDyeTexture, ref titleDyePixels, ref titleDyeTextureStamp );
		using var scope = p.Scope();
		p.Clip( TitleViewport );
		DrawDyeLayer( p, titleDyeTexture, TitleViewport, TitleViewport, DyeLayerOpacity( 1 ) );
	}

	void DrawTitleDyeTiles( Painter p )
	{
		if ( !titleDye.Ready( TitleViewport ) ) UpdateTitleDye( 0 );
		using var scope = p.Scope();
		p.Clip( TitleViewport );
		using var layer = p.BeginLayer( TitleViewport, 0.22f, new Painter.Filter { Blur = 6 } );
		var background = Color.Parse( "#0b1017" ).Value;
		p.Stroke = Stroke.None;
		for ( int y = 0; y < DiamondMenuDye.Rows; y++ )
		for ( int x = 0; x < DiamondMenuDye.Columns; x++ )
		{
			float total = 0, weight = 0;
			var rgb = Vector3.Zero;
			for ( int color = 0; color < 5; color++ )
			{
				float amount = titleDye.Sample( x, y, color );
				total += amount; weight += amount * amount;
				rgb += new Vector3( Palette[color].r, Palette[color].g, Palette[color].b ) * (amount * amount);
			}
			if ( weight <= 0 ) continue;
			float coverage = Math.Clamp( (total - 0.04f) / 0.28f, 0, 1 );
			coverage = coverage * coverage * (3 - 2 * coverage);
			rgb /= weight;
			p.Fill = Color.Lerp( background, new Color( rgb.x, rgb.y, rgb.z ), coverage );
			p.Rect( new Rect( titleDye.Position( x, y ) - titleDye.CellSize * 0.5f, titleDye.CellSize + new Vector2( 0.6f ) ) );
		}
	}

	void DrawTitleWord( Painter p, string word, int index )
	{
		float age = TitleAnimationTime - index * TitleSlideDuration;
		if ( age <= 0 ) return;
		var color = TitleWordColor( index, titleColorTime );
		using var scope = p.Scope();
		p.Translate( TitleWordCenter( index ) );
		p.Rotate( TitleAngle );
		p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, 100, color ) { FontWeight = 700, Alignment = TextFlag.Center };
		var bounds = p.MeasureText( word );
		if ( bounds.x <= 0 || bounds.y <= 0 ) return;
		p.Scale( 700 / bounds.x, 1.8f );
		var rect = new Rect( -(bounds + new Vector2( 4 )) * 0.5f, bounds + new Vector2( 4 ) );
		p.Text( word, rect );
		float shine = (age - TitleSlideDuration) / TitleShineDuration;
		if ( shine < 0 || shine >= 1 ) return;
		// Sweep downhill along the glyphs, clipping bright text instead of drawing over its gaps.
		float sweep = bounds.x * (0.65f - shine * 1.3f);
		// A blurred copy of a wider band spills light past the glyph edges.
		using ( p.BeginLayer( new Rect( rect.Position - new Vector2( 32 ), rect.Size + new Vector2( 64 ) ), 0.9f * (1 - shine * shine),
			new Painter.Filter { Blur = 7 } ) )
			DrawTitleShineBands( p, word, rect, bounds, color, sweep, 2.2f, 1 );
		DrawTitleShineBands( p, word, rect, bounds, color, sweep, 1, 0.94f );
	}

	static void DrawTitleShineBands( Painter p, string word, Rect rect, Vector2 bounds, Color color, float sweep, float spread, float brightness )
	{
		float width = bounds.x * 0.0065f * spread;
		for ( int band = 0; band < 25; band++ )
		{
			using var highlight = p.Scope();
			// Overlap the antialiased clip edges so the reflection has no dark seams.
			p.Clip( new Rect( sweep + (band - 12.5f) * width, -bounds.y, width + 1, bounds.y * 2 ) );
			float intensity = 1 - MathF.Abs( band - 12 ) / 13;
			p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, 100, Color.Lerp( color, Color.White, intensity * brightness ) )
				{ FontWeight = 700, Alignment = TextFlag.Center };
			p.Text( word, rect );
		}
	}

	void DrawTitleEffects( Painter p )
	{
		foreach ( var spark in titleEffects.Particles )
		{
			float fade = Math.Clamp( (spark.Lifetime - spark.Age) / 0.45f, 0, 1 );
			var tint = Color.Lerp( spark.Color, Color.White, 0.45f );
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade * 0.25f ), spark.Radius * 3 );
			var tail = spark.Position - spark.Velocity.Normal * MathF.Min( 15, spark.Velocity.Length * 0.012f );
			p.Line( tail, spark.Position );
			p.Stroke = Stroke.Solid( tint.WithAlpha( fade ), spark.Radius );
			p.Line( tail, spark.Position );
		}
		foreach ( var burst in titleEffects.Bursts )
		{
			if ( burst.FaceEnd.HasValue )
			{
				var jolt = TitleWordCenter( 1 ) - TitleWordPoint( 1, Vector2.Zero, TitleAnimationTime );
				DrawFaceFlash( p, burst.Position + jolt, burst.FaceEnd.Value + jolt, burst.Color, burst.Age, burst.Rotation, 0.42f, 2 );
			}
			else DrawSunburst( p, burst.Position, burst.Color, burst.Age / 0.25f, burst.Rotation, 0.42f, 2.4f );
		}
	}

	// PLAY, LEADERBOARDS, INSTRUCTIONS and QUIT rest on blue, yellow, purple and red.
	static readonly int[] TitleButtonPalette = [1, 3, 4, 0];
	const float TitleButtonRainbowRate = 7;
	const float TitleButtonBlendRate = 12;
	// Created on use: hotload leaves new fields null.
	float[] titleButtonBlend;
	float titleButtonRainbowTime;

	void UpdateTitleButtonRainbow( float delta )
	{
		titleButtonBlend ??= new float[TitleButtonCount];
		titleButtonRainbowTime = (titleButtonRainbowTime + delta * TitleButtonRainbowRate) % (Palette.Length * 100);
		// Lit (hovered or selected) labels ease toward the cycling rainbow, and back once unlit.
		float ease = 1 - MathF.Exp( -delta * TitleButtonBlendRate );
		for ( int i = 0; i < titleButtonBlend.Length; i++ )
			titleButtonBlend[i] += ((titleSelection == i ? 1 : 0) - titleButtonBlend[i]) * ease;
	}

	Color TitleButtonLabel( int index )
	{
		float blend = titleButtonBlend is { } values && index < values.Length ? values[index] : 0;
		// Offset each button's phase so neighbours don't flash the same color.
		return Color.Lerp( Palette[TitleButtonPalette[index]], ScoreColor( titleButtonRainbowTime + index * 1.3f ), blend );
	}

	// Quit and Leave warn on hover by flashing their background red, five times a second.
	// A selected title button lights (and warns) as if hovered; only a mouse press over it shows pressed.
	void DrawTitleButton( Painter p, Rect rect, string label, Vector2 mouse, float scale, float fontSize = 34, float brightness = 1, bool danger = false, bool? selected = null, Color? labelColor = null )
	{
		bool hover = !ModalOpen && rect.IsInside( mouse );
		bool lit = selected ?? hover;
		bool pressed = lit && hover && HasActive;
		if ( lit && danger ) p.Fill = Palette[0].WithAlpha( RealTime.Now * 5 % 1 < 0.5f ? (pressed ? 0.7f : 0.55f) : 0.14f );
		else p.Fill = Ink.WithAlpha( pressed ? 0.2f : lit ? 0.12f : 0.045f );
		p.Stroke = Stroke.None;
		p.Rect( rect );
		DrawScoreNumber( p, label, rect.Center, fontSize, (labelColor ?? (lit ? Color.White : Ink)) * brightness );
	}
}