Static catalogue of the game's keyboard shortcuts, with English/Russian labels, scope masks per screen (match, club, player, settings) and a chord parser for strings like 'Shift+J'. Also handles rebinding: capture mode polls the keyboard, rejects duplicate bindings per overlapping scope, saves through GamePreferences, rolls back on save failure and reloads saved overrides with conflict cleanup.
using Sandbox;
using System;
using System.Collections.Generic;
namespace Socer;
[Flags]
public enum GameHotkeyScope
{
Match=1, IntroReplay=2, Club=4, Player=8, Standard=16, Settings=32,
Global=Match|IntroReplay|Club|Player|Standard|Settings
}
public sealed class GameHotkeyDefinition
{
public string Id {get;}
public string DefaultBinding {get;}
public GameHotkeyScope Scope {get;}
readonly string groupEn,groupRu,labelEn,labelRu;
public GameHotkeyDefinition(string id,string groupEn,string groupRu,string labelEn,string labelRu,
string defaultBinding,GameHotkeyScope scope)
{Id=id;this.groupEn=groupEn;this.groupRu=groupRu;this.labelEn=labelEn;this.labelRu=labelRu;DefaultBinding=defaultBinding;Scope=scope;}
public string Group(bool russian)=>russian?groupRu:groupEn;
public string Label(bool russian)=>russian?labelRu:labelEn;
}
// Physical keyboard shortcuts. Dispatch/context ownership belongs to the UI
// controller; this catalogue never purchases, confirms or changes match state.
public static class GameHotkeys
{
[Flags] enum Modifiers { None=0,Ctrl=1,Shift=2,Alt=4 }
readonly record struct Key(string Name,string EngineName);
readonly record struct Chord(string Text,Key Key,Modifiers Modifiers);
static readonly List<Key> keys=CreateKeys();
static readonly Dictionary<string,Key> keyNames=CreateKeyNames();
const GameHotkeyScope Live=GameHotkeyScope.Match;
const GameHotkeyScope Speed=GameHotkeyScope.Match|GameHotkeyScope.Standard|GameHotkeyScope.IntroReplay;
const GameHotkeyScope Global=GameHotkeyScope.Global;
static GameHotkeyDefinition D(string id,string group,string groupRu,string en,string ru,string binding,GameHotkeyScope scope)
=>new(id,group,groupRu,en,ru,binding,scope);
static readonly GameHotkeyDefinition[] definitions={
D("app.settings","General","Общие","Settings","Настройки","F10",Global),
D("app.back","General","Общие","Back / close","Назад / закрыть","Escape",Global),
D("app.club","General","Общие","Open club","Открыть клуб","F1",Global),
D("app.confirm","General","Общие","Continue / confirm selection","Продолжить / подтвердить выбор","Enter",Live|GameHotkeyScope.Club|GameHotkeyScope.Player),
D("match.pause","Match","Матч","Pause / resume","Пауза / продолжить","Space",Speed),
D("match.slower","Match","Матч","Slower playback","Медленнее","Left",Speed),
D("match.faster","Match","Матч","Faster playback","Быстрее","Right",Speed),
D("match.normal_speed","Match","Матч","Normal speed","Обычная скорость","Down",Speed),
D("match.auto_speed","Match","Матч","Automatic playback speed","Автоматическая скорость","A",Speed),
D("match.skip","Match","Матч","Skip introduction / replay","Пропустить выход / повтор","Enter",GameHotkeyScope.IntroReplay),
D("match.substitutions","Match","Матч","Substitutions","Замены","S",Live|GameHotkeyScope.Player),
D("match.finish_text","Match","Матч","Finish in text mode","Завершить в текстовом режиме","",Live),
D("match.rematch","Match","Матч","Rematch","Переиграть матч","",Live),
D("match.new_match","Match","Матч","New match","Новый матч","",Live),
D("camera.toggle","Broadcast","Трансляция","Camera / return to broadcast","Камера / вернуться к трансляции","C",Live|GameHotkeyScope.Standard),
D("view.vision","Broadcast","Трансляция","Player vision","Области видимости","V",Live),
D("view.speech","Broadcast","Трансляция","Player speech","Реплики игроков","B",Live),
D("standards.manual","Broadcast","Трансляция","Manual set pieces","Стандарты вручную","M",Live|GameHotkeyScope.Standard),
D("tactics.counter","Team instructions","Указания команде","Counterattack","Контратака","1",Live),
D("tactics.balanced","Team instructions","Указания команде","Balanced tactics","Сбалансированная тактика","2",Live),
D("tactics.pressing","Team instructions","Указания команде","Pressing tactics","Прессинг","3",Live),
D("coach.order_1","Team instructions","Указания команде","Coach: press","Тренер: прессинг","Q",Live),
D("coach.order_2","Team instructions","Указания команде","Coach: attack","Тренер: в атаку","W",Live),
D("coach.order_3","Team instructions","Указания команде","Coach: conserve energy","Тренер: беречь силы","E",Live),
D("play.run","Playbook","Розыгрыши","Run selected play","Запустить выбранный розыгрыш","R",Live),
D("play.automatic","Playbook","Розыгрыши","Automatic plays","Автоматические розыгрыши","T",Live),
D("play.next","Playbook","Розыгрыши","Next play","Следующий розыгрыш","Y",Live),
D("field.move","Field commands","Команды на поле","Move / choose position","Переместиться / выбрать позицию","Z",Live),
D("field.mark","Field commands","Команды на поле","Mark opponent","Опекать соперника","X",Live),
D("field.press","Field commands","Команды на поле","Press opponent","Прессинговать соперника","G",Live),
D("field.zone","Field commands","Команды на поле","Guard zone","Держать зону","F",Live),
D("field.run","Field commands","Команды на поле","Attacking run","Забегание","D",Live),
D("field.pass","Field commands","Команды на поле","Pass to selected receiver","Пас выбранному игроку","H",Live),
D("field.attack","Field commands","Команды на поле","Create a chance","Обострить атаку","J",Live),
D("field.shoot","Field commands","Команды на поле","Shoot immediately","Немедленный удар","Shift+J",Live),
D("field.clear","Field commands","Команды на поле","Clear player's order","Снять приказ игроку","Backspace",Live),
D("standard.confirm","Manual set pieces","Ручные стандарты","Build power / take set piece","Набрать силу / исполнить стандарт","Enter",GameHotkeyScope.Standard),
D("standard.left","Manual set pieces","Ручные стандарты","Aim left","Прицел влево","Alt+Left",GameHotkeyScope.Standard),
D("standard.right","Manual set pieces","Ручные стандарты","Aim right","Прицел вправо","Alt+Right",GameHotkeyScope.Standard),
D("standard.up","Manual set pieces","Ручные стандарты","Aim higher","Прицел выше","Alt+Up",GameHotkeyScope.Standard),
D("standard.down","Manual set pieces","Ручные стандарты","Aim lower","Прицел ниже","Alt+Down",GameHotkeyScope.Standard),
D("standard.automatic","Manual set pieces","Ручные стандарты","Take set piece automatically","Исполнить стандарт автоматически","Alt+Enter",GameHotkeyScope.Standard),
D("player.inspect","Player inspection","Осмотр игрока","Inspect selected player","Осмотреть выбранного игрока","I",Live|GameHotkeyScope.Club),
D("player.previous","Player inspection","Осмотр игрока","Previous player / selection","Предыдущий игрок / выбор","PageUp",Live|GameHotkeyScope.Player),
D("player.next","Player inspection","Осмотр игрока","Next player / selection","Следующий игрок / выбор","PageDown",Live|GameHotkeyScope.Player),
D("player.role","Player inspection","Осмотр игрока","Change instruction","Изменить указание","P",Live|GameHotkeyScope.Player),
D("player.calm","Player inspection","Осмотр игрока","Calm player","Успокоить игрока","O",Live|GameHotkeyScope.Player),
D("ui.search","Navigation","Навигация","Search this screen","Поиск на экране","Ctrl+F",GameHotkeyScope.Club|GameHotkeyScope.Player),
D("ui.previous_page","Navigation","Навигация","Previous page","Предыдущая страница","Alt+PageUp",GameHotkeyScope.Club),
D("ui.next_page","Navigation","Навигация","Next page","Следующая страница","Alt+PageDown",GameHotkeyScope.Club),
D("club.overview","Club sections","Разделы клуба","Overview","Главная","Ctrl+1",GameHotkeyScope.Club),
D("club.squad","Club sections","Разделы клуба","Squad","Состав","Ctrl+2",GameHotkeyScope.Club),
D("club.training","Club sections","Разделы клуба","Training","Тренировка","Ctrl+3",GameHotkeyScope.Club),
D("club.equipment","Club sections","Разделы клуба","Equipment","Экипировка","Ctrl+4",GameHotkeyScope.Club),
D("club.league","Club sections","Разделы клуба","League","Лига","Ctrl+5",GameHotkeyScope.Club),
D("club.cups","Club sections","Разделы клуба","Cups","Кубки","Ctrl+6",GameHotkeyScope.Club),
D("club.missions","Club sections","Разделы клуба","Missions","Задания","Ctrl+7",GameHotkeyScope.Club),
D("club.store","Club sections","Разделы клуба","Store","Магазин","Ctrl+8",GameHotkeyScope.Club),
D("club.catalog","Club sections","Разделы клуба","Player catalogue","Каталог игроков","Ctrl+9",GameHotkeyScope.Club),
D("club.friendly","Club sections","Разделы клуба","Friendly match","Товарищеский матч","",GameHotkeyScope.Club),
D("club.opponent","Club sections","Разделы клуба","View opponent","Осмотреть соперника","",GameHotkeyScope.Club),
D("club.mode_text","Club sections","Разделы клуба","Text match mode","Текстовый режим матча","",GameHotkeyScope.Club)
};
public static IReadOnlyList<GameHotkeyDefinition> Definitions {get;}=Array.AsReadOnly(definitions);
static readonly Dictionary<string,GameHotkeyDefinition> byId=new(StringComparer.Ordinal);
static readonly Dictionary<string,Chord> defaults=new(StringComparer.Ordinal);
static readonly Dictionary<string,Chord> effective=new(StringComparer.Ordinal);
static Dictionary<string,string> overrides=new(StringComparer.Ordinal);
static bool waitingForRelease,blockUntilRelease;
static string capturingId="",captureMessage="";
public static int Revision {get;private set;}
public static bool Capturing=>capturingId.Length>0;
public static string CapturingId=>capturingId;
public static string CaptureMessage=>captureMessage;
static GameHotkeys()
{
foreach(var definition in definitions)
{
byId.Add(definition.Id,definition);
TryParse(definition.DefaultBinding,out var chord,out _);
defaults.Add(definition.Id,chord);effective.Add(definition.Id,chord);
}
}
static List<Key> CreateKeys()
{
var result=new List<Key>();
for(char c='A';c<='Z';c++)result.Add(new(c.ToString(),c.ToString()));
for(int i=0;i<=9;i++)result.Add(new(i.ToString(),i.ToString()));
for(int i=1;i<=12;i++)result.Add(new("F"+i,"F"+i));
result.AddRange(new[]{new Key("Space","SPACE"),new("Enter","ENTER"),new("Escape","ESCAPE"),
new("Tab","TAB"),new("Backspace","BACKSPACE"),new("Delete","DEL"),new("Insert","INS"),
new("Home","HOME"),new("End","END"),new("PageUp","PGUP"),new("PageDown","PGDN"),
new("Left","LEFTARROW"),new("Right","RIGHTARROW"),new("Up","UPARROW"),new("Down","DOWNARROW")});
return result;
}
static Dictionary<string,Key> CreateKeyNames()
{
var result=new Dictionary<string,Key>(StringComparer.OrdinalIgnoreCase);
foreach(var key in keys){result[key.Name]=key;result[key.EngineName]=key;}
result["Esc"]=result["Escape"];result["Return"]=result["Enter"];result["Spacebar"]=result["Space"];
result["PgDn"]=result["PageDown"];result["PgUp"]=result["PageUp"];
return result;
}
static string Text(string ru,string en)=>GamePreferences.Russian?ru:en;
static bool TryParse(string binding,out Chord chord,out string error)
{
chord=default;error="";
if(string.IsNullOrWhiteSpace(binding)){chord=new("",default,Modifiers.None);return true;}
if(binding.Length>64){error=Text("Слишком длинное сочетание клавиш","The key combination is too long");return false;}
Modifiers modifiers=Modifiers.None;Key key=default;bool found=false;
foreach(string raw in binding.Split('+'))
{
string token=raw.Trim();Modifiers modifier=token.ToLowerInvariant() switch
{"ctrl" or "control"=>Modifiers.Ctrl,"shift"=>Modifiers.Shift,"alt"=>Modifiers.Alt,_=>Modifiers.None};
if(modifier!=Modifiers.None)
{
if((modifiers&modifier)!=0){error=Text("Модификатор указан дважды","A modifier is repeated");return false;}
modifiers|=modifier;continue;
}
if(found || !keyNames.TryGetValue(token,out key))
{error=Text("Выберите одну поддерживаемую клавишу; Ctrl, Shift и Alt используются в сочетаниях",
"Choose one supported key; Ctrl, Shift and Alt are modifiers");return false;}
found=true;
}
if(!found){error=Text("Добавьте клавишу после Ctrl, Shift или Alt","Add a key after Ctrl, Shift or Alt");return false;}
if((modifiers&Modifiers.Alt)!=0 && (key.Name=="F4" || key.Name=="Tab") ||
(modifiers&(Modifiers.Ctrl|Modifiers.Alt))==(Modifiers.Ctrl|Modifiers.Alt) && key.Name=="Delete")
{error=Text("Это сочетание занято операционной системой","This combination is reserved by the operating system");return false;}
string prefix=((modifiers&Modifiers.Ctrl)!=0?"Ctrl+":"")+((modifiers&Modifiers.Shift)!=0?"Shift+":"")+
((modifiers&Modifiers.Alt)!=0?"Alt+":"");
chord=new(prefix+key.Name,key,modifiers);return true;
}
public static string Binding(string id)=>id!=null && effective.TryGetValue(id,out var chord)?chord.Text:"";
public static string Display(string id)=>DisplayBinding(Binding(id));
public static string DisplayBinding(string binding)
{
if(!TryParse(binding,out var chord,out _))return binding??"";
if(chord.Text.Length==0)return Text("Не назначено","Unbound");
string name=chord.Key.Name;
if(GamePreferences.Russian)name=name switch
{"Space"=>"Пробел","Enter"=>"Enter","Escape"=>"Esc","Left"=>"←","Right"=>"→","Up"=>"↑","Down"=>"↓",
"PageUp"=>"Page Up","PageDown"=>"Page Down",_=>name};
else name=name switch {"Escape"=>"Esc","Left"=>"←","Right"=>"→","Up"=>"↑","Down"=>"↓",
"PageUp"=>"Page Up","PageDown"=>"Page Down",_=>name};
return chord.Text.Substring(0,chord.Text.Length-chord.Key.Name.Length)+name;
}
static string Conflict(GameHotkeyDefinition definition,Chord chord)
{
if(chord.Text.Length==0)return "";
foreach(var other in definitions)
if(other.Id!=definition.Id && (other.Scope&definition.Scope)!=0 && Binding(other.Id)==chord.Text)
return Text($"{DisplayBinding(chord.Text)} уже назначено: {other.Label(true)}",
$"{DisplayBinding(chord.Text)} is already assigned to: {other.Label(false)}");
return "";
}
public static string Bind(string id,string binding)
{
if(id==null || !byId.TryGetValue(id,out var definition))return Text("Неизвестное действие","Unknown action");
if(!TryParse(binding,out var chord,out string error))return error;
error=Conflict(definition,chord);if(error.Length>0)return error;
if(Binding(id)==chord.Text)return "";
bool had=overrides.TryGetValue(id,out string previous);var previousChord=effective[id];
if(chord.Text==defaults[id].Text)overrides.Remove(id);else overrides[id]=chord.Text;
effective[id]=chord;
if(!GamePreferences.SaveHotkeys())
{
if(had)overrides[id]=previous;else overrides.Remove(id);effective[id]=previousChord;
return Text("Не удалось сохранить настройки. Привязка не изменена.","Could not save preferences. The binding is unchanged.");
}
blockUntilRelease=true;Revision++;return "";
}
public static void ClearBinding(string id)
{string error=Bind(id,"");captureMessage=error.Length>0?error:Text("Привязка удалена","Binding cleared");Revision++;}
public static void ResetDefaults()
{
var previous=overrides;var previousEffective=new Dictionary<string,Chord>(effective,StringComparer.Ordinal);
overrides=new(StringComparer.Ordinal);foreach(var d in definitions)effective[d.Id]=defaults[d.Id];
if(!GamePreferences.SaveHotkeys())
{
overrides=previous;foreach(var pair in previousEffective)effective[pair.Key]=pair.Value;
captureMessage=Text("Не удалось сохранить настройки. Привязки не изменены.","Could not save preferences. Bindings are unchanged.");
}
else{capturingId="";waitingForRelease=false;blockUntilRelease=true;captureMessage=Text("Восстановлены стандартные клавиши","Default bindings restored");}
Revision++;
}
internal static Dictionary<string,string> ExportBindings()=>new(overrides,StringComparer.Ordinal);
internal static void LoadBindings(Dictionary<string,string> saved)
{
overrides=new(StringComparer.Ordinal);
foreach(var d in definitions)effective[d.Id]=defaults[d.Id];
if(saved!=null && saved.Count<=256)
foreach(var pair in saved)
{
if(string.IsNullOrEmpty(pair.Key) || pair.Key.Length>128 || pair.Value==null || pair.Value.Length>64)continue;
if(!byId.ContainsKey(pair.Key)){overrides[pair.Key]=pair.Value;continue;} // Retain future-version preferences.
if(!TryParse(pair.Value,out var chord,out _) || chord.Text==defaults[pair.Key].Text)continue;
overrides[pair.Key]=chord.Text;effective[pair.Key]=chord;
}
// Validate the complete merged map, so valid swaps survive a load.
// Reject conflicting overrides together instead of order-dependent theft.
bool changed;
do
{
changed=false;var reject=new HashSet<string>(StringComparer.Ordinal);
for(int a=0;a<definitions.Length;a++)for(int b=a+1;b<definitions.Length;b++)
{
var x=definitions[a];var y=definitions[b];string binding=Binding(x.Id);
if(binding.Length==0 || binding!=Binding(y.Id) || (x.Scope&y.Scope)==0)continue;
if(overrides.ContainsKey(x.Id))reject.Add(x.Id);
if(overrides.ContainsKey(y.Id))reject.Add(y.Id);
}
foreach(string id in reject){overrides.Remove(id);effective[id]=defaults[id];changed=true;}
}while(changed);
capturingId="";captureMessage="";waitingForRelease=blockUntilRelease=false;Revision++;
}
static bool Down(string key)=>Input.Keyboard.Down(key);
static Modifiers CurrentModifiers()
{
Modifiers result=Modifiers.None;
if(Down("CTRL") || Down("RCTRL"))result|=Modifiers.Ctrl;
if(Down("SHIFT") || Down("RSHIFT"))result|=Modifiers.Shift;
if(Down("ALT") || Down("RALT"))result|=Modifiers.Alt;
return result;
}
static bool MetaDown()=>Down("LWIN") || Down("RWIN");
static bool AnyDown()
{
if(CurrentModifiers()!=Modifiers.None || MetaDown())return true;
foreach(var key in keys)if(Down(key.EngineName))return true;
return false;
}
public static bool Pressed(string id)
{
if(Capturing || blockUntilRelease || id==null || !effective.TryGetValue(id,out var chord) || chord.Text.Length==0)return false;
try{return Input.Keyboard.Pressed(chord.Key.EngineName) && !MetaDown() && CurrentModifiers()==chord.Modifiers;}
catch{return false;}
}
public static void BeginCapture(string id)
{
if(id==null || !byId.ContainsKey(id)){captureMessage=Text("Неизвестное действие","Unknown action");Revision++;return;}
capturingId=id;waitingForRelease=true;blockUntilRelease=true;
captureMessage=Text("Отпустите клавиши, затем нажмите новое сочетание. Esc — отмена, Delete — очистить.",
"Release held keys, then press a combination. Esc cancels; Delete clears.");Revision++;
}
public static void CancelCapture()
{
if(!Capturing)return;
capturingId="";waitingForRelease=false;blockUntilRelease=true;captureMessage=Text("Назначение отменено","Assignment cancelled");Revision++;
}
public static void ClearStatus(){captureMessage="";Revision++;}
public static bool TickCapture()
{
try
{
if(!Capturing)
{
if(!blockUntilRelease)return false;
if(!AnyDown())blockUntilRelease=false;
return true; // Also consume the release frame.
}
if(Input.Keyboard.Pressed("ESCAPE")){CancelCapture();return true;}
if(waitingForRelease)
{
if(!AnyDown())
{waitingForRelease=false;captureMessage=Text("Нажмите клавишу или сочетание. Esc — отмена, Delete — очистить.",
"Press a key or combination. Esc cancels; Delete clears.");Revision++;}
return true;
}
if(MetaDown())
{captureMessage=Text("Клавиша Windows не поддерживается","The Windows key is not supported");return true;}
if(Input.Keyboard.Pressed("DEL"))
{
string error=Bind(capturingId,"");
if(error.Length>0){captureMessage=error;Revision++;return true;}
capturingId="";blockUntilRelease=true;captureMessage=Text("Привязка удалена","Binding cleared");Revision++;return true;
}
foreach(var key in keys)
{
if(key.Name is "Escape" or "Delete" || !Input.Keyboard.Pressed(key.EngineName))continue;
Modifiers modifiers=CurrentModifiers();
string prefix=((modifiers&Modifiers.Ctrl)!=0?"Ctrl+":"")+((modifiers&Modifiers.Shift)!=0?"Shift+":"")+
((modifiers&Modifiers.Alt)!=0?"Alt+":"");
string error=Bind(capturingId,prefix+key.Name);
if(error.Length>0){captureMessage=error;Revision++;return true;}
captureMessage=Text("Назначено: ","Assigned: ")+Display(capturingId);
capturingId="";blockUntilRelease=true;Revision++;return true;
}
return true;
}
catch
{captureMessage=Text("Клавиатура пока недоступна","Keyboard input is not available yet");return Capturing || blockUntilRelease;}
}
}