DiamondPanel.Options.cs
using System;
using Sandbox;
using Sandbox.UI;
namespace Diamonds;
public sealed partial class DiamondPanel
{
public enum OptionsSlider { None, RestartDelay, Rumble, Master, Music }
public bool OptionsOpen { get; private set; }
/// <summary>Any overlay owns input and pauses gameplay and title animation behind it.</summary>
public bool ModalOpen => TutorialOpen || OptionsOpen || LeaderboardOpen;
public OptionsSlider DraggingSlider { get; private set; }
bool optionsWasPaused;
DiamondBoard optionsBoard;
float optionsColorPhase;
float disableFluidKnob;
SoundHandle optionsPreview;
const float OptionsWidth = 700, OptionsHeight = 604;
const float OptionsLabelX = 48, DisableFluidY = 150, RestartDelayY = 226, RumbleY = 302, VolumeDividerY = 351, VolumeHeadingY = 398, MasterY = 468, MusicY = 544;
// Restart Delay and Controller Rumble have longer labels than the volume sliders, so their shared track start is farther right.
const float SliderRight = 568, SliderValueX = 618, VolumeTrackLeft = 196, LongLabelTrackLeft = 404;
const int DisableFluidColor = 0, RestartDelayColor = 2, RumbleColor = 3, MasterColor = 1, MusicColor = 4;
static Rect OptionsButton( Vector2 size ) => new( LetterboxCenter( size, true ) - 136, Top + 796, 272, 64 );
// Share the tutorial's screen-pixel fitting, so the close button and scale match it.
static Rect OptionsFrame( Vector2 size )
{
float scale = MathF.Min( size.x * 0.5f / OptionsWidth, size.y * 0.62f / OptionsHeight );
var extent = new Vector2( OptionsWidth, OptionsHeight ) * scale;
return new Rect( (size - extent) * 0.5f, extent );
}
static Rect DisableFluidRow => new( 32, DisableFluidY - 34, OptionsWidth - 64, 68 );
static Rect DisableFluidTrack => new( SliderRight - 28, DisableFluidY - 7, 112, 14 );
static float SliderY( OptionsSlider id ) => id switch
{
OptionsSlider.RestartDelay => RestartDelayY, OptionsSlider.Rumble => RumbleY, OptionsSlider.Master => MasterY, _ => MusicY
};
static Rect SliderTrack( OptionsSlider id )
{
float left = id is OptionsSlider.RestartDelay or OptionsSlider.Rumble ? LongLabelTrackLeft : VolumeTrackLeft;
return new( left, SliderY( id ) - 4, SliderRight - left, 8 );
}
static Rect SliderHit( OptionsSlider id )
{
var track = SliderTrack( id );
return new( track.Left - 24, track.Center.y - 34, track.Width + 48, 68 );
}
public void OpenOptions()
{
if ( ModalOpen || Board is null ) return;
optionsBoard = Board;
optionsWasPaused = Board.Paused;
if ( !Board.Paused ) Board.TogglePause();
OptionsOpen = true;
DraggingSlider = OptionsSlider.None;
disableFluidKnob = DiamondSettings.Current.DisableFluid ? 1 : 0;
StopTitleAudio();
}
public void CloseOptions()
{
if ( !OptionsOpen ) return;
EndOptionsDrag();
if ( optionsBoard is not null && optionsBoard.Paused != optionsWasPaused ) optionsBoard.TogglePause();
OptionsOpen = false;
optionsBoard = null;
}
public void UpdateOptions( float delta, bool backspace )
{
if ( !OptionsOpen ) return;
if ( backspace ) { CloseOptions(); return; }
delta = MathF.Max( 0, delta );
optionsColorPhase = (optionsColorPhase + delta * 2.4f) % Palette.Length;
float target = DiamondSettings.Current.DisableFluid ? 1 : 0;
disableFluidKnob = target > disableFluidKnob ? MathF.Min( target, disableFluidKnob + delta / 0.14f ) : MathF.Max( target, disableFluidKnob - delta / 0.14f );
if ( DraggingSlider == OptionsSlider.None ) return;
// Mouse-up normally ends the drag; losing the active state covers releases we never saw.
if ( !HasActive ) { EndOptionsDrag(); return; }
DragOptionsSlider( MousePosition, Box.Rect.Size );
}
/// <summary>Opens Options from the letterbox button, or handles a press inside the open overlay.</summary>
public void HandleOptionsClick( Vector2 mouse, Vector2 size )
{
if ( !OptionsOpen )
{
if ( TitleScreen || TutorialOpen ) 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;
if ( OptionsButton( size ).IsInside( point ) ) { PlayClickSound(); OpenOptions(); }
return;
}
var frame = OptionsFrame( size );
if ( TutorialClose( frame, OptionsWidth ).IsInside( mouse ) ) { PlayClickSound(); CloseOptions(); return; }
if ( !frame.IsInside( mouse ) ) { CloseOptions(); return; }
var local = OptionsLocal( mouse, frame );
if ( DisableFluidRow.IsInside( local ) )
{
PlayClickSound();
DiamondSettings.Current.DisableFluid = !DiamondSettings.Current.DisableFluid;
DiamondSettings.Save();
return;
}
DraggingSlider = SliderHit( OptionsSlider.RestartDelay ).IsInside( local ) ? OptionsSlider.RestartDelay
: SliderHit( OptionsSlider.Rumble ).IsInside( local ) ? OptionsSlider.Rumble
: SliderHit( OptionsSlider.Master ).IsInside( local ) ? OptionsSlider.Master
: SliderHit( OptionsSlider.Music ).IsInside( local ) ? OptionsSlider.Music : OptionsSlider.None;
DragOptionsSlider( mouse, size );
}
public void DragOptionsSlider( Vector2 mouse, Vector2 size )
{
if ( DraggingSlider == OptionsSlider.None ) return;
var track = SliderTrack( DraggingSlider );
float value = Math.Clamp( (OptionsLocal( mouse, OptionsFrame( size ) ).x - track.Left) / track.Width, 0, 1 );
// The delay setter snaps to its 0.05-second steps.
if ( DraggingSlider == OptionsSlider.RestartDelay ) DiamondSettings.Current.RestartDelay = value;
else if ( DraggingSlider == OptionsSlider.Rumble ) DiamondSettings.Current.ControllerRumble = MathF.Round( value * 100 ) / 100;
else if ( DraggingSlider == OptionsSlider.Master ) DiamondSettings.Current.MasterVolume = MathF.Round( value * 100 ) / 100;
else DiamondSettings.Current.MusicVolume = MathF.Round( value * 100 ) / 100;
}
/// <summary>Finish a slider drag, saving it and previewing the new effects or rumble level.</summary>
public void EndOptionsDrag()
{
if ( DraggingSlider == OptionsSlider.None ) return;
bool master = DraggingSlider == OptionsSlider.Master;
bool rumble = DraggingSlider == OptionsSlider.Rumble;
DraggingSlider = OptionsSlider.None;
DiamondSettings.Save();
// Preview on a real cue: the hit of a slam that cracks a diamond.
if ( rumble ) DiamondHaptics.DamageHit( 0.7f );
if ( !master || IsMuted ) return;
optionsPreview?.Stop();
optionsPreview = Sound.Play( "sfx/diamond_impact.sound" );
if ( optionsPreview is null ) return;
optionsPreview.SpacialBlend = 0;
optionsPreview.Volume *= 0.7f * DiamondSettings.EffectsGain;
}
protected override void OnMouseUp( MousePanelEvent e )
{
if ( e.Button != "mouseleft" ) return;
EndOptionsDrag();
leaderboardDragging = false;
}
static Vector2 OptionsLocal( Vector2 mouse, Rect frame ) => (mouse - frame.Position) / (frame.Width / OptionsWidth);
void DrawOptionsButton( Painter p, float scale )
{
var size = Box.Rect.Size;
var mouse = (MousePosition - (size - new Vector2( CanvasWidth, CanvasHeight ) * scale) * 0.5f) / scale;
DrawTitleButton( p, OptionsButton( size ), "OPTIONS", mouse, scale, fontSize: 26, brightness: 0.7f );
}
void DrawOptions( Painter p )
{
if ( !OptionsOpen ) return;
using var scope = p.Scope();
p.Fill = Color.Black.WithAlpha( 0.32f );
p.Stroke = Stroke.None;
p.Rect( p.Bounds );
var frame = OptionsFrame( p.Bounds.Size );
p.Fill = Color.Parse( "#0d151e" ).Value;
p.Rect( frame );
p.Clip( frame );
float scale = frame.Width / OptionsWidth;
if ( scale <= 0 ) return;
DrawModalClose( p, frame, OptionsWidth );
var mouse = OptionsLocal( MousePosition, frame );
p.Translate( frame.Position );
p.Scale( scale );
var settings = DiamondSettings.Current;
DrawScoreNumber( p, "OPTIONS", new Vector2( OptionsWidth * 0.5f, 60 ), 52, ScoreColor( optionsColorPhase ) );
DrawDisableFluidToggle( p, mouse );
DrawOptionsSlider( p, "RESTART DELAY", settings.RestartDelay, DiamondSettings.FormatSeconds( settings.RestartDelay ),
RestartDelayColor, OptionsSlider.RestartDelay, mouse );
DrawOptionsSlider( p, "CONTROLLER RUMBLE", settings.ControllerRumble, $"{MathF.Round( settings.ControllerRumble * 100 )}",
RumbleColor, OptionsSlider.Rumble, mouse );
p.Fill = Ink.WithAlpha( 0.07f );
p.Stroke = Stroke.None;
p.Rect( new Rect( 32, VolumeDividerY - 1, OptionsWidth - 64, 2 ) );
DrawScoreNumber( p, "VOLUME", new Vector2( OptionsLabelX, VolumeHeadingY ), 34, Muted * 0.8f, 0 );
DrawOptionsSlider( p, "MASTER", settings.MasterVolume, $"{MathF.Round( settings.MasterVolume * 100 )}", MasterColor, OptionsSlider.Master, mouse );
DrawOptionsSlider( p, "MUSIC", settings.MusicVolume, $"{MathF.Round( settings.MusicVolume * 100 )}", MusicColor, OptionsSlider.Music, mouse );
}
void DrawDisableFluidToggle( Painter p, Vector2 mouse )
{
bool hover = DraggingSlider == OptionsSlider.None && DisableFluidRow.IsInside( mouse );
bool pressed = hover && HasActive;
p.Stroke = Stroke.None;
if ( hover )
{
p.Fill = Ink.WithAlpha( pressed ? 0.07f : 0.04f );
p.Rect( DisableFluidRow );
}
DrawScoreNumber( p, "DISABLE FLUID", new Vector2( OptionsLabelX, DisableFluidY ), 30, hover ? Ink : Ink * 0.8f, 0 );
var track = DisableFluidTrack;
float t = disableFluidKnob * disableFluidKnob * (3 - 2 * disableFluidKnob);
float x = track.Left + 17 + (track.Width - 34) * t;
p.Fill = Ink.WithAlpha( hover ? 0.1f : 0.06f );
p.Rect( track );
DrawTrackFill( p, track, x, Palette[DisableFluidColor], t );
DrawOptionsHandle( p, new Vector2( x, DisableFluidY ), DisableFluidColor, hover ? 0.68f : 0.62f, t );
}
void DrawOptionsSlider( Painter p, string label, float value, string valueText, int color, OptionsSlider id, Vector2 mouse, float labelSize = 26 )
{
bool active = DraggingSlider == id;
bool hover = active || DraggingSlider == OptionsSlider.None && SliderHit( id ).IsInside( mouse );
float y = SliderY( id );
DrawScoreNumber( p, label, new Vector2( OptionsLabelX, y ), labelSize, hover ? Ink : Ink * 0.8f, 0 );
var track = SliderTrack( id );
p.Stroke = Stroke.None;
p.Fill = Ink.WithAlpha( hover ? 0.1f : 0.06f );
p.Rect( track );
// Faint quarter notches, cut as small diamonds into the empty track.
for ( int i = 1; i < 4; i++ )
{
var notch = new Vector2( track.Left + track.Width * i / 4, y );
p.Fill = Ink.WithAlpha( 0.14f );
p.Polygon( [notch + new Vector2( 0, -7 ), notch + new Vector2( 4, 0 ), notch + new Vector2( 0, 7 ), notch + new Vector2( -4, 0 )] );
}
float x = track.Left + track.Width * value;
DrawTrackFill( p, track, x, Palette[color], 1 );
DrawOptionsHandle( p, new Vector2( x, y ), color, active ? 0.72f : hover ? 0.68f : 0.62f, 1 );
var valueCenter = new Vector2( SliderValueX, y );
if ( valueText.EndsWith( 's' ) ) DrawSecondsValue( p, valueText[..^1], valueCenter, 26, hover ? Ink : Muted );
else DrawScoreNumber( p, valueText, valueCenter, 26, hover ? Ink : Muted );
}
/// <summary>Score-font digits followed by a smaller score-font S, sharing their baseline so it reads as a unit, not a 5.</summary>
static void DrawSecondsValue( Painter p, string number, Vector2 center, float fontSize, Color color )
{
const float UnitScale = 0.68f, CapHeight = 0.52f;
float unitSize = fontSize * UnitScale, gap = fontSize * 0.1f;
float numberWidth = DiamondScoreEffects.MeasureNumber( number ).x * fontSize / DiamondScoreEffects.PickupFontSize;
float unitWidth = DiamondScoreEffects.MeasureNumber( "S" ).x * unitSize / DiamondScoreEffects.PickupFontSize;
float left = center.x - (numberWidth + gap + unitWidth) * 0.5f;
DrawScoreNumber( p, number, new Vector2( left, center.y ), fontSize, color, 0 );
// Caps-only glyphs sit centered on the line, so drop the smaller S by half the cap-height difference.
DrawScoreNumber( p, "S", new Vector2( left + numberWidth + gap, center.y + (fontSize - unitSize) * CapHeight * 0.5f ), unitSize, color, 0 );
}
static void DrawTrackFill( Painter p, Rect track, float x, Color color, float opacity )
{
if ( x <= track.Left || opacity <= 0 ) return;
p.Stroke = Stroke.None;
p.Fill = color.WithAlpha( 0.12f * opacity );
p.Rect( new Rect( track.Left, track.Center.y - 8, x - track.Left, 16 ) );
p.Fill = color.WithAlpha( 0.8f * opacity );
p.Rect( new Rect( track.Left, track.Top, x - track.Left, track.Height ) );
}
/// <summary>A small in-game gem with its bonded outline; <paramref name="lit"/> fades it in from dull steel.</summary>
void DrawOptionsHandle( Painter p, Vector2 center, int color, float scale, float lit )
{
if ( lit < 1 ) DrawSteelHandle( p, center, scale, 1 - lit );
if ( lit <= 0 ) return;
if ( lit >= 1 ) DrawDiamond( p, center, color, false, scale );
else DrawDiamondVector( p, center, color, false, scale, lit );
var cells = new DiamondBoard.Diamond[] { new( 0, 0, color ) };
DrawGroupOutlines( p, cells, DiamondMatching.FindGroups( cells ),
center - new Vector2( DiamondBoard.LaneX( 0 ) * scale, 0 ), scale, lit );
}
static void DrawSteelHandle( Painter p, Vector2 center, float scale, float opacity )
{
using var scope = p.Scope();
p.Translate( center );
p.Scale( scale );
float w = DiamondBoard.Width * 0.5f, h = DiamondBoard.Height * 0.5f;
Vector2 top = new( 0, -h ), right = new( w, 0 ), bottom = new( 0, h ), left = new( -w, 0 ), middle = Vector2.Zero;
var steel = FloorInk * 0.75f;
Facet( p, steel, 0.32f, opacity, false, [top, middle, left] );
Facet( p, steel, 0.02f, opacity, false, [top, right, middle] );
Facet( p, steel, -0.42f, opacity, false, [middle, right, bottom] );
Facet( p, steel, -0.12f, opacity, false, [left, middle, bottom] );
DrawGemStroke( p, [top, right, bottom, left], FloorInk.WithAlpha( opacity ), 2, true );
}
}