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