UI/SettingsPanel.razor
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace Prototype
@inherits Panel
<root>
    <div class="settings-a-body">
        <div class="settings-a-nav">
            <label class="settings-a-title" style="font-size:38px; font-weight:700; color:#f2f7fa; letter-spacing:0px;">Settings</label>
            <label class="settings-a-caption">MAKE IT YOURS</label>
            @foreach(var category in Categories)
            {
                var item = category;
                <button class="settings-a-tab @(_category == item ? "active" : "")" onclick="@(() => SelectCategory(item))"><MenuGlyph Kind="@CategoryIcon(item)" /><label style="font-size:21px; font-weight:500; color:#f2f7fa; letter-spacing:0px;">@item</label></button>
            }
        </div>
        <div class="settings-a-options">
            <label class="settings-a-section" style="font-size:28px; font-weight:600; color:#f2f7fa; letter-spacing:0px;">@_category</label>
            @foreach(var option in Options())
            {
                var row = option;
                <div class="settings-a-row @(_helpTitle == row.Name ? "focused" : "")" onmouseover="@(() => Help(row))">
                    <label class="settings-a-name" style="font-size:19px; font-weight:400; color:#e5eef4; letter-spacing:0px;">@row.Name</label>
                    @if(row.Action is not null)
                    { <button class="settings-a-open" onclick="@row.Action"><label style="font-size:17px; font-weight:500; color:#ffffff; letter-spacing:0px;">Open settings</label><MenuGlyph Kind="join" /></button> }
                    else if(row.Toggle is not null)
                    { <button class="settings-a-toggle @(row.Value() > 0 ? "enabled" : "")" onclick="@(() => { row.Toggle(); Changed(row); })"><div class="settings-a-switch"></div><label style="font-size:17px; color:#ffffff; letter-spacing:0px;">@(row.Value() > 0 ? "On" : "Off")</label></button> }
                    else
                    {
                        <div class="settings-a-range"><SliderControl Min="@row.Min" Max="@row.Max" Step="@row.Step" Value="@row.Value()" ShowTextEntry="@false" OnValueChanged="@((float v) => { row.Set(v); Changed(row); })" /><label class="settings-a-value" style="font-size:18px; font-weight:500; color:#f2f7fa; letter-spacing:0px;">@row.Format(row.Value())</label></div>
                    }
                </div>
            }
            <label class="settings-a-local-note">Game options are saved with Apply changes. Display, device and key binding settings use the s&amp;box settings window.</label>
        </div>
        <div class="settings-a-help">
            @if(_category == "Crosshair" || _category == "Damage numbers")
            {
                <div class="appearance-preview">
                    @if(_category == "Crosshair"){<div class="preview-center"><CrosshairView Settings="@_draft" /></div>}
                    else if(_draft.ShowDamageNumbers){<label style="@HudAppearance.DamageStyle(_draft)">-128</label>}
                </div>
                @if (_category == "Damage numbers")
                {
                    <div class="feedback-preview-row">
                        <div class="feedback-preview"><label>BODY</label>@if (_draft.ShowHitmarker) { <HitmarkerView Hue="@_draft.HitmarkerBodyHue" /> }</div>
                        <div class="feedback-preview"><label>HEADSHOT</label>@if (_draft.ShowHitmarker) { <HitmarkerView Hue="@_draft.HitmarkerHeadHue" Headshot="@true" /> }</div>
                    </div>
                    <div class="feedback-preview hurt-preview"><label>INCOMING DAMAGE</label>@if (_draft.ShowHurtEffect) { <HurtEffectView Hue="@_draft.HurtEffectHue" Visibility="@(0.7f)" /> }</div>
                }
                <label class="settings-a-local-note">Live preview</label>
            }
            <div class="settings-a-help-icon"><MenuGlyph Kind="@CategoryIcon(_category)" /></div>
            <label style="font-size:27px; font-weight:600; color:#f2f7fa; letter-spacing:0px;">@_helpTitle</label>
            <label class="settings-a-help-text" style="font-size:19px; font-weight:400; color:#b9ccd9; letter-spacing:0px;">@_help</label>
        </div>
    </div>
    <div class="settings-a-footer">
        @if(InRun){<button onclick="@BackRequested"><label style="font-size:18px; color:#ffffff; letter-spacing:0px;">Back</label></button>}
        <label class="settings-a-status">@(Dirty ? "Unsaved changes" : _status)</label>
        <button onclick="@Reset"><label style="font-size:18px; color:#ffffff; letter-spacing:0px;">Restore defaults</label></button>
        <button onclick="@Cancel"><label style="font-size:18px; color:#ffffff; letter-spacing:0px;">Cancel</label></button>
        <button class="settings-a-apply @(!Dirty ? "inactive" : "")" disabled="@(!Dirty)" onclick="@Apply"><label style="font-size:18px; font-weight:600; color:#ffffff; letter-spacing:0px;">Apply changes</label></button>
    </div>
</root>
@code {
    public bool InRun { get; set; }
    public Action BackRequested { get; set; }
    private static readonly string[] Categories = { "Video", "Audio", "Controls", "Crosshair", "Damage numbers" };
    private GameSettingsData _draft = GameProfile.Settings.Copy();
    private string _category = "Audio";
    private string _helpTitle = "Audio";
    private string _help = "Balance music, sound effects and voice chat separately. Master volume affects all sound.";
    private string _status = "Changes apply after saving";
    private int _revision;
    private static string Signature(GameSettingsData s) => $"{s.FieldOfView}|{s.Brightness}|{s.SoundVolume}|{s.MusicVolume}|{s.EffectsVolume}|{s.VoiceVolume}|{s.MouseSensitivity}|{s.InvertMouse}|{s.ShowCrosshair}|{s.ShowDamageNumbers}|{s.ReduceWeaponMotion}|{s.MicrophoneEnabled}|{s.CrosshairLength}|{s.CrosshairGap}|{s.CrosshairThickness}|{s.CrosshairOpacity}|{s.CrosshairHue}|{s.CrosshairDot}|{s.CrosshairOutline}|{s.DamageTextSize}|{s.DamageHue}|{s.CrosshairSaturation}|{s.DamageSaturation}|{s.ShowHitmarker}|{s.HitmarkerBodyHue}|{s.HitmarkerHeadHue}|{s.ShowHurtEffect}|{s.HurtEffectHue}";
    private bool Dirty => Signature(_draft) != Signature(GameProfile.Settings);
    private static string CategoryIcon(string c) => c switch { "Video" => "monitor", "Audio" => "speaker", "Controls" => "keyboard", "Crosshair" => "interface", "Damage numbers" => "plus", _ => "accessibility" };
    private void SelectCategory(string c) { _category=c; _helpTitle=c; _help="Select a setting to see what it changes."; _revision++; StateHasChanged(); }
    private void Help(Option o) { if (_helpTitle == o.Name) return; _helpTitle=o.Name; _help=o.Help; _revision++; StateHasChanged(); }
    private void Changed(Option o) { Help(o); _revision++; StateHasChanged(); }
    private void Apply() { GameProfile.ApplySettings(_draft); _status="Settings saved"; _revision++; StateHasChanged(); }
    private void Cancel() { _draft=GameProfile.Settings.Copy(); _status="Changes discarded"; _revision++; StateHasChanged(); }
    private void Reset()
    {
        var defaults = new GameSettingsData();
        switch (_category)
        {
            case "Video":
                _draft.FieldOfView=defaults.FieldOfView; _draft.Brightness=defaults.Brightness; break;
            case "Audio":
                _draft.SoundVolume=defaults.SoundVolume; _draft.MusicVolume=defaults.MusicVolume;
                _draft.EffectsVolume=defaults.EffectsVolume; _draft.VoiceVolume=defaults.VoiceVolume;
                _draft.MicrophoneEnabled=defaults.MicrophoneEnabled; break;
            case "Controls":
                _draft.MouseSensitivity=defaults.MouseSensitivity; _draft.InvertMouse=defaults.InvertMouse; break;
            case "Crosshair":
                _draft.ShowCrosshair=defaults.ShowCrosshair; _draft.CrosshairLength=defaults.CrosshairLength;
                _draft.CrosshairGap=defaults.CrosshairGap; _draft.CrosshairThickness=defaults.CrosshairThickness;
                _draft.CrosshairHue=defaults.CrosshairHue; _draft.CrosshairSaturation=defaults.CrosshairSaturation;
                _draft.CrosshairOpacity=defaults.CrosshairOpacity; _draft.CrosshairDot=defaults.CrosshairDot;
                _draft.CrosshairOutline=defaults.CrosshairOutline; break;
            case "Damage numbers":
                _draft.ShowDamageNumbers=defaults.ShowDamageNumbers; _draft.DamageTextSize=defaults.DamageTextSize;
                _draft.DamageHue=defaults.DamageHue; _draft.DamageSaturation=defaults.DamageSaturation;
                _draft.ShowHitmarker=defaults.ShowHitmarker;
                _draft.HitmarkerBodyHue=defaults.HitmarkerBodyHue;
                _draft.HitmarkerHeadHue=defaults.HitmarkerHeadHue;
                _draft.ShowHurtEffect=defaults.ShowHurtEffect;
                _draft.HurtEffectHue=defaults.HurtEffectHue;
                break;
        }
        _status=_category + " defaults restored - apply to save"; _revision++; StateHasChanged();
    }
    protected override int BuildHash() => HashCode.Combine(_revision, Signature(GameProfile.Settings));
    private sealed class Option {
        public string Name, Help; public Func<float> Value; public Action<float> Set; public Action Toggle, Action;
        public float Min, Max, Step; public Func<float,string> Format;
    }
    private Option Range(string n,string h,Func<float> get,Action<float> set,float min,float max,float step,bool percent=true) => new() { Name=n, Help=h, Value=get, Set=v=>set(Math.Clamp(v,min,max)), Min=min, Max=max, Step=step, Format=v=>percent ? $"{v*100:0}%" : $"{v:0.##}" };
    private Option FeedbackRange(string name, Func<float> get, Action<float> set)
    {
        var option = Range(name,"Choose a color with live preview. Move fully left for white.",get,set,-1,360,1,false);
        option.Format = v => v < 0 ? "White" : $"{v:0}";
        return option;
    }
    private Option Toggle(string n,string h,Func<bool> get,Action action) => new() {Name=n,Help=h,Value=()=>get()?1f:0f,Toggle=action};
    private Option Native(string n,string h,string page) => new() {Name=n,Help=h,Action=()=>Sandbox.Game.Overlay.ShowSettingsModal(page)};
    private IEnumerable<Option> Options() {
        switch(_category) {
        case "Video":
            yield return Range("Field of view","Wider values show more of the room. Applied to your first-person camera.",()=>_draft.FieldOfView,v=>_draft.FieldOfView=v,75,120,1,false);
            yield return Range("Menu brightness","Adjusts interface brightness. World lighting is configured by the game scene.",()=>_draft.Brightness,v=>_draft.Brightness=v,.65f,1.5f,.05f);
            yield return Native("Display and resolution","Choose your monitor, window mode and resolution in s&box.","display");
            yield return Native("Graphics quality","Configure graphics quality and performance in s&box.","graphics"); break;
        case "Audio":
            yield return Range("Master volume","Controls the overall volume of music, effects and voice chat.",()=>_draft.SoundVolume,v=>_draft.SoundVolume=v,0,2,.01f);
            yield return Range("Music","Lobby and run music volume. 100% preserves the music level already tuned for this game.",()=>_draft.MusicVolume,v=>_draft.MusicVolume=v,0,2,.01f);
            yield return Range("Sound effects","Controls shots, combat feedback and the match-ready cue.",()=>_draft.EffectsVolume,v=>_draft.EffectsVolume=v,0,2,.01f);
            yield return Range("Voice chat","Scales incoming voice chat. Individual teammate volume can also be adjusted in the ESC menu.",()=>_draft.VoiceVolume,v=>_draft.VoiceVolume=v,0,2,.01f);
            yield return Toggle("Microphone","When off, your microphone is never transmitted. When on, hold your Voice key (T by default) to talk.",()=>_draft.MicrophoneEnabled,()=>_draft.MicrophoneEnabled=!_draft.MicrophoneEnabled);
            yield return Native("Audio devices","Select output and microphone devices using the s&box audio settings.","audio"); break;
        case "Controls":
            yield return Range("Mouse sensitivity","Mouse look speed. 1 is the default multiplier.",()=>_draft.MouseSensitivity,v=>_draft.MouseSensitivity=v,.25f,3,.05f,false);
            yield return Toggle("Invert vertical look","Reverses vertical mouse movement without changing horizontal aim.",()=>_draft.InvertMouse,()=>_draft.InvertMouse=!_draft.InvertMouse);
            yield return Native("Key bindings","Rebind movement, fire, dash, ultimate, reload, use and push-to-talk in s&box.","keybinds"); break;
        case "Crosshair":
            yield return Toggle("Show crosshair","Display your custom crosshair during gameplay.",()=>_draft.ShowCrosshair,()=>_draft.ShowCrosshair=!_draft.ShowCrosshair);
            yield return Range("Line length","Length of each of the four crosshair lines, in pixels.",()=>_draft.CrosshairLength,v=>_draft.CrosshairLength=v,0,24,1,false);
            yield return Range("Center gap","Distance from the aiming point to each line.",()=>_draft.CrosshairGap,v=>_draft.CrosshairGap=v,0,20,1,false);
            yield return Range("Thickness","Thickness of the lines and center dot, in pixels.",()=>_draft.CrosshairThickness,v=>_draft.CrosshairThickness=v,1,6,1,false);
            yield return Range("Color","Choose a hue across the full color spectrum. Preview updates immediately.",()=>_draft.CrosshairHue,v=>_draft.CrosshairHue=v,0,360,1,false);
            yield return Range("Color intensity","0% is white, 100% is the selected full color.",()=>_draft.CrosshairSaturation,v=>_draft.CrosshairSaturation=v,0,1,.01f);
            yield return Range("Opacity","Crosshair visibility from transparent to fully opaque.",()=>_draft.CrosshairOpacity,v=>_draft.CrosshairOpacity=v,0,1,.05f);
            yield return Toggle("Center dot","Adds a dot exactly at the aiming point.",()=>_draft.CrosshairDot,()=>_draft.CrosshairDot=!_draft.CrosshairDot);
            yield return Toggle("Dark outline","Adds a dark edge for contrast on bright surfaces.",()=>_draft.CrosshairOutline,()=>_draft.CrosshairOutline=!_draft.CrosshairOutline); break;
        case "Damage numbers":
            yield return Toggle("Show damage","Shows the damage value when you hit an enemy. Numbers appear around the crosshair.",()=>_draft.ShowDamageNumbers,()=>_draft.ShowDamageNumbers=!_draft.ShowDamageNumbers);
            yield return Range("Text size","Size of damage numbers in pixels, shown in the preview.",()=>_draft.DamageTextSize,v=>_draft.DamageTextSize=v,14,56,1,false);
            yield return Range("Color","Choose the color used for damage numbers.",()=>_draft.DamageHue,v=>_draft.DamageHue=v,0,360,1,false);
            yield return Range("Color intensity","0% is white, 100% is the selected full color.",()=>_draft.DamageSaturation,v=>_draft.DamageSaturation=v,0,1,.01f);
            yield return Toggle("Show hitmarker","Show hit confirmation at the crosshair. Hit sounds stay enabled.",()=>_draft.ShowHitmarker,()=>_draft.ShowHitmarker=!_draft.ShowHitmarker);
            yield return FeedbackRange("Body hit color",()=>_draft.HitmarkerBodyHue,v=>_draft.HitmarkerBodyHue=v);
            yield return FeedbackRange("Headshot color",()=>_draft.HitmarkerHeadHue,v=>_draft.HitmarkerHeadHue=v);
            yield return Toggle("Incoming damage effect","Flash the screen edges when you take damage. The damage sound stays enabled.",()=>_draft.ShowHurtEffect,()=>_draft.ShowHurtEffect=!_draft.ShowHurtEffect);
            yield return FeedbackRange("Incoming damage color",()=>_draft.HurtEffectHue,v=>_draft.HurtEffectHue=v); break;
        }
    }
}