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