PaintballControls.cs
using Sandbox;
using System;
namespace PaintballBaddies;

/// <summary>Player-local bindings feeding the native controller and action system.</summary>
public sealed class PaintballControls : Component
{
    public bool Open { get; private set; }
    public bool Welcome { get; private set; }
    public bool EditingName { get; set; }
    public string NameDraft { get; set; } = "Player";
    public PlayerProfile Profile => MultiplayerSession.LocalPlayer(Scene)?.Components.Get<PlayerProfile>();
    public Dictionary<string,int> ObservedPresses { get; } = new();
    public string Capturing { get; private set; }
    public string Notice { get; private set; } = "Select an action, then press its new key. M always opens this menu.";
    public Dictionary<string,string> Bindings { get; private set; } = new();
    public Dictionary<string,string> Titles { get; private set; } = new();
    private Dictionary<string,string> defaults=new();
    private IDisposable updateHook,fixedHook;
    private float previousScale;
    private int captureDelay;
    private readonly QuickTurnGesture quickTurn=new();
    public bool QuickTurning => quickTurn.Turning;
    public int QuickTurnsCompleted => quickTurn.Completed;
    // Opt-in native review: prime the same gesture, then let normal input
    // eligibility, view updates and animation run on subsequent game frames.
    public void BeginQuickTurnReview()
    {
        quickTurn.Reset();
        quickTurn.Advance(true,0,true);
        quickTurn.Advance(false,0,true);
        quickTurn.Advance(true,0,true);
    }
    private readonly HashSet<string> blockedUntilRelease=new();
    private static readonly string[] Keys=("abcdefghijklnopqrstuvwxyz0123456789".Select(c=>c.ToString()))
        .Concat(new[]{"space","shift","ctrl","alt","tab","enter","backspace","uparrow","downarrow","leftarrow","rightarrow","mouse1","mouse2","mouse3","mouse4","mouse5","f2","f3","f4","f5","f7","f8","f9","f10","f11","f12"}).ToArray();
    protected override void OnStart()
    {
        var localPlayer = MultiplayerSession.LocalPlayer(Scene);
        if (localPlayer.IsValid() && !Profile.IsValid()) localPlayer.Components.Create<PlayerProfile>();
        NameDraft = PlayerProfile.CleanName(Game.Cookies.Get("paintball-player-name", "Player"));
        var excluded=new[]{"jump","slot7","slot8","slot9","slot0","slotprev","slotnext","view","voice","drop","score","menu","chat"};
        foreach(var action in Input.GetActions())
        {
            var name=action.Name.ToLowerInvariant();
            if(excluded.Contains(name))continue;
            defaults[name]=action.KeyboardCode.ToLowerInvariant();
            Titles[name]=action.Title ?? action.Name;
        }
        defaults["shoulder"]="q";Titles["shoulder"]="Switch shoulder";
        defaults["roundlength"]="t";Titles["roundlength"]="Round length";
        Titles["attack1"]="Fire marker";Titles["attack2"]="Aim / nearby gun-butt strike / rapid recovery";Titles["duck"]="Crouch";
        defaults["flashlight"]="b";Titles["flashlight"]="Start / restart Baddies match";Titles["use"]="Reset / return to training";
        Titles["cover"]="Take / leave cover";
        for(int i=0;i<6;i++)Titles[$"slot{i+1}"]=$"Select {RosterSelection.Names[i]}";
        var saved=Game.Cookies.Get("paintball-bindings-v2",new Dictionary<string,string>());
        if(saved.Count==0)
        {
            saved=Game.Cookies.Get("paintball-bindings-v1",new Dictionary<string,string>());
            saved["flashlight"]="b";
        }
        foreach(var pair in defaults)Bindings[pair.Key]=saved.TryGetValue(pair.Key,out var key) && Keys.Contains(key) ? key : pair.Value;
        updateHook=Scene.AddHook(GameObjectSystem.Stage.StartUpdate,-100,UpdateControls,nameof(PaintballControls),"Player bindings");
        fixedHook=Scene.AddHook(GameObjectSystem.Stage.StartFixedUpdate,-100,ApplyMappings,nameof(PaintballControls),"Native movement bindings");
        Welcome = !ArenaMatch.RoundAfterReload.HasValue;
        if (Welcome) Toggle();
    }
    public string Label(string action) => Bindings.TryGetValue(action.ToLowerInvariant(),out var key) ? key.ToUpperInvariant() : Input.GetButtonOrigin(action);
    public void Toggle()
    {
        quickTurn.Reset();
        Open=!Open;Capturing=null;
        if(Open){previousScale=Scene.TimeScale;if(!MultiplayerSession.Online)Scene.TimeScale=0;}
        else
        {
            Profile?.SaveName(NameDraft);
            NameDraft = Profile?.DisplayName ?? "Player";
            Welcome=false;EditingName=false;
            foreach(var key in Keys)if(Input.Keyboard.Down(key))blockedUntilRelease.Add(key);
            Scene.TimeScale=previousScale;Input.ReleaseActions();
        }
    }
    public void Capture(string action){Capturing=action;captureDelay=2;Notice="Press a key or mouse button. Escape cancels.";}
    public bool Rebind(string action,string key)
    {
        key=key.ToLowerInvariant();
        if(!Bindings.ContainsKey(action) || !Keys.Contains(key))return false;
        quickTurn.Reset();
        var old=Bindings[action];
        var conflict=Bindings.FirstOrDefault(x=>x.Key!=action && x.Value==key);
        if(conflict.Key is not null)Bindings[conflict.Key]=old;
        Bindings[action]=key;Game.Cookies.Set("paintball-bindings-v2",Bindings);
        Notice=conflict.Key is null ? "Binding saved." : $"Swapped with {Titles[conflict.Key]} to avoid a conflict.";
        Capturing=null;return true;
    }
    public void ResetBindings()
    {
        quickTurn.Reset();
        Bindings=new(defaults);Game.Cookies.Set("paintball-bindings-v2",Bindings);Capturing=null;Notice="Default controls restored.";
    }
    public void StartMatch()
    {
        if(Open)Toggle();
        MultiplayerSession.LocalPlayer(Scene)?.Components.Get<ArenaMatch>()?.StartRound();
    }
    public void StartPractice()
    {
        if(MultiplayerSession.Online){if(Open)Toggle();return;}
        if(Open)Toggle();
        var match=MultiplayerSession.LocalPlayer(Scene)?.Components.Get<ArenaMatch>();
        if(match?.Status != "TRAINING")match?.ReturnToTraining(endCurrentRound:true);
    }
    public void RestartGame()
    {
        if(MultiplayerSession.Online){if(Open)Toggle();MultiplayerSession.Find(Scene)?.StartRound();return;}
        var options=new SceneLoadOptions { ShowLoadingScreen=true, IsAdditive=false };
        if(!options.SetScene("scenes/neon_foundry.scene"))
        {
            Notice="Could not load the arena. Try again.";
            return;
        }
        ArenaMatch.RoundAfterReload=MultiplayerSession.LocalPlayer(Scene)?.Components.Get<ArenaMatch>()?.RoundDuration ?? 180;
        if(Open)Toggle();
        Scene.TimeScale=1;
        Input.ReleaseActions();
        Scene.Load(options);
    }
    private void UpdateControls()
    {
        foreach(var key in Keys.Concat(new[]{"m"}))
            if(Input.Keyboard.Pressed(key))ObservedPresses[key]=ObservedPresses.GetValueOrDefault(key)+1;
        if(Input.Keyboard.Pressed("m") && !(Open && EditingName))Toggle();
        if(Open && Input.EscapePressed)
        {
            if(Capturing is not null){Capturing=null;Notice="Binding cancelled.";}else Toggle();
            Input.EscapePressed=false;
        }
        if(Open && Capturing is not null && --captureDelay<=0)
            foreach(var key in Keys)if(Input.Keyboard.Pressed(key)){Rebind(Capturing,key);break;}
        ApplyMappings();
        UpdateQuickTurn();
    }
    private void UpdateQuickTurn()
    {
        var player=Profile?.Components.Get<PlayerController>();
        var cover=player?.Components.Get<CoverController>();
        var key=Bindings.GetValueOrDefault("backward","s");
        bool down=Input.Keyboard.Down(key)&&!blockedUntilRelease.Contains(key);
        bool allowed=!Open&&!Input.UsingController&&player.IsValid()&&player.UseLookControls&&player.UseCameraControls
            && (player.UseInputControls || cover?.Attached==true)
            && player.Components.Get<PaintballMarker>()?.AcceptInput==true
            && cover?.Sliding!=true&&player.Components.Get<VaultController>()?.IsVaulting!=true;
        float turn=quickTurn.Advance(down,Time.Delta,allowed);
        if(turn!=0)
        {
            cover?.Leave();
            var angles=player.EyeAngles;
            angles.yaw=MathF.IEEERemainder(angles.yaw+turn,360);
            player.EyeAngles=angles;
        }
        SuppressConsumedBackward();
    }
    private void SuppressConsumedBackward()
    {
        if(!quickTurn.ConsumeBackward)return;
        Input.SetAction("backward",false);
        Input.AnalogMove=Input.AnalogMove.WithX(MathF.Max(0,Input.AnalogMove.x));
    }
    private void ApplyMappings()
    {
        if(!Open && Input.UsingController)return;
        blockedUntilRelease.RemoveWhere(key=>!Input.Keyboard.Down(key));
        foreach(var pair in Bindings)
        {
            var down=!Open && !blockedUntilRelease.Contains(pair.Value) && Input.Keyboard.Down(pair.Value);
            Input.SetAction(pair.Key,down);
            Input.SetLastAction(pair.Key,down && !Input.Keyboard.Pressed(pair.Value));
        }
        Input.AnalogMove=Open ? Vector3.Zero :
            Vector3.Forward*((Input.Down("forward") ? 1 : 0)-(Input.Down("backward") ? 1 : 0))+
            Vector3.Left*((Input.Down("left") ? 1 : 0)-(Input.Down("right") ? 1 : 0));
        if(Open)Input.AnalogLook=Angles.Zero;
        SuppressConsumedBackward();
    }
    protected override void OnDestroy()
    {
        updateHook?.Dispose();fixedHook?.Dispose();
        if(Open)Scene.TimeScale=previousScale;
    }
    public static string Display(Scene scene,string action)=>scene.GetAllComponents<PaintballControls>().FirstOrDefault()?.Label(action) ?? Input.GetButtonOrigin(action);
}