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 ShowGameplayText { get; private set; } = true;
    // Standings are a temporary view, never a persistent gameplay overlay.
    public bool ShowScoreboard { get; private set; }
    private int scoreboardRound = -1;
    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 LastCaptureButton { get; private set; }
    public string Notice { get; private set; } = "Select an action, then press its new key. Esc or M 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 ArcadePlateAssist PlateAssist {get;}=new();
    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","home","end","ins","del","pgup","pgdn","capslock","kp_enter","kp_del","kp_plus","kp_minus","kp_multiply","kp_divide","mouse1","mouse2","mouse3","mouse4","mouse5","f2","f3","f4","f5","f7","f8","f9","f10","f11","f12"})
        .Concat(Enumerable.Range(0,10).Select(i=>$"kp_{i}")).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"));
        ShowGameplayText = Game.Cookies.Get("paintball-show-gameplay-text", true);
        ShowScoreboard = false;
        var excluded=new[]{"run","jump","slot7","slot8","slot9","slot0","slotprev","slotnext","view","drop","score","menu","chat","voicetransmit"};
        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";
        defaults["togglehud"]="h";Titles["togglehud"]="Show / hide match announcements";
        defaults["togglescoreboard"]="j";Titles["togglescoreboard"]="Open / close standings";
        defaults["voice"]="v";Titles["voice"]="Voice microphone (tap on/off; optional hold mode)";
        defaults["vault"]="x";
        Titles["attack1"]="Fire / cover peek / close strike";Titles["attack2"]="Focus (optional) / rapid recovery";Titles["duck"]="Hold crouch (optional)";
        defaults["flashlight"]="b";Titles["flashlight"]="Play / restart round (host)";Titles["use"]="Reset practice targets";
        Titles["cover"]="Cover nearby / toggle crouch in the open";
        for(int i=0;i<6;i++)Titles[$"slot{i+1}"]=$"Select {RosterSelection.Names[i]}";
        Titles["slot1"]="Equip dual pistols";Titles["slot2"]="Equip explosive crossbow";
        defaults["grenade"]="g";Titles["grenade"]="Throw ready special";
        var stored=ControlBindingsStore.Load();
        var saved=stored ?? Game.Cookies.Get("paintball-bindings-v3",new Dictionary<string,string>()) ?? new();
        bool migrate=stored is null;
        if(saved.Count==0)
        {
            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";}
            if(saved.GetValueOrDefault("vault")=="v")
                saved["vault"]=saved.Any(x=>x.Key!="vault" && x.Value=="x")
                    ? Keys.First(k=>k!="v" && !defaults.Values.Contains(k) && !saved.Values.Contains(k)) : "x";
            // Voice has not shipped before v3; reserve its new default without overwriting other custom actions.
            saved.Remove("voice");
        }
        foreach(var pair in defaults)Bindings[pair.Key]=saved.TryGetValue(pair.Key,out var key) && Keys.Contains(key) ? key : pair.Value;
        // Preserve older custom H/J bindings when adding these new actions.
        foreach(var action in new[]{"togglehud","togglescoreboard","voice","grenade"})
            if(!saved.ContainsKey(action) && Bindings.Any(x=>x.Key!=action && x.Value==Bindings[action]))
                Bindings[action]=Keys.First(key=>!Bindings.Any(x=>x.Key!=action && x.Value==key));
        if(migrate)SaveBindings();
        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) ? KeyLabel(key) : Input.GetButtonOrigin(action);
    private static string KeyLabel(string key) => key switch
    {
        "uparrow" => "UP ARROW", "downarrow" => "DOWN ARROW",
        "leftarrow" => "LEFT ARROW", "rightarrow" => "RIGHT ARROW",
        _ when key.StartsWith("kp_") => $"NUM {key[3..].ToUpperInvariant()}",
        _ => key.ToUpperInvariant()
    };
    public void SetMovementLayout(bool arrows)
    {
        var actions=new[]{"forward","backward","left","right"};
        var keys=arrows ? new[]{"uparrow","downarrow","leftarrow","rightarrow"} : new[]{"w","s","a","d"};
        for(int i=0;i<actions.Length;i++)Rebind(actions[i],keys[i]);
        if(SaveBindings())Notice=arrows ? "Arrow-key movement saved. Other controls stay available below." : "WASD movement saved. Other controls stay available below.";
    }
    public void ToggleGameplayText()
    {
        ShowGameplayText=!ShowGameplayText;
        Game.Cookies.Set("paintball-show-gameplay-text",ShowGameplayText);
    }
    public void ToggleScoreboard()
    {
        ShowScoreboard=!ShowScoreboard;
    }
    public void Toggle()
    {
        quickTurn.Reset();
        if(Open && (MultiplayerSession.Find(Scene)?.QuickPlaying==true || Networking.IsConnecting))
            MultiplayerSession.Find(Scene)?.CancelMenuConnection();
        Open=!Open;Capturing=null;ShowScoreboard=false;
        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.";}
    // Focused UI receives navigation keys before the gameplay keyboard poll.
    // Normalize UI event names to the engine's physical keyboard binding names.
    public bool CaptureButton(string key, bool pressed)
    {
        if(!Open || Capturing is null || !pressed)return false;
        LastCaptureButton=key;
        key=(key ?? "").ToLowerInvariant();
        if(key.StartsWith("key_"))key=key[4..];
        if(key.StartsWith("pad_") && key.Length==5 && char.IsDigit(key[4]))key=$"kp_{key[4]}";
        key=key switch
        {
            "up" or "arrowup" => "uparrow", "down" or "arrowdown" => "downarrow",
            "left" or "arrowleft" => "leftarrow", "right" or "arrowright" => "rightarrow",
            "pad_enter" => "kp_enter", "pad_decimal" => "kp_del",
            "pad_plus" => "kp_plus", "pad_minus" => "kp_minus",
            "pad_multiply" => "kp_multiply", "pad_divide" => "kp_divide",
            "insert" => "ins", "delete" => "del", "pageup" => "pgup", "pagedown" => "pgdn",
            "return" => "enter", "control" or "lctrl" or "rctrl" => "ctrl",
            "mouseleft" => "mouse1", "mouseright" => "mouse2", "mousemiddle" => "mouse3",
            "mouseback" => "mouse4", "mouseforward" => "mouse5",
            "lcontrol" or "rcontrol" => "ctrl", "lshift" or "rshift" => "shift",
            "lalt" or "ralt" => "alt", var name => name
        };
        if(key=="escape"){Capturing=null;Input.EscapePressed=false;Notice="Binding cancelled.";return true;}
        if(key=="m"){Notice="M is reserved for the menu. Choose another key; Escape cancels.";return true;}
        if(captureDelay>0 && key.StartsWith("mouse"))return true;
        if(!Rebind(Capturing,key))Notice="That key is not supported. Try another key; Escape cancels.";
        return true;
    }
    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;
        if(SaveBindings())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);Capturing=null;
        if(SaveBindings())Notice="Default controls restored.";
    }
    public bool UsesMovementLayout(bool arrows)
    {
        var actions=new[]{"forward","backward","left","right"};
        var keys=arrows ? new[]{"uparrow","downarrow","leftarrow","rightarrow"} : new[]{"w","s","a","d"};
        return actions.Select((action,index)=>Bindings.GetValueOrDefault(action)==keys[index]).All(matches=>matches);
    }
    private bool SaveBindings()
    {
        if(ControlBindingsStore.Save(Bindings))return true;
        Notice="Controls work for this session, but could not be saved. Please try again.";
        return false;
    }
    public void StartMatch()
    {
        if(!MultiplayerSession.Online){MultiplayerSession.Find(Scene)?.QuickPlay();return;}
        if(Open)Toggle();
        MultiplayerSession.LocalPlayer(Scene)?.Components.Get<ArenaMatch>()?.StartRound();
    }
    public void ResumeGame(){if(Open)Toggle();}
    public void QuitGame()
    {
        SavePlayerName();
        MultiplayerSession.Find(Scene)?.CancelPlaySearch();
        Game.Close();
    }
    public void SavePlayerName()
    {
        NameDraft=PlayerProfile.CleanName(NameDraft);
        Profile?.SaveName(NameDraft);
        Game.Cookies.Set("paintball-player-name",NameDraft);
    }
    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(ArenaSelection.Current(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()
    {
        int round=MultiplayerSession.Find(Scene)?.RoundId ?? 0;
        if(round!=scoreboardRound){scoreboardRound=round;ShowScoreboard=false;}
        foreach(var key in Keys.Concat(new[]{"m"}))
            if(Input.Keyboard.Pressed(key))ObservedPresses[key]=ObservedPresses.GetValueOrDefault(key)+1;
        if(Input.EscapePressed)
        {
            if(Capturing is not null){Capturing=null;Notice="Binding cancelled.";}else Toggle();
            Input.EscapePressed=false;
        }
        else if(Input.Keyboard.Pressed("m") && Capturing is not null)CaptureButton("m",true);
        else if(Input.Keyboard.Pressed("m") && !(Open && EditingName))Toggle();
        if(Open && Capturing is not null && --captureDelay<=0)
            foreach(var key in Keys)if(Input.Keyboard.Pressed(key)){CaptureButton(key,true);break;}
        ApplyMappings();
        if(!Open && Input.Pressed("togglehud"))ToggleGameplayText();
        if(!Open && Input.Pressed("togglescoreboard"))ToggleScoreboard();
        UpdateQuickTurn();
        var player=MultiplayerSession.LocalPlayer(Scene);
        UpdateWeapons(player);
        PlateAssist.Update(player,!Open && !QuickTurning && player.IsValid() && player.UseLookControls
            && player.Components.Get<PaintballMarker>()?.AcceptInput==true
            && player.Components.Get<CloseCombat>()?.Incapacitated!=true
            && player.Components.Get<VaultController>()?.IsVaulting!=true,Time.Delta);
    }
    public string GrenadeNotice {get;private set;}="";
    private float grenadeNoticeSeconds;
    private bool LoadingArena=>Components.Get<TrainingHud>()?.LoadingArena==true;
    private void UpdateWeapons(PlayerController player)
    {
        grenadeNoticeSeconds=MathF.Max(0,grenadeNoticeSeconds-Time.Delta);
        if(grenadeNoticeSeconds==0)GrenadeNotice="";
        if(Open || LoadingArena || !player.IsValid() || player.IsProxy || player.Components.Get<PaintballMarker>()?.AcceptInput!=true
            || MultiplayerSession.Find(Scene) is not {IsOverdrive:true,InRound:true})return;
        var actor=player.Components.Get<PaintballCombatant>();if(!actor.IsValid())return;
        if(Input.Pressed("grenade"))
        {
            GrenadeNotice=player.Components.Get<CloseCombat>()?.Incapacitated==true ? "GET UP BEFORE THROWING" :
                actor.GrenadeCharges<=0 ? "NO GRENADE · EARN ONE WITH FIVE HEADSHOTS" :
                player.Components.Get<VaultController>()?.IsVaulting==true || player.Components.Get<CoverController>()?.Sliding==true
                    || player.Components.Get<CloseCombat>()?.Attacking==true ? "FINISH YOUR MOVE, THEN THROW" : "";
            grenadeNoticeSeconds=2;
            if(GrenadeNotice.Length==0 && Scene.Camera.IsValid())
            {
                var camera=Scene.Camera;
                var aim=PaintballFlight.Trace(Scene,camera.WorldPosition,camera.WorldPosition+camera.WorldRotation.Forward*1700,player.GameObject).EndPosition;
                if(MultiplayerSession.Online)player.Components.Get<NetworkPawn>()?.RequestGrenade(aim);
                else actor.TryThrowGrenade(aim);
            }
        }
        int selection=Input.Pressed("slot1") ? 0 : Input.Pressed("slot2") ? 1 : MathF.Abs(Input.MouseWheel.y)>.01f ? 1-actor.EquippedWeapon : -1;
        if(selection<0 || selection==actor.EquippedWeapon)return;
        if(MultiplayerSession.Online)player.Components.Get<NetworkPawn>()?.RequestWeapon(selection);
        else actor.EquipWeapon(selection);
    }
    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&&!LoadingArena&&!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()
    {
        bool blocked=Open || LoadingArena;
        if(!blocked && Input.UsingController)return;
        blockedUntilRelease.RemoveWhere(key=>!Input.Keyboard.Down(key));
        foreach(var pair in Bindings)
        {
            var down=!blocked && !blockedUntilRelease.Contains(pair.Value) && Input.Keyboard.Down(pair.Value);
            if(pair.Key=="duck" && !blocked && Profile?.Components.Get<CoverController>()?.FreeCrouched==true)down=true;
            Input.SetAction(pair.Key,down);
            Input.SetLastAction(pair.Key,down && !Input.Keyboard.Pressed(pair.Value));
        }
        Input.AnalogMove=blocked ? 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(blocked)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);
}