The file manages game keybind configurations. It defines default bindings, loads user overrides from a local JSON file, and applies input actions to the s&box engine on every update and fixed update.
namespace HorizonRP;
/// <summary>
/// Nos propres touches, réglées depuis le menu pause et gardées chez le joueur
/// (touches.json dans ses données locales).
///
/// Le jeu ne peut pas réécrire les liaisons de s&box (InputBinds est interne),
/// mais il peut lire le clavier brut et poser lui-même l'état d'une action :
/// une action déplacée sur une autre touche est donc pilotée par nous à chaque
/// image, avant tout composant, et sa touche d'origine n'y fait plus rien. Une
/// action laissée sur sa touche par défaut reste à s&box, manette comprise.
///
/// Les noms de touches sont des positions physiques, comme dans Input.config :
/// « q » est le A d'un clavier AZERTY. <see cref="Display"/> les traduit.
/// </summary>
public static class Keybinds
{
public record Bind( string Action, string Label, string Group, string Default, bool StaffOnly = false );
/// <summary>
/// Les défauts reprennent Input.config : les changer ensemble. Une propriété, pas un
/// champ : un tableau statique garderait son ancienne valeur après un rechargement à
/// chaud dans l'éditeur.
/// </summary>
public static Bind[] All => new Bind[]
{
new( "Forward", "Avancer", "Déplacements", "w" ),
new( "Backward", "Reculer", "Déplacements", "s" ),
new( "Left", "Aller à gauche", "Déplacements", "a" ),
new( "Right", "Aller à droite", "Déplacements", "d" ),
new( "Jump", "Sauter", "Déplacements", "space" ),
new( "Run", "Courir", "Déplacements", "shift" ),
new( "Walk", "Marcher", "Déplacements", "alt" ),
new( "Duck", "S'accroupir", "Déplacements", "ctrl" ),
new( "use", "Interagir", "Actions", "e" ),
new( "Drop", "Lâcher l'objet en main", "Actions", "q" ),
new( "Search", "Fouiller un corps (maintenir)", "Actions", "g" ),
new( "View", "Vue : 1re personne, derrière, face", "Actions", "c" ),
new( "Voice", "Parler", "Actions", "v" ),
new( "Score", "Liste des joueurs", "Actions", "tab" ),
new( "KeyHints", "Afficher les touches", "Actions", "b" ),
new( "JobMenu", "Menu", "Menus", "f4" ),
new( "Phone", "Téléphone", "Menus", "uparrow" ),
new( "Inventory", "Inventaire", "Menus", "i" ),
new( "Wallet", "Portefeuille", "Menus", "k" ),
new( "PropMenu", "Props", "Menus", "p" ),
new( "PropUndo", "Retirer le dernier prop", "Menus", "u" ),
new( "WeaponMenu", "Menu de l'arme", "Menus", "z" ),
new( "DoorMenu", "Porte", "Menus", "o" ),
new( "VehicleLock", "Verrou véhicule", "Véhicule", "l" ),
new( "Horn", "Klaxon", "Véhicule", "h" ),
new( "Engine", "Moteur (démarrer / couper)", "Véhicule", "m" ),
new( "RadioToggle", "Radio (allumer / couper)", "Véhicule", "r" ),
new( "RadioPrevious", "Radio : station précédente", "Véhicule", "u" ),
new( "RadioNext", "Radio : station suivante", "Véhicule", "i" ),
new( "Headlights", "Phares (codes, phares, éteints)", "Véhicule", "j" ),
new( "IndicatorLeft", "Clignotant gauche", "Véhicule", "leftarrow" ),
new( "IndicatorRight", "Clignotant droit", "Véhicule", "rightarrow" ),
new( "Hazards", "Feux de détresse", "Véhicule", "downarrow" ),
new( "VehicleAdmin", "Gérer le véhicule", "Staff", "g", true ),
// Police (en service seulement : Police/PoliceRules.cs).
new( "PoliceMenu", "Menu police (actions sur un citoyen)", "Police", "f6" ),
new( "PoliceTablet", "Tablette de police (MDT)", "Police", "f7" ),
new( "SirenLights", "Gyrophares", "Police", "y" ),
new( "Siren", "Sirène (tonalité suivante)", "Police", "t" ),
new( "AdminMenu", "Administration", "Staff", "ins", true ),
new( "StaffTool", "Physgun", "Staff", "x", true ),
new( "Noclip", "Noclip", "Staff", "n", true ),
};
/// <summary>Les touches qu'on peut prendre : ni la souris (elle clique dans le menu), ni Échap.</summary>
static readonly string[] Candidates =
{
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0",
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
"space", "shift", "ctrl", "alt", "tab", "enter", "backspace", "capslock", "semicolon", "comma",
"ins", "del", "home", "end", "pgup", "pgdn", "uparrow", "downarrow", "leftarrow", "rightarrow", "mouse3",
};
/// <summary>
/// Prises par une action que ce menu ne règle pas (recharger, emplacements,
/// lampe, discussion), ou par s&box et son éditeur : on les refuse plutôt que
/// de laisser deux actions sur une touche.
/// </summary>
static readonly Dictionary<string, string> Reserved = new()
{
["r"] = "recharger", ["f"] = "la lampe", ["enter"] = "la discussion",
["1"] = "les emplacements", ["2"] = "les emplacements", ["3"] = "les emplacements", ["4"] = "les emplacements", ["5"] = "les emplacements",
["6"] = "les emplacements", ["7"] = "les emplacements", ["8"] = "les emplacements", ["9"] = "les emplacements", ["0"] = "les emplacements",
["f1"] = "s&box", ["f2"] = "la console", ["f6"] = "s&box", ["f7"] = "s&box", ["f8"] = "s&box",
};
const string File = "touches.json";
static Dictionary<string, string> overrides;
/// <summary>Les actions déplacées par le joueur, action → touche.</summary>
static Dictionary<string, string> Overrides => overrides ??= Load();
static Dictionary<string, string> Load()
{
var saved = FileSystem.Data.ReadJsonOrDefault<Dictionary<string, string>>( File ) ?? new();
// Une action disparue ou une touche hors liste (fichier retouché) est oubliée.
return saved.Where( x => All.Any( b => b.Action == x.Key ) && Candidates.Contains( x.Value ) )
.ToDictionary( x => x.Key, x => x.Value );
}
static void Save() => FileSystem.Data.WriteJson( File, Overrides );
public static string KeyOf( string action )
{
if ( Overrides.TryGetValue( action, out var key ) )
return key;
return DefaultOf( action );
}
/// <summary>
/// Les défauts qui changent avec le clavier. Une touche s'y désigne par sa position
/// QWERTY : « m » est la virgule d'un AZERTY. Sur AZERTY, le moteur va donc sur la vraie
/// touche M, qui est à la place du point-virgule QWERTY.
/// </summary>
static readonly Dictionary<string, string> AzertyDefaults = new()
{
["Engine"] = "semicolon",
};
/// <summary>La touche par défaut d'une action, selon le clavier du joueur.</summary>
public static string DefaultOf( string action ) =>
Layout == Azerty && AzertyDefaults.TryGetValue( action, out var key ) ? key : All.FirstOrDefault( x => x.Action == action )?.Default;
/// <summary>Ce que le jeu pilote lui-même : les touches déplacées par le joueur, et les défauts propres à son clavier.</summary>
static IEnumerable<KeyValuePair<string, string>> Driven =>
Overrides.Concat( All.Where( b => !Overrides.ContainsKey( b.Action ) && (DefaultOf( b.Action ) != b.Default || b.Action.StartsWith( "Radio" )) )
.Select( b => new KeyValuePair<string, string>( b.Action, DefaultOf( b.Action ) ) ) );
public static bool IsChanged( string action ) => Overrides.ContainsKey( action );
/// <summary>L'action en attente d'une nouvelle touche, ou null.</summary>
public static string Capturing { get; private set; }
/// <summary>Le dernier mot du menu : touche refusée, échange fait.</summary>
public static string Message { get; private set; }
public static void StartCapture( string action )
{
Capturing = action;
Message = null;
}
public static void CancelCapture()
{
Capturing = null;
Message = null;
}
/// <summary>
/// Pose une touche. Déjà prise par une autre action du menu : les deux
/// échangent leurs touches, pour qu'aucune ne se retrouve sans.
/// </summary>
public static void Assign( string action, string key )
{
Capturing = null;
if ( Reserved.TryGetValue( key, out var owner ) && !(action == "RadioToggle" && key == "r") )
{
Message = $"{Display( key )} est réservée à {owner}.";
return;
}
var old = KeyOf( action );
var other = All.FirstOrDefault( x => x.Action != action && KeyOf( x.Action ) == key && !ContextualPair( action, x.Action ) );
Message = null;
if ( action == "RadioToggle" && old == "r" && other is not null && other.Group != "Véhicule" )
{
Message = $"{Display( key )} est déjà utilisée par {other.Label}. Choisissez une touche libre.";
return;
}
if ( other is not null )
{
SetKey( other.Action, old );
Message = $"{other.Label} passe sur {Display( old )}.";
}
SetKey( action, key );
Save();
}
static void SetKey( string action, string key )
{
if ( key == DefaultOf( action ) )
Overrides.Remove( action );
else
Overrides[action] = key;
}
// À pied et au volant : U/I ont un rôle distinct, sans échanger les liaisons du joueur.
static bool ContextualPair( string a, string b ) =>
(a.StartsWith( "Radio" ) && b is "PropUndo" or "Inventory")
|| (b.StartsWith( "Radio" ) && a is "PropUndo" or "Inventory");
public static void ResetAll()
{
Overrides.Clear();
Capturing = null;
Message = "Touches d'origine rétablies.";
Save();
}
/// <summary>
/// Touches maintenues sans clavier, pour les essais pilotés depuis l'éditeur.
/// Vide en jeu.
/// </summary>
public static readonly HashSet<string> Simulated = new();
static bool Down( string key ) => Simulated.Contains( key ) || Input.Keyboard.Down( key );
static bool Pressed( string key ) => Input.Keyboard.Pressed( key );
static readonly string[] Moves = { "Forward", "Backward", "Left", "Right" };
/// <summary>Chaque image, avant les composants.</summary>
/// <summary>Les actions pilotées par le jeu enfoncées à l'image précédente, et celles pressées à celle-ci.</summary>
static readonly HashSet<string> heldLastFrame = new();
static readonly HashSet<string> pressedThisFrame = new();
/// <summary>
/// Une pression, vue une fois et une seule. Pour une action que le jeu pilote (touche
/// déplacée, ou le moteur sur la vraie touche M d'un AZERTY), s&box ne le sait pas : son
/// état est posé deux fois par image (image et image fixe), et « vient d'être pressée » s'y
/// perdait ou s'y doublait — M démarrait puis recoupait aussitôt, ou rien. Le jeu compare
/// donc lui-même l'état d'une image à l'autre. Les autres actions : Input.Pressed.
/// </summary>
public static bool WasPressed( string action )
{
if ( !Driven.Any( x => x.Key == action ) )
return Input.Pressed( action );
// Relevé à la première question de l'image, pas par un écouteur de GameObjectSystem :
// ajouté pendant une partie, il ne se branche qu'au chargement suivant de la scène, et M
// ne voyait plus aucune pression.
if ( trackedAt != Time.Now )
{
trackedAt = Time.Now;
TrackPresses();
}
return pressedThisFrame.Contains( action );
}
static float trackedAt = -1;
/// <summary>Une fois par image, au tout début (pas à l'image fixe) : les pressions des actions pilotées.</summary>
internal static void TrackPresses()
{
pressedThisFrame.Clear();
if ( Capturing is not null )
return;
var typing = Sandbox.UI.InputFocus.Current is not null;
foreach ( var (action, key) in Driven.ToList() )
{
var down = !typing && Down( key );
if ( down && !heldLastFrame.Contains( action ) )
pressedThisFrame.Add( action );
if ( down )
heldLastFrame.Add( action );
else
heldLastFrame.Remove( action );
}
}
internal static void Apply()
{
if ( Capturing is not null )
{
// La touche frappée devient celle de l'action, et ne déclenche rien d'autre.
var key = Candidates.FirstOrDefault( Pressed );
foreach ( var bind in All )
Input.Clear( bind.Action );
if ( key is not null )
Assign( Capturing, key );
return;
}
var driven = Driven.ToList();
if ( driven.Count == 0 )
return;
// Une saisie de texte a le clavier : rien ne doit partir pendant qu'on écrit.
var typing = Sandbox.UI.InputFocus.Current is not null;
foreach ( var (action, key) in driven )
Input.SetAction( action, !typing && Down( key ) );
// Le déplacement se lit dans AnalogMove, que s&box a déjà calculé avec les
// anciennes touches.
if ( Moves.Any( Overrides.ContainsKey ) )
{
float Axis( string action ) => Input.Down( action ) ? 1f : 0f;
Input.AnalogMove = new Vector3( Axis( "Forward" ) - Axis( "Backward" ), Axis( "Left" ) - Axis( "Right" ), 0 );
}
}
/// <summary>
/// Le nom d'une touche tel que le joueur la voit sur son clavier. s&box ne
/// donne au jeu le nom local que pour ses propres liaisons : la disposition
/// se déduit donc de celle d'« Avancer » (W en QWERTY, Z en AZERTY).
/// </summary>
public static string Display( string key )
{
if ( string.IsNullOrEmpty( key ) )
return "?";
var layout = Layout;
if ( layout.TryGetValue( key, out var local ) )
return local;
return key switch
{
"space" => "ESPACE",
"shift" => "MAJ",
"ctrl" => "CTRL",
"alt" => "ALT",
"tab" => "TAB",
"enter" => "ENTRÉE",
"backspace" => "RETOUR",
"capslock" => "VERR. MAJ",
"ins" => "INSER",
"del" => "SUPPR",
"home" => "DÉBUT",
"end" => "FIN",
"pgup" => "PG. HAUT",
"pgdn" => "PG. BAS",
"uparrow" => "↑",
"downarrow" => "↓",
"leftarrow" => "←",
"rightarrow" => "→",
"mouse3" => "MOLETTE",
"semicolon" => ";",
"comma" => ",",
_ => key.ToUpperInvariant(),
};
}
/// <summary>La touche d'une action, prête à afficher (celle de s&box pour une action que ce menu ne règle pas).</summary>
public static string DisplayOf( string action ) =>
KeyOf( action ) is { } key ? Display( key ) : (Input.GetButtonOrigin( action ) ?? "?").ToUpperInvariant();
/// <summary>L'icône à mettre à la place d'une flèche, que Poppins n'a pas.</summary>
public static string ArrowIcon( string shown ) => shown switch
{
"↑" => "keyboard_arrow_up",
"↓" => "keyboard_arrow_down",
"←" => "keyboard_arrow_left",
"→" => "keyboard_arrow_right",
_ => null,
};
static readonly Dictionary<string, string> Azerty = new()
{
["q"] = "A", ["a"] = "Q", ["w"] = "Z", ["z"] = "W", ["m"] = ",", ["semicolon"] = "M",
};
static readonly Dictionary<string, string> Qwertz = new()
{
["y"] = "Z", ["z"] = "Y",
};
static readonly Dictionary<string, string> Qwerty = new();
static Dictionary<string, string> Layout => (Input.GetButtonOrigin( "Forward" ) ?? "").ToUpperInvariant() switch
{
"Z" => Azerty,
_ => (Input.GetButtonOrigin( "PropUndo" ) ?? "").ToUpperInvariant() == "Y" ? Qwertz : Qwerty,
};
}
/// <summary>Pose nos touches au tout début de chaque image (et image fixe), avant le PlayerController.</summary>
public sealed class KeybindSystem : GameObjectSystem
{
public KeybindSystem( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, -1000, Apply, "Touches Horizon RP" );
// Le PlayerController lit le déplacement à l'image fixe : il faut y être aussi.
Listen( Stage.StartFixedUpdate, -1000, Apply, "Touches Horizon RP (fixe)" );
}
void Apply()
{
if ( Application.IsDedicatedServer || !Game.IsPlaying )
return;
Keybinds.Apply();
}
}