DiamondPanel.Tutorial.cs
using System;
using Sandbox;
using Sandbox.UI;

namespace Diamonds;

public sealed partial class DiamondPanel
{
	public bool TutorialOpen { get; private set; }
	public int TutorialPage { get; private set; }
	public float TutorialTime { get; private set; }
	bool tutorialWasPaused;
	DiamondBoard tutorialBoard;
	float tutorialColorPhase;
	const float TutorialFallDuration = 0.28f;
	const float TutorialFallPower = 2.6f;
	const float TutorialSlideDuration = 0.42f;
	const float TutorialCornerCycle = 1.8f;
	const float TutorialSlideCycle = 2.0f;
	const float TutorialShardCycle = 2.9f;
	const float TutorialCornerLanding = 0.6f + TutorialFallDuration;
	const float TutorialSlideStart = 0.5f;
	const float TutorialSlideLength = 156.5f;
	// A clock jump (a screenshot seek or a long hitch) skips its sounds instead of firing them all at once.
	const float TutorialMaxSoundStep = 0.25f;
	// Not readonly: a hotloaded panel may predate these fields.
	DiamondSlideAudio tutorialSlideAudio;
	List<SoundHandle> tutorialSounds;
	static Rect TutorialButton( Vector2 size ) => new( LetterboxCenter( size, true ) - 136, Top + 716, 272, 64 );
	static Rect TutorialTab( int page ) => new( 218 + page * 64, 24, 56, 44 );
	static Rect TutorialClose( Rect frame, float layoutWidth = 620 )
	{
		float scale = frame.Width / layoutWidth;
		return new Rect( frame.Right - 72 * scale, frame.Top + 20 * scale, 52 * scale, 52 * scale );
	}
	// Layout and hit testing share screen-pixel coordinates, independent of UI scaling.
	static Rect TutorialFrame( Vector2 size )
	{
		float scale = MathF.Min( size.x * 0.5f / 620, size.y * 0.62f / 500 );
		var extent = new Vector2( 620, 500 ) * scale;
		return new Rect( (size - extent) * 0.5f, extent );
	}
	static float TutorialScale( Rect frame ) => frame.Width / 620;
	static Vector2 TutorialOrigin( Rect frame ) => frame.Position;

	public void OpenTutorial()
	{
		if ( ModalOpen || Board is null ) return;
		tutorialBoard = Board;
		tutorialWasPaused = Board.Paused;
		if ( !Board.Paused ) Board.TogglePause();
		TutorialOpen = true;
		StopTitleAudio();
		SelectTutorialPage( 0 );
	}

	public void CloseTutorial()
	{
		if ( !TutorialOpen ) return;
		if ( tutorialBoard is not null && tutorialBoard.Paused != tutorialWasPaused ) tutorialBoard.TogglePause();
		TutorialOpen = false;
		tutorialBoard = null;
		TutorialTime = 0;
		StopTutorialAudio();
	}

	public void SelectTutorialPage( int page )
	{
		TutorialPage = Math.Clamp( page, 0, 2 );
		TutorialTime = 0;
		StopTutorialAudio();
	}

	public void UpdateTutorial( float delta, bool backspace )
	{
		if ( !TutorialOpen ) return;
		if ( backspace ) { CloseTutorial(); return; }
		float duration = TutorialPage == 0 ? TutorialCornerCycle * 2 : TutorialPage == 1 ? TutorialSlideCycle : TutorialShardCycle;
		float before = TutorialTime;
		TutorialTime = (TutorialTime + MathF.Max( 0, delta )) % duration;
		tutorialColorPhase = (tutorialColorPhase + MathF.Max( 0, delta ) * 2.4f) % Palette.Length;
		UpdateTutorialAudio( before, MathF.Max( 0, delta ), duration );
	}

	public void StopTutorialAudio()
	{
		tutorialSlideAudio?.Stop();
		if ( tutorialSounds is null ) return;
		foreach ( var sound in tutorialSounds ) sound.Stop();
		tutorialSounds.Clear();
	}

	/// <summary>Each demo plays the game's own sounds for its impacts, slide, crushes and score arrivals.</summary>
	void UpdateTutorialAudio( float before, float delta, float duration )
	{
		if ( IsMuted ) { StopTutorialAudio(); return; }
		tutorialSounds ??= new();
		tutorialSounds.RemoveAll( h => !h.IsValid );
		float after = before + delta;
		bool Crossed( float at ) => delta <= TutorialMaxSoundStep && (before < at && after >= at || before < at + duration && after >= at + duration);
		if ( TutorialPage == 0 )
		{
			// Both hits fall at the same speed: the first cracks the pair to 2 HP, the second to 1 HP.
			float strength = DiamondFeedback.ImpactStrength( 92 * TutorialFallPower / TutorialFallDuration );
			for ( int hit = 0; hit < 2; hit++ )
				if ( Crossed( TutorialCornerCycle * hit + TutorialCornerLanding ) )
					PlayTutorialImpact( DiamondFeedback.ImpactSound( 2 - hit ), strength );
		}
		else if ( TutorialPage == 1 )
		{
			float velocity = TutorialSlideLength * 2.8f / TutorialSlideDuration;
			float progress = (TutorialTime - TutorialSlideStart) / TutorialSlideDuration;
			// The derivative of the power-2.8 slide drives the same foley as gameplay.
			if ( progress > 0 && progress < 1 )
				(tutorialSlideAudio ??= new()).Update( DiamondFeedback.SlideAudioSpeed( velocity * MathF.Pow( progress, 1.8f ) ), delta );
			else tutorialSlideAudio?.Stop();
			if ( Crossed( TutorialSlideStart + TutorialSlideDuration ) )
				PlayTutorialImpact( DiamondFeedback.ImpactSound( 2 ), DiamondFeedback.ImpactStrength( velocity ) );
		}
		else
		{
			for ( int i = 0; i < 3; i++ )
			{
				float contact = TutorialShardContactTime( i );
				if ( Crossed( contact ) ) AddTutorialSound( DiamondFeedback.PlayCrush( 1, 0.94f + i * 0.07f ) );
				// Awards count +1, +2, +3, stepping the pitch up as in play.
				if ( Crossed( contact + TutorialShardFlight( i ) ) )
					AddTutorialSound( DiamondFeedback.PlayImpact( "sfx/score_add.sound", 1, DiamondFeedback.ScoreTickPitch( i + 1 ) ) );
			}
			if ( Crossed( TutorialCornerLanding ) )
				PlayTutorialImpact( DiamondFeedback.ImpactSound( null ), DiamondFeedback.ImpactStrength( 186 * TutorialFallPower / TutorialFallDuration ) );
		}
	}

	void PlayTutorialImpact( string path, float strength ) =>
		AddTutorialSound( DiamondFeedback.PlayImpact( path, DiamondFeedback.ImpactVolume( strength, 2 ), 0.88f + strength * 0.28f ) );

	void AddTutorialSound( SoundHandle sound )
	{
		if ( sound is null ) return;
		tutorialSounds.Add( sound );
		while ( tutorialSounds.Count > 8 ) { tutorialSounds[0].Stop( 0.015f ); tutorialSounds.RemoveAt( 0 ); }
	}

	// Three irregular fragments rest on the two slopes, wholly above the floor.
	static Vector2[][] TutorialShards =>
	[
		[new( 450, 318.57f ), new( 465, 321 ), new( 461, 335.85f )],
		[new( 480, 334.28f ), new( 474, 324 ), new( 490, 318.57f )],
		[new( 462, 337.42f ), new( 479, 335.85f ), new( 470, 350 )]
	];

	// Contact occurs when the gem's sloped lower face reaches a fragment,
	// before its tip arrives at the valley. No shard intersects the gem.
	static float TutorialShardContactTime( int shard )
	{
		float contactY = TutorialShards[shard].Min( v => v.y + MathF.Abs( v.x - 470 ) * 44 / 28 );
		return 0.6f + TutorialFallDuration * MathF.Pow( (contactY - 164) / 186, 1 / TutorialFallPower );
	}

	static float TutorialShardFlight( int shard ) => 1.5f + shard * 0.07f;

	protected override void OnMouseDown( MousePanelEvent e )
	{
		if ( e.Button != "mouseleft" ) return;
		// The outgoing screen ignores input while it fades to black.
		if ( TransitionPending ) { e.StopPropagation(); return; }
		if ( LeaderboardOpen )
		{
			HandleLeaderboardClick( MousePosition, Box.Rect.Size );
			e.StopPropagation();
			return;
		}
		if ( TitleScreen && !ModalOpen )
		{
			HandleTitleClick( MousePosition, Box.Rect.Size );
			e.StopPropagation();
			return;
		}
		if ( HandleGameOverClick( MousePosition, Box.Rect.Size ) )
		{
			e.StopPropagation();
			return;
		}
		bool wasOpen = ModalOpen, left = false;
		if ( OptionsOpen ) HandleOptionsClick( MousePosition, Box.Rect.Size );
		else
		{
			HandleTutorialClick( MousePosition, Box.Rect.Size );
			// The letterbox Options and Leave buttons, when this click neither used nor opened the tutorial.
			if ( !wasOpen && !TutorialOpen ) HandleOptionsClick( MousePosition, Box.Rect.Size );
			if ( !wasOpen && !ModalOpen ) left = HandleLeaveClick( MousePosition, Box.Rect.Size );
		}
		if ( wasOpen || ModalOpen || left ) e.StopPropagation();
	}

	public void HandleTutorialClick( Vector2 mouse, Vector2 size )
	{
		if ( !TutorialOpen )
		{
			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;
			if ( TutorialButton( size ).IsInside( point ) ) { PlayClickSound(); OpenTutorial(); }
			return;
		}
		var frame = TutorialFrame( size );
		if ( !frame.IsInside( mouse ) ) { CloseTutorial(); return; }
		if ( TutorialClose( frame ).IsInside( mouse ) ) { PlayClickSound(); CloseTutorial(); return; }
		float modalScale = TutorialScale( frame );
		if ( modalScale <= 0 ) return;
		var local = (mouse - TutorialOrigin( frame )) / modalScale;
		for ( int i = 0; i < 3; i++ )
			if ( TutorialTab( i ).IsInside( local ) ) { PlayClickSound(); SelectTutorialPage( i ); return; }
	}

	void DrawTutorialButton( Painter p, float scale )
	{
		var size = Box.Rect.Size;
		var button = TutorialButton( size );
		var mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
		DrawTitleButton( p, button, "INSTRUCTIONS", mouse, scale, fontSize: 26, brightness: 0.7f );
	}

	void DrawTutorial( Painter p )
	{
		if ( !TutorialOpen ) return;
		using var scope = p.Scope();
		p.Fill = Color.Black.WithAlpha( 0.32f );
		p.Stroke = Stroke.None;
		p.Rect( p.Bounds );
		var frame = TutorialFrame( p.Bounds.Size );
		p.Fill = Color.Parse( "#0d151e" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( frame );
		p.Clip( frame );
		float scale = TutorialScale( frame );
		if ( scale <= 0 ) return;
		var origin = TutorialOrigin( frame );
		var mouse = (MousePosition - origin) / scale;
		DrawModalClose( p, frame );
		p.Translate( origin );
		p.Scale( scale );
		for ( int i = 0; i < 3; i++ )
			DrawTutorialControl( p, TutorialTab( i ), $"{i + 1}", mouse, i == TutorialPage );
		p.Fill = Color.Parse( "#080e15" ).Value;
		p.Stroke = Stroke.None;
		p.Rect( new Rect( 32, 88, 556, 380 ) );
		using ( p.Scope() )
		{
			p.Clip( new Rect( 32, 88, 556, 380 ) );
			p.Translate( -168, 88 );
			if ( TutorialPage == 0 ) DrawCornerLesson( p );
			else if ( TutorialPage == 1 ) DrawSlideLesson( p );
			else DrawShardLesson( p );
		}
	}

	void DrawModalClose( Painter p, Rect frame, float layoutWidth = 620 )
	{
		float scale = frame.Width / layoutWidth;
		var close = TutorialClose( frame, layoutWidth );
		if ( close.IsInside( MousePosition ) )
		{
			p.Fill = Ink.WithAlpha( 0.09f );
			p.Stroke = Stroke.None;
			p.Rect( close );
		}
		p.Stroke = Stroke.None;
		float fontSize = 44 * scale;
		p.TextStyle = new TextStyle( DiamondScoreEffects.ScoreFont, fontSize, Ink ) { Alignment = TextFlag.Center };
		// Enixe's X advance carries extra right bearing and sits slightly high; nudge its ink onto the box center.
		p.Text( "X", new Rect( close.Position + new Vector2( 0.07f, 0.02f ) * fontSize, close.Size ) );
	}

	void DrawTutorialControl( Painter p, Rect rect, string label, Vector2 mouse, bool selected = false )
	{
		bool hover = rect.IsInside( mouse );
		bool pressed = hover && HasActive;
		p.Fill = Ink.WithAlpha( pressed ? 0.26f : hover ? 0.18f : selected ? 0.12f : 0.045f );
		p.Stroke = Stroke.None;
		p.Rect( rect );
		DrawScoreNumber( p, label, rect.Center, 40,
			selected ? ScoreColor( tutorialColorPhase ) : Muted.WithAlpha( hover ? 0.8f : 0.45f ) );
	}

	// Small deterministic demonstrations use the game's real gem, crack, outline,
	// flash and numeric renderers, with an independent clock and no gameplay RNG.
	void DrawLessonPair( Painter p, Vector2 position, int color, int health = 3, bool upward = true )
	{
		var offset = upward ? new Vector2( -28, -44 ) : new Vector2( 28, 44 );
		DrawDiamond( p, position, color, false, health: health );
		DrawDiamond( p, position + offset, color, false, health: health );
		var cells = new DiamondBoard.Diamond[] { new( 0, 0, color ), new( upward ? -1 : 1, offset.y, color ) };
		DrawGroupOutlines( p, cells, DiamondMatching.FindGroups( cells ), position - new Vector2( DiamondBoard.LaneX( 0 ), 0 ) );
	}

	static Vector2 TutorialDamageShake( float age, float angle )
	{
		if ( age < 0 || age >= DiamondBoard.DamageShineDuration ) return Vector2.Zero;
		float t = age / DiamondBoard.DamageShineDuration;
		float magnitude = 8 * (1 - t) * (1 - t);
		return new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * magnitude;
	}

	void DrawCornerLesson( Painter p )
	{
		float cycle = TutorialTime % TutorialCornerCycle;
		int hits = Math.Min( 1, (int)(TutorialTime / TutorialCornerCycle) );
		float travel = MathF.Pow( Math.Clamp( (cycle - 0.6f) / TutorialFallDuration, 0, 1 ), TutorialFallPower );
		float impactAge = cycle - 0.6f - TutorialFallDuration;
		bool impact = impactAge >= 0;
		int health = 3 - hits - (impact ? 1 : 0);
		var receiver = new Vector2( 470, 270 );
		var moving = new Vector2( 470, 90 + 92 * travel );
		DrawLessonPair( p, moving, 1 );
		DrawLessonPair( p, receiver + TutorialDamageShake( impactAge, hits == 0 ? 0.4f : 2.2f ), 0, health, false );
		if ( impact && impactAge < 0.4f ) DrawSunburst( p, receiver - new Vector2( 0, 44 ), 0, impactAge / 0.4f, 0.3f, 0.4f, 1.5f );
		// Keep the held arrow visible without a surrounding button or key cap.
		DrawScoreNumber( p, "↓", new Vector2( 598, 156 ), 94, Ink );
	}

	void DrawSlideLesson( Painter p )
	{
		using var scope = p.Scope();
		p.Translate( 0, -28 );
		float cycle = TutorialTime % TutorialSlideCycle;
		float travel = MathF.Pow( Math.Clamp( (cycle - TutorialSlideStart) / TutorialSlideDuration, 0, 1 ), 2.8f );
		float impactAge = cycle - TutorialSlideStart - TutorialSlideDuration;
		bool impact = impactAge >= 0;
		for ( int i = 0; i < 5; i++ ) DrawDiamond( p, new Vector2( 410 + i * 28, 178 + i * 44 ), 4, false );
		DrawLessonPair( p, new Vector2( 466 + travel * 84, 178 + travel * 132 ), 2 );
		DrawDiamond( p, new Vector2( 578, 354 ) + TutorialDamageShake( impactAge, 1.4f ), 3, false,
			health: impact ? 2 : 3, crackCorner: DiamondBoard.Corner.Left );
		if ( impact && impactAge < DiamondBoard.DamageFlashDuration )
			DrawFaceFlash( p, new Vector2( 550, 354 ), new Vector2( 578, 310 ), 3, impactAge, 0.8f, 0.4f, 1 );
	}

	void DrawShardLesson( Painter p )
	{
		float cycle = TutorialTime % TutorialShardCycle;
		float travel = MathF.Pow( Math.Clamp( (cycle - 0.6f) / TutorialFallDuration, 0, 1 ), TutorialFallPower );
		for ( int i = 0; i < 5; i++ ) DrawFloorTooth( p, new Vector2( 330 + i * 56, 350 ), FloorInk );
		var floor = new Vector2[11];
		for ( int i = 0; i < floor.Length; i++ ) floor[i] = new Vector2( 302 + i * 28, i % 2 == 0 ? 350 : 306 );
		p.Stroke = Stroke.Solid( FloorInk.WithAlpha( 0.08f ), 9 );
		p.Line( floor );
		p.Stroke = Stroke.Solid( FloorInk, 2 );
		p.Line( floor );
		var shards = TutorialShards;
		var color = GemColor( 0, 1 );
		var tint = Color.Lerp( color, Color.White, 0.18f );
		var scoreTarget = new Vector2( 290, 70 );
		int score = 0;
		for ( int i = 0; i < shards.Length; i++ )
		{
			var vertices = shards[i];
			var center = (vertices[0] + vertices[1] + vertices[2]) / 3;
			float age = cycle - TutorialShardContactTime( i );
			if ( age < 0 )
			{
				DrawShardFacets( p, vertices, color, tint );
				p.Fill = Fill.None;
				p.Stroke = Stroke.Solid( tint.WithAlpha( 0.45f ), 0.8f );
				p.Polygon( vertices );
				continue;
			}
			if ( age < DiamondDebris.CrushBurstDuration )
				DrawSunburst( p, center, 0, age / DiamondDebris.CrushBurstDuration, i * 0.7f, 0.4f, DiamondDebris.CrushBurstScale );
		}
		DrawLessonPair( p, new Vector2( 470, 120 + 186 * travel ), 1 );
		// Score flights render above the gems, as they do in the game.
		for ( int i = 0; i < shards.Length; i++ )
		{
			var vertices = shards[i];
			float age = cycle - TutorialShardContactTime( i );
			if ( age < 0 ) continue;
			float flight = Math.Clamp( age / TutorialShardFlight( i ), 0, 1 );
			if ( flight >= 1 ) { score += i + 1; continue; }
			var center = (vertices[0] + vertices[1] + vertices[2]) / 3;
			var from = center + new Vector2( (i - 1) * 65 * flight, -135 * MathF.Sin( flight * MathF.PI ) );
			float pull = MathF.Pow( Math.Clamp( (flight - 0.6f) / 0.4f, 0, 1 ), 3 );
			DrawScoreNumber( p, $"+{i + 1}", from + (scoreTarget - from) * pull, 30, Palette[0] );
		}
		DrawScoreNumber( p, score.ToString(), scoreTarget, 64, Palette[0] );
	}
}