UI/OptionsMenuController.cs
namespace BlockParty;
/// <summary>
/// Shared options-menu view-model. Drives the "Show Timer" toggle, Screenshake and Restart Button Delay sliders, the
/// volume rows (Overall / SFX / Music) and the GAMEPAD Vibration row, plus a BACK row, with full selector-cube parity to
/// the title menu — keyboard (Up/Down to move, Left/Right to nudge the focused slider or flip the
/// toggle, Confirm to flip the toggle / activate BACK, Back to close) AND mouse (hover to focus a
/// row, drag a slider, click the checkbox, click BACK). The same controller backs both the main
/// <see cref="OptionsStage"/> and the in-game options overlay so behaviour is identical.
///
/// Changing a value applies it immediately (volumes to <see cref="Audio"/>, vibration to
/// <see cref="Haptics"/>) and marks the settings dirty; the save is debounced so dragging/holding
/// doesn't hit the disk every frame, and <see cref="Close"/> flushes it on exit.
/// </summary>
public sealed class OptionsMenuController
{
public enum Row { ShowTimer, Screenshake, RestartButtonDelay, Overall, Sfx, Music, Vibration, Back }
public const int RowCount = (int)Row.Back + 1;
public const int BackRow = (int)Row.Back;
public int SelectedRow { get; private set; }
public float SelectorPos => _selector.Pos;
public int SelectorFrame => _selector.Frame;
/// <summary>Invoked when BACK is activated (Confirm/click) or the Back action is pressed. The
/// owner decides what closing means (return to title / resume the game).</summary>
public System.Action OnBack { get; set; }
private readonly MenuSelectorAnim _selector = new();
private readonly NavRepeater _nav = new(); // W/S hold-to-repeat between rows, shared UI-wide
// Slider movement per keypress, and the held-direction auto-repeat cadence (slider-space 0..100).
private const float NUDGE_STEP = 2f;
private const float REPEAT_DELAY = 0.35f;
private const float KEY_REPEAT_RATE = 0.04f;
private int _repeatDir;
private float _repeatTimer;
private float _sfxPreviewTimer;
// Debounced save: flush a short time after the last change so a drag doesn't spam the disk.
private const float SAVE_DEBOUNCE = 0.4f;
private bool _saveDirty;
private float _saveTimer;
public void Tick( float dt )
{
if ( _sfxPreviewTimer > 0f )
_sfxPreviewTimer -= dt;
// W/S move between rows with hold-to-repeat (a held key keeps stepping).
int step = _nav.Tick( dt, InputState.NavUp, InputState.NavDown );
if ( step != 0 ) Navigate( step );
HandleNudge( dt );
if ( InputState.ConfirmJust )
{
if ( SelectedRow == BackRow ) Activate();
else if ( SelectedRow == (int)Row.ShowTimer ) ToggleShowTimer();
}
if ( InputState.BackJust )
{
Audio.PlaySfx( SfxType.MenuStart );
Haptics.MenuConfirm();
Close();
}
_selector.Tick( dt, SelectedRow );
if ( _saveDirty )
{
_saveTimer -= dt;
if ( _saveTimer <= 0f )
{
Settings.Save();
_saveDirty = false;
}
}
}
/// <summary>Vertical navigation between rows; wobble at the list ends.</summary>
private void Navigate( int delta )
{
SelectedRow = MenuNav.Step( SelectedRow, delta, RowCount, _selector );
}
/// <summary>Left/Right nudge of the focused slider, with auto-repeat while held.</summary>
private void HandleNudge( float dt )
{
if ( SelectedRow == BackRow ) { _repeatDir = 0; return; }
// Toggle rows are boolean, not sliders: Left = off, Right = on (no hold-to-repeat).
if ( SelectedRow == (int)Row.ShowTimer )
{
if ( InputState.LeftJust ) SetShowTimer( false );
else if ( InputState.RightJust ) SetShowTimer( true );
_repeatDir = 0;
return;
}
int dir = 0;
if ( InputState.LeftJust ) dir = -1;
else if ( InputState.RightJust ) dir = 1;
if ( dir != 0 )
{
Nudge( dir );
// Master/SFX rows preview via their setters (PlaySfxPreview). The other sliders don't,
// so give them an explicit blip on the initial press (the per-step haptic tick from Nudge
// is itself the vibration-strength preview — turning it down weakens the ticks).
if ( SelectedRow == (int)Row.Screenshake || SelectedRow == (int)Row.RestartButtonDelay || SelectedRow == (int)Row.Music || SelectedRow == (int)Row.Vibration )
Audio.PlaySfx( SfxType.MenuBlip );
_repeatDir = dir;
_repeatTimer = REPEAT_DELAY;
return;
}
bool held = (_repeatDir < 0 && InputState.Left) || (_repeatDir > 0 && InputState.Right);
if ( _repeatDir != 0 && held )
{
_repeatTimer -= dt;
if ( _repeatTimer <= 0f )
{
Nudge( _repeatDir );
_repeatTimer = KEY_REPEAT_RATE;
}
}
else
{
_repeatDir = 0;
}
}
private void Nudge( int dir )
{
float step = dir * NUDGE_STEP;
switch ( (Row)SelectedRow )
{
case Row.Screenshake: SetScreenshake( Settings.Current.Screenshake + step ); break;
case Row.RestartButtonDelay: SetRestartButtonDelay( Settings.Current.RestartButtonDelay + dir * GameSettings.RestartButtonDelayStep ); break;
case Row.Overall: SetMaster( Settings.Current.MasterVolume + step ); break;
case Row.Sfx: SetSfx( Settings.Current.SfxVolume + step ); break;
case Row.Music: SetMusic( Settings.Current.MusicVolume + step ); break;
case Row.Vibration: SetVibration( Settings.Current.VibrationStrength + step ); break;
}
// A light tick per step so adjusting a slider on a controller has tactile feedback (both the
// initial press and each auto-repeat step route through here; a mouse drag does not).
Haptics.MenuBlip();
}
/// <summary>Confirm/click on the BACK row.</summary>
public void Activate()
{
_selector.PlayWobble();
Audio.PlaySfx( SfxType.MenuStart );
Haptics.MenuConfirm();
Close();
}
/// <summary>Mouse hover: focus a row (no-op if already focused).</summary>
public void HoverRow( int row )
{
int next = Math.Clamp( row, 0, RowCount - 1 );
if ( next == SelectedRow ) return;
SelectedRow = next;
_selector.PlayMove();
Audio.PlaySfx( SfxType.MenuBlip );
}
// --- toggle (shared by keyboard flip + mouse click) -------------------------------------
/// <summary>Set the "Show Timer" option. Always gives audible/haptic feedback; only persists
/// when the value actually changes.</summary>
public void SetShowTimer( bool value )
{
bool changed = Settings.Current.ShowTimer != value;
Settings.Current.ShowTimer = value;
Audio.PlaySfx( SfxType.MenuBlip );
Haptics.MenuBlip();
if ( changed ) MarkDirty();
}
public void ToggleShowTimer() => SetShowTimer( !Settings.Current.ShowTimer );
// --- slider setters (shared by keyboard nudge + mouse drag) -----------------------------
public void SetScreenshake( float value )
{
// Read live by GameStage's ApplyScreenshake; nothing to apply here beyond persisting.
Settings.Current.Screenshake = Clamp100( value );
MarkDirty();
}
public void SetRestartButtonDelay( float value )
{
Settings.Current.RestartButtonDelay = value;
MarkDirty();
}
public void SetMaster( float value )
{
Settings.Current.MasterVolume = Clamp100( value );
Audio.SetMasterVolume( Settings.Current.MasterVolume / 100f );
PlaySfxPreview();
MarkDirty();
}
public void SetSfx( float value )
{
Settings.Current.SfxVolume = Clamp100( value );
Audio.SetSfxVolume( Settings.Current.SfxVolume / 100f );
PlaySfxPreview();
MarkDirty();
}
public void SetMusic( float value )
{
Settings.Current.MusicVolume = Clamp100( value );
Audio.SetMusicVolume( Settings.Current.MusicVolume / 100f );
MarkDirty();
}
public void SetVibration( float value )
{
Settings.Current.VibrationStrength = Clamp100( value );
// Apply live so the per-step haptic tick fired by Nudge previews the new strength (no SFX
// preview: this row's feedback is the vibration itself, not a sound).
Haptics.SetStrength( Settings.Current.VibrationStrength / 100f );
MarkDirty();
}
private void Close()
{
// Persist immediately on close so the latest values survive even if the debounce hasn't fired.
if ( _saveDirty )
{
Settings.Save();
_saveDirty = false;
}
OnBack?.Invoke();
}
private void MarkDirty()
{
_saveDirty = true;
_saveTimer = SAVE_DEBOUNCE;
}
private void PlaySfxPreview()
{
if ( _sfxPreviewTimer > 0f ) return;
Audio.PlaySfx( SfxType.MenuBlip );
_sfxPreviewTimer = KEY_REPEAT_RATE;
}
private static float Clamp100( float v ) => Math.Clamp( v, 0f, 100f );
}