UI/OptionsPanel.razor
@using Sandbox
@using Sandbox.UI
@using System.Linq
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "OptionsPanel.razor.scss" )]
@* Shared options block: top titleless gameplay toggles, the VOLUME sliders (Overall / SFX
/ Music) and the GAMEPAD Vibration slider, plus a BACK row, with the "smiling cube" selector
mirroring the title menu. Driven by an OptionsMenuController so the main options stage and the
in-game overlay share identical behaviour. The controls bind directly to Settings.Current so
keyboard input and mouse both stay in sync. *@
<root class="@(Dimmed ? "dimmed" : "")">
@{ if ( Controller is null ) return; }
<div class="rows">
@* Selector cube: slides to the focused row (SelectorPos in row-index units × ROW_PITCH),
advancing through the 7 menu_selector frames. Percent background-position is deliberate
(s&box scales background *size* but not px *position*) — same trick as TitleScreen. *@
<div class="selector"
style="top: @(SelectorTopCss); background-position: @(-Controller.SelectorFrame * 100)% 0%;"></div>
@* Top section (no heading title): gameplay preferences. *@
<div class="row toggle @(Controller.SelectedRow == 0 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 0 ))>
<label class="label">SHOW TIMER</label>
<div class="checkbox-wrap">
<PixelCheckbox [email protected] OnValueChanged=@((bool v) => Controller.SetShowTimer( v ))></PixelCheckbox>
</div>
</div>
<div class="row @(Controller.SelectedRow == 1 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 1 ))>
<label class="label">SCREENSHAKE</label>
<div class="slider-wrap">
<PixelSlider [email protected] Min=@(0f) Max=@(100f) Step=@(1f) OnValueChanged=@((float v) => Controller.SetScreenshake( v ))></PixelSlider>
</div>
<label class="value">@((int)Settings.Current.Screenshake)</label>
</div>
<div class="row restart-delay @(Controller.SelectedRow == 2 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 2 ))>
<label class="label">RESTART BUTTON DELAY</label>
<div class="slider-wrap">
<PixelSlider [email protected] Min=@(0f) Max=@(1f) [email protected] OnValueChanged=@((float v) => Controller.SetRestartButtonDelay( v ))></PixelSlider>
</div>
<label class="value">@(Settings.Current.RestartButtonDelay.ToString( "0.00", System.Globalization.CultureInfo.InvariantCulture ))s</label>
</div>
<div class="section">VOLUME</div>
<div class="row @(Controller.SelectedRow == 3 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 3 ))>
<label class="label">OVERALL</label>
<div class="slider-wrap">
<PixelSlider [email protected] Min=@(0f) Max=@(100f) Step=@(1f) OnValueChanged=@((float v) => Controller.SetMaster( v ))></PixelSlider>
</div>
<label class="value">@((int)Settings.Current.MasterVolume)</label>
</div>
<div class="row @(Controller.SelectedRow == 4 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 4 ))>
<label class="label">SFX</label>
<div class="slider-wrap">
<PixelSlider [email protected] Min=@(0f) Max=@(100f) Step=@(1f) OnValueChanged=@((float v) => Controller.SetSfx( v ))></PixelSlider>
</div>
<label class="value">@((int)Settings.Current.SfxVolume)</label>
</div>
<div class="row @(Controller.SelectedRow == 5 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 5 ))>
<label class="label">MUSIC</label>
<div class="slider-wrap">
<PixelSlider [email protected] Min=@(0f) Max=@(100f) Step=@(1f) OnValueChanged=@((float v) => Controller.SetMusic( v ))></PixelSlider>
</div>
<label class="value">@((int)Settings.Current.MusicVolume)</label>
</div>
<div class="section">GAMEPAD</div>
<div class="row @(Controller.SelectedRow == 6 ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( 6 ))>
<label class="label">VIBRATION</label>
<div class="slider-wrap">
<PixelSlider [email protected] Min=@(0f) Max=@(100f) Step=@(1f) OnValueChanged=@((float v) => Controller.SetVibration( v ))></PixelSlider>
</div>
<label class="value">@((int)Settings.Current.VibrationStrength)</label>
</div>
<div class="row back @(Controller.SelectedRow == OptionsMenuController.BackRow ? "selected" : "")"
onmouseover=@(() => Controller.HoverRow( OptionsMenuController.BackRow ))
onclick=@(() => Controller.Activate())>
<label class="text">@(Dimmed ? "CLOSE" : "BACK")</label>
</div>
</div>
</root>
@code
{
[Parameter] public OptionsMenuController Controller { get; set; }
// When hosted over the dark in-game overlay panel, lift the normally near-black labels so they
// read against the panel. Styled from this component's OWN scss (.dimmed) because s&box
// stylesheets don't cascade into child components from the host.
[Parameter] public bool Dimmed { get; set; }
// Vertical distance (1080-ref px) between rows. Fallback only, used until the rows have been laid
// out at least once (see SelectorTop); MUST match the SCSS .row height (56) + .rows gap (16).
const float ROW_PITCH = 72f;
// Cube box size (matches .selector width/height in the SCSS). The cube art is centred in its
// frame, so centring this box on a row centres the cube on the row's text.
const float SELECTOR_SIZE = 56f;
// Extra vertical space a titled section divider (VOLUME / GAMEPAD) inserts above the row that
// follows it: its margin-top (24) + height (56) + one extra .rows gap (16) = 96. MUST match the
// SCSS. Only used by the first-frame fallback below (the measured path reads real rects).
const float SECTION_GAP = 96f;
// Section gaps follow Restart Button Delay and Music respectively.
float VolumeOffset => System.Math.Clamp( (Controller?.SelectorPos ?? 0f) - (int)OptionsMenuController.Row.RestartButtonDelay, 0f, 1f ) * SECTION_GAP;
float GamepadOffset => System.Math.Clamp( (Controller?.SelectorPos ?? 0f) - (int)OptionsMenuController.Row.Music, 0f, 1f ) * SECTION_GAP;
// Extra gap above the BACK row (matches .row.back margin-top in the SCSS). Fallback path only.
const float BACK_GAP = 24f;
float BackOffset => System.Math.Clamp( (Controller?.SelectorPos ?? 0f) - (int)OptionsMenuController.Row.Vibration, 0f, 1f ) * BACK_GAP;
// Selector `top` (ref px, relative to the .rows padding box). Rather than trust a hardcoded row
// pitch plus per-section offsets — which desync when the pixel font's tall line box inflates a
// row past its set height — measure the real laid-out row rects and centre the cube on the row
// the (fractional) SelectorPos points at, lerping between the two straddled rows while it slides.
// The section dividers fall out for free since we read where the rows actually landed. Falls back
// to the nominal pitch + section offsets on the first frame, before any layout exists.
private float SelectorTop()
{
float pos = Controller.SelectorPos;
var rowsPanel = Children?.FirstOrDefault( c => c.HasClass( "rows" ) );
var rows = rowsPanel?.Children?.Where( c => c.HasClass( "row" ) ).ToList();
if ( rowsPanel is null || rows is null || rows.Count == 0 || rowsPanel.Box.Rect.Height <= 0f )
return pos * ROW_PITCH + VolumeOffset + GamepadOffset + BackOffset;
float scale = rowsPanel.ScaleFromScreen;
float baseTop = rowsPanel.Box.Rect.Top;
// Centre on the row's own rect normally. The BACK row is the exception: its `.text` button is
// `align-self: flex-start` (pinned to the top of the 56px row, not vertically centred like the
// slider/toggle labels), so centring the cube on the row rect leaves it low relative to the
// visible button. When a row has a `.text` child (only BACK does) centre on THAT element so the
// cube tracks the actual button.
float RowCentre( Panel r )
{
var content = r.Children?.FirstOrDefault( c => c.HasClass( "text" ) ) ?? r;
return (content.Box.Rect.Top - baseTop) * scale + content.Box.Rect.Height * scale * 0.5f;
}
int lo = System.Math.Clamp( (int)System.MathF.Floor( pos ), 0, rows.Count - 1 );
int hi = System.Math.Clamp( lo + 1, 0, rows.Count - 1 );
float frac = System.Math.Clamp( pos - lo, 0f, 1f );
float centre = MathX.Lerp( RowCentre( rows[lo] ), RowCentre( rows[hi] ), frac );
return centre - SELECTOR_SIZE * 0.5f;
}
string SelectorTopCss => SelectorTop().ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) + "px";
// HashCode.Combine caps at 8 args, so build incrementally.
protected override int BuildHash()
{
var hash = new System.HashCode();
hash.Add( Controller?.SelectedRow ?? 0 );
hash.Add( (int)((Controller?.SelectorPos ?? 0f) * 100f) );
hash.Add( Controller?.SelectorFrame ?? 0 );
hash.Add( Settings.Current.ShowTimer );
hash.Add( (int)Settings.Current.Screenshake );
hash.Add( Settings.Current.RestartButtonDelay );
hash.Add( (int)Settings.Current.MasterVolume );
hash.Add( (int)Settings.Current.SfxVolume );
hash.Add( (int)Settings.Current.MusicVolume );
hash.Add( (int)Settings.Current.VibrationStrength );
// Fold the measured selector top in so the first paint (which falls back to the nominal pitch
// before any layout exists) re-renders once real row rects arrive — and again if the layout
// ever shifts — rather than sticking on the fallback at a resting row.
hash.Add( Controller is null ? 0 : (int)(SelectorTop() * 4f) );
return hash.ToHashCode();
}
}