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