diff --git a/ClientCore/ClientConfiguration.cs b/ClientCore/ClientConfiguration.cs
index 574c14e0c..5b8997eba 100644
--- a/ClientCore/ClientConfiguration.cs
+++ b/ClientCore/ClientConfiguration.cs
@@ -252,6 +252,8 @@ public string GetThemePath(string themeName)
public string MPMapsIniPath => SafePath.CombineFilePath(clientDefinitionsIni.GetStringValue(SETTINGS, "MPMapsPath", SafePath.CombineFilePath("INI", "MPMaps.ini")));
+ public string QuickMatchPath => clientDefinitionsIni.GetStringValue(SETTINGS, "QuickMatchPath", "INI/QuickMatch.ini");
+
public string KeyboardINI => clientDefinitionsIni.GetStringValue(SETTINGS, "KeyboardINI", "Keyboard.ini");
public int MinimumIngameWidth => clientDefinitionsIni.GetIntValue(SETTINGS, "MinimumIngameWidth", 640);
diff --git a/ClientCore/Exceptions/ClientException.cs b/ClientCore/Exceptions/ClientException.cs
new file mode 100644
index 000000000..dbcaf3b5a
--- /dev/null
+++ b/ClientCore/Exceptions/ClientException.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace ClientCore.Exceptions
+{
+ public class ClientException : Exception
+ {
+ public ClientException(string message, Exception innerException = null) : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/ClientCore/Exceptions/ClientRequestException.cs b/ClientCore/Exceptions/ClientRequestException.cs
new file mode 100644
index 000000000..552e34d66
--- /dev/null
+++ b/ClientCore/Exceptions/ClientRequestException.cs
@@ -0,0 +1,14 @@
+using System.Net;
+
+namespace ClientCore.Exceptions
+{
+ public class ClientRequestException : ClientException
+ {
+ public HttpStatusCode? StatusCode { get; }
+
+ public ClientRequestException(string message, HttpStatusCode? statusCode = null) : base(message)
+ {
+ StatusCode = statusCode;
+ }
+ }
+}
diff --git a/ClientGUI/Parser.cs b/ClientGUI/Parser.cs
index bd23ca5b3..a91a46f0b 100644
--- a/ClientGUI/Parser.cs
+++ b/ClientGUI/Parser.cs
@@ -24,6 +24,7 @@
using Rampastring.XNAUI.XNAControls;
using System;
using System.Collections.Generic;
+using System.Text.RegularExpressions;
namespace ClientGUI
{
@@ -69,6 +70,9 @@ private XNAControl GetControl(string controlName)
if (controlName == primaryControl.Name)
return primaryControl;
+ if (controlName == primaryControl.Parent.Name)
+ return primaryControl.Parent;
+
var control = Find(primaryControl.Children, controlName);
if (control == null)
throw new KeyNotFoundException($"Control '{controlName}' not found while parsing input '{Input}'");
@@ -104,7 +108,7 @@ public void SetPrimaryControl(XNAControl primaryControl)
public int GetExprValue(string input, XNAControl parsingControl)
{
this.parsingControl = parsingControl;
- Input = input;
+ Input = Regex.Replace(input, @"\s", "");
tokenPlace = 0;
return GetExprValue();
}
@@ -115,8 +119,6 @@ private int GetExprValue()
while (true)
{
- SkipWhitespace();
-
if (IsEndOfInput())
return value;
diff --git a/ClientGUI/XNAMessageBox.cs b/ClientGUI/XNAMessageBox.cs
index 06351c588..c4afa1b0d 100644
--- a/ClientGUI/XNAMessageBox.cs
+++ b/ClientGUI/XNAMessageBox.cs
@@ -262,7 +262,10 @@ private static void MsgBox_OKClicked(XNAMessageBox messageBox)
/// The caption of the message box.
/// The description in the message box.
/// The XNAMessageBox instance that is created.
- public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string caption, string description)
+ public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string caption, string description)
+ => ShowYesNoDialog(windowManager, caption, description, null);
+
+ public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string caption, string description, Action yesAction)
{
var panel = new DarkeningPanel(windowManager);
windowManager.AddAndInitializeControl(panel);
@@ -274,6 +277,8 @@ public static XNAMessageBox ShowYesNoDialog(WindowManager windowManager, string
panel.AddChild(msgBox);
msgBox.YesClickedAction = MsgBox_YesClicked;
+ if (yesAction != null)
+ msgBox.YesClickedAction += yesAction;
msgBox.NoClickedAction = MsgBox_NoClicked;
return msgBox;
diff --git a/DXMainClient/DXGUI/Generic/LoadingScreen.cs b/DXMainClient/DXGUI/Generic/LoadingScreen.cs
index 357eb8c8c..124986220 100644
--- a/DXMainClient/DXGUI/Generic/LoadingScreen.cs
+++ b/DXMainClient/DXGUI/Generic/LoadingScreen.cs
@@ -7,6 +7,7 @@
using DTAClient.DXGUI.Multiplayer;
using DTAClient.DXGUI.Multiplayer.CnCNet;
using DTAClient.DXGUI.Multiplayer.GameLobby;
+using DTAClient.DXGUI.Multiplayer.QuickMatch;
using DTAClient.Online;
using DTAConfig;
using Microsoft.Xna.Framework;
@@ -14,6 +15,7 @@
using System.Threading.Tasks;
using Rampastring.Tools;
using ClientUpdater;
+using Rampastring.XNAUI.XNAControls;
using SkirmishLobby = DTAClient.DXGUI.Multiplayer.GameLobby.SkirmishLobby;
namespace DTAClient.DXGUI.Generic
@@ -79,26 +81,20 @@ private void LogGameClientVersion()
private void LoadMaps()
{
- mapLoader = new MapLoader();
+ mapLoader = MapLoader.GetInstance();
mapLoader.LoadMaps();
}
private void Finish()
{
- ProgramConstants.GAME_VERSION = ClientConfiguration.Instance.ModMode ?
+ ProgramConstants.GAME_VERSION = ClientConfiguration.Instance.ModMode ?
"N/A" : Updater.GameVersion;
DiscordHandler discordHandler = null;
if (!string.IsNullOrEmpty(ClientConfiguration.Instance.DiscordAppId))
discordHandler = new DiscordHandler(WindowManager);
- ClientGUICreator.Instance.AddControl(typeof(GameLobbyCheckBox));
- ClientGUICreator.Instance.AddControl(typeof(GameLobbyDropDown));
- ClientGUICreator.Instance.AddControl(typeof(MapPreviewBox));
- ClientGUICreator.Instance.AddControl(typeof(GameLaunchButton));
- ClientGUICreator.Instance.AddControl(typeof(ChatListBox));
- ClientGUICreator.Instance.AddControl(typeof(XNAChatTextBox));
- ClientGUICreator.Instance.AddControl(typeof(PlayerExtraOptionsPanel));
+ DeclareCustomControls();
var gameCollection = new GameCollection();
gameCollection.Initialize();
@@ -109,7 +105,7 @@ private void Finish()
var cncnetManager = new CnCNetManager(WindowManager, gameCollection, cncnetUserData);
var tunnelHandler = new TunnelHandler(WindowManager, cncnetManager);
var privateMessageHandler = new PrivateMessageHandler(cncnetManager, cncnetUserData);
-
+
var topBar = new TopBar(WindowManager, cncnetManager, privateMessageHandler);
var optionsWindow = new OptionsWindow(WindowManager, gameCollection, topBar);
@@ -120,23 +116,26 @@ private void Finish()
var cncnetGameLobby = new CnCNetGameLobby(WindowManager,
"MultiplayerGameLobby", topBar, cncnetManager, tunnelHandler, gameCollection, cncnetUserData, mapLoader, discordHandler, pmWindow);
- var cncnetGameLoadingLobby = new CnCNetGameLoadingLobby(WindowManager,
+ var cncnetGameLoadingLobby = new CnCNetGameLoadingLobby(WindowManager,
topBar, cncnetManager, tunnelHandler, mapLoader.GameModes, gameCollection, discordHandler);
- var cncnetLobby = new CnCNetLobby(WindowManager, cncnetManager,
+ var cncnetLobby = new CnCNetLobby(WindowManager, cncnetManager,
cncnetGameLobby, cncnetGameLoadingLobby, topBar, pmWindow, tunnelHandler,
gameCollection, cncnetUserData, optionsWindow);
var gipw = new GameInProgressWindow(WindowManager);
var skirmishLobby = new SkirmishLobby(WindowManager, topBar, mapLoader, discordHandler);
+ var quickMatchWindow = new QuickMatchWindow(WindowManager);
topBar.SetSecondarySwitch(cncnetLobby);
- var mainMenu = new MainMenu(WindowManager, skirmishLobby, lanLobby,
+ var mainMenu = new MainMenu(WindowManager, skirmishLobby, quickMatchWindow, lanLobby,
topBar, optionsWindow, cncnetLobby, cncnetManager, discordHandler);
WindowManager.AddAndInitializeControl(mainMenu);
DarkeningPanel.AddAndInitializeWithControl(WindowManager, skirmishLobby);
+ DarkeningPanel.AddAndInitializeWithControl(WindowManager, quickMatchWindow);
+
DarkeningPanel.AddAndInitializeWithControl(WindowManager, cncnetGameLoadingLobby);
DarkeningPanel.AddAndInitializeWithControl(WindowManager, cncnetGameLobby);
@@ -152,9 +151,11 @@ private void Finish()
topBar.SetTertiarySwitch(pmWindow);
topBar.SetOptionsWindow(optionsWindow);
+ topBar.SetQuickMatchWindow(quickMatchWindow);
WindowManager.AddAndInitializeControl(gipw);
skirmishLobby.Disable();
+ quickMatchWindow.Disable();
cncnetLobby.Disable();
cncnetGameLobby.Disable();
cncnetGameLoadingLobby.Disable();
@@ -183,6 +184,24 @@ private void Finish()
Cursor.Visible = visibleSpriteCursor;
}
+ private static void DeclareCustomControls()
+ {
+ ClientGUICreator.Instance.AddControl(typeof(GameLobbyCheckBox));
+ ClientGUICreator.Instance.AddControl(typeof(GameLobbyDropDown));
+ ClientGUICreator.Instance.AddControl(typeof(MapPreviewBox));
+ ClientGUICreator.Instance.AddControl(typeof(GameLaunchButton));
+ ClientGUICreator.Instance.AddControl(typeof(ChatListBox));
+ ClientGUICreator.Instance.AddControl(typeof(XNAChatTextBox));
+ ClientGUICreator.Instance.AddControl(typeof(XNAScrollBar));
+ ClientGUICreator.Instance.AddControl(typeof(XNAPasswordBox));
+ ClientGUICreator.Instance.AddControl(typeof(PlayerExtraOptionsPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchLoginPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchLobbyPanel));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchMapList));
+ ClientGUICreator.Instance.AddControl(typeof(QuickMatchStatusMessageWindow));
+ ClientGUICreator.Instance.AddControl(typeof(XNAClientTabControl));
+ }
+
public override void Update(GameTime gameTime)
{
base.Update(gameTime);
diff --git a/DXMainClient/DXGUI/Generic/MainMenu.cs b/DXMainClient/DXGUI/Generic/MainMenu.cs
index b890e5bb8..3fb8470bc 100644
--- a/DXMainClient/DXGUI/Generic/MainMenu.cs
+++ b/DXMainClient/DXGUI/Generic/MainMenu.cs
@@ -5,6 +5,7 @@
using DTAClient.DXGUI.Multiplayer;
using DTAClient.DXGUI.Multiplayer.CnCNet;
using DTAClient.DXGUI.Multiplayer.GameLobby;
+using DTAClient.DXGUI.Multiplayer.QuickMatch;
using DTAClient.Online;
using DTAConfig;
using Localization;
@@ -36,12 +37,20 @@ class MainMenu : XNAWindow, ISwitchable
///
/// Creates a new instance of the main menu.
///
- public MainMenu(WindowManager windowManager, SkirmishLobby skirmishLobby,
- LANLobby lanLobby, TopBar topBar, OptionsWindow optionsWindow,
+ public MainMenu(
+ WindowManager windowManager,
+ SkirmishLobby skirmishLobby,
+ QuickMatchWindow quickMatchWindow,
+ LANLobby lanLobby,
+ TopBar topBar,
+ OptionsWindow optionsWindow,
CnCNetLobby cncnetLobby,
- CnCNetManager connectionManager, DiscordHandler discordHandler) : base(windowManager)
+ CnCNetManager connectionManager,
+ DiscordHandler discordHandler
+ ) : base(windowManager)
{
this.skirmishLobby = skirmishLobby;
+ this.quickMatchWindow = quickMatchWindow;
this.lanLobby = lanLobby;
this.topBar = topBar;
this.connectionManager = connectionManager;
@@ -62,6 +71,8 @@ public MainMenu(WindowManager windowManager, SkirmishLobby skirmishLobby,
private SkirmishLobby skirmishLobby;
+ private QuickMatchWindow quickMatchWindow;
+
private LANLobby lanLobby;
private CnCNetManager connectionManager;
@@ -106,6 +117,7 @@ private bool UpdateInProgress
private XNAClientButton btnLoadGame;
private XNAClientButton btnSkirmish;
private XNAClientButton btnCnCNet;
+ private XNAClientButton btnQuickmatch;
private XNAClientButton btnLan;
private XNAClientButton btnOptions;
private XNAClientButton btnMapEditor;
@@ -154,6 +166,11 @@ public override void Initialize()
btnCnCNet.HoverSoundEffect = new EnhancedSoundEffect("MainMenu/button.wav");
btnCnCNet.LeftClick += BtnCnCNet_LeftClick;
+ btnQuickmatch = new XNAClientButton(WindowManager);
+ btnQuickmatch.Name = nameof(btnQuickmatch);
+ btnQuickmatch.LeftClick += BtnQuickmatch_LeftClick;
+ btnQuickmatch.Disable();
+
btnLan = new XNAClientButton(WindowManager);
btnLan.Name = nameof(btnLan);
btnLan.IdleTexture = AssetLoader.LoadTexture("MainMenu/lan.png");
@@ -225,6 +242,7 @@ public override void Initialize()
AddChild(btnLoadGame);
AddChild(btnSkirmish);
AddChild(btnCnCNet);
+ AddChild(btnQuickmatch);
AddChild(btnLan);
AddChild(btnOptions);
AddChild(btnMapEditor);
@@ -799,6 +817,8 @@ private void BtnLan_LeftClick(object sender, EventArgs e)
private void BtnCnCNet_LeftClick(object sender, EventArgs e) => topBar.SwitchToSecondary();
+ private void BtnQuickmatch_LeftClick(object sender, EventArgs e) => quickMatchWindow.Enable();
+
private void BtnSkirmish_LeftClick(object sender, EventArgs e)
{
skirmishLobby.Open();
diff --git a/DXMainClient/DXGUI/Generic/TopBar.cs b/DXMainClient/DXGUI/Generic/TopBar.cs
index ab8fadcc6..370fb6756 100644
--- a/DXMainClient/DXGUI/Generic/TopBar.cs
+++ b/DXMainClient/DXGUI/Generic/TopBar.cs
@@ -10,6 +10,7 @@
using ClientCore;
using System.Threading;
using DTAClient.Domain.Multiplayer.CnCNet;
+using DTAClient.DXGUI.Multiplayer.QuickMatch;
using DTAClient.Online.EventArguments;
using DTAConfig;
using Localization;
@@ -53,6 +54,7 @@ PrivateMessageHandler privateMessageHandler
private ISwitchable privateMessageSwitch;
private OptionsWindow optionsWindow;
+ private QuickMatchWindow quickMatchWindow;
private XNAClientButton btnMainButton;
private XNAClientButton btnCnCNetLobby;
@@ -107,6 +109,8 @@ public void SetOptionsWindow(OptionsWindow optionsWindow)
optionsWindow.EnabledChanged += OptionsWindow_EnabledChanged;
}
+ public void SetQuickMatchWindow(QuickMatchWindow quickMatchWindow) => this.quickMatchWindow = quickMatchWindow;
+
private void OptionsWindow_EnabledChanged(object sender, EventArgs e)
{
if (!lanMode)
@@ -324,6 +328,7 @@ private void BtnMainButton_LeftClick(object sender, EventArgs e)
LastSwitchType = SwitchType.PRIMARY;
cncnetLobbySwitch.SwitchOff();
privateMessageSwitch.SwitchOff();
+ quickMatchWindow.Disable();
primarySwitches[primarySwitches.Count - 1].SwitchOn();
// HACK warning
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyPanel.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyPanel.cs
new file mode 100644
index 000000000..dd8f30fa2
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLobbyPanel.cs
@@ -0,0 +1,437 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using Localization;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchLobbyPanel : QuickMatchPanel
+ {
+ private const int TAB_WIDTH = 133;
+
+ private readonly QmService qmService;
+ private readonly MapLoader mapLoader;
+
+ public event EventHandler LogoutEvent;
+
+ private QuickMatchMapList mapList;
+ private XNAClientButton btnQuickMatch;
+ private XNAClientButton btnLogout;
+ private XNAClientButton btnExit;
+ private XNAClientDropDown ddUserAccounts;
+ private XNAClientDropDown ddNicknames;
+ private XNAClientDropDown ddSides;
+ private XNAPanel mapPreviewBox;
+ private XNAPanel settingsPanel;
+ private XNAClientTabControl tabPanel;
+
+ private bool requestingMatchStatus;
+ private readonly EnhancedSoundEffect matchFoundSoundEffect;
+ private readonly QmSettings qmSettings;
+
+ public QuickMatchLobbyPanel(WindowManager windowManager) : base(windowManager)
+ {
+ qmService = QmService.GetInstance();
+ qmService.UserAccountsEvent += UserAccountsEvent;
+ qmService.LadderMapsEvent += LadderMapsEvent;
+ qmService.MatchedEvent += MatchedEvent;
+ qmService.QmRequestEvent += QmRequestResponseEvent;
+
+ mapLoader = MapLoader.GetInstance();
+
+ qmSettings = QmSettingsService.GetInstance().GetSettings();
+ matchFoundSoundEffect = new EnhancedSoundEffect(qmSettings.MatchFoundSoundFile);
+ }
+
+ public override void Initialize()
+ {
+ Name = nameof(QuickMatchLobbyPanel);
+
+ base.Initialize();
+
+ mapList = FindChild(nameof(QuickMatchMapList));
+ mapList.MapSelected += MapSelected;
+
+ btnLogout = FindChild(nameof(btnLogout));
+ btnLogout.LeftClick += BtnLogout_LeftClick;
+
+ btnExit = FindChild(nameof(btnExit));
+ btnExit.LeftClick += Exit_Click;
+
+ btnQuickMatch = FindChild(nameof(btnQuickMatch));
+ btnQuickMatch.LeftClick += BtnQuickMatch_LeftClick;
+
+ ddUserAccounts = FindChild(nameof(ddUserAccounts));
+ ddUserAccounts.SelectedIndexChanged += UserAccountSelected;
+
+ ddNicknames = FindChild(nameof(ddNicknames));
+
+ ddSides = FindChild(nameof(ddSides));
+
+ mapPreviewBox = FindChild(nameof(mapPreviewBox));
+ mapPreviewBox.PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.CENTERED;
+
+ settingsPanel = FindChild(nameof(settingsPanel));
+ settingsPanel.Disable();
+
+ tabPanel = FindChild(nameof(tabPanel));
+ tabPanel.AddTab("Map".L10N("QM:Tabs:Map"), TAB_WIDTH);
+ tabPanel.AddTab("Settings".L10N("QM:Tabs:Settings"), TAB_WIDTH);
+ tabPanel.SelectedIndexChanged += TabSelected;
+
+ EnabledChanged += EnabledChangedEvent;
+ }
+
+ private void EnabledChangedEvent(object sender, EventArgs e)
+ {
+ if (!Enabled)
+ return;
+
+ FetchLaddersAndUserAccountsAsync();
+ }
+
+ private void FetchLaddersAndUserAccountsAsync()
+ {
+ qmService.ShowStatus("Fetching ladders and accounts...");
+ Task.Run(async () =>
+ {
+ try
+ {
+ var qmData = await qmService.FetchLaddersAndUserAccountsAsync();
+ qmService.ClearStatus();
+ UserAccountsUpdated(qmData.UserAccounts);
+ }
+ catch (Exception e)
+ {
+ string error = (e as ClientException)?.Message ?? "Error fetching ladders and accounts...";
+ ShowError(error);
+ qmService.ClearStatus();
+ }
+ });
+ }
+
+ private void TabSelected(object sender, EventArgs eventArgs)
+ {
+ switch (tabPanel.SelectedTab)
+ {
+ case 0:
+ mapPreviewBox.Enable();
+ settingsPanel.Disable();
+ return;
+ case 1:
+ mapPreviewBox.Disable();
+ settingsPanel.Enable();
+ return;
+ }
+ }
+
+ private void BtnQuickMatch_LeftClick(object sender, EventArgs eventArgs)
+ => RequestQuickMatch();
+
+ private void RequestQuickMatch()
+ {
+ var matchRequest = GetMatchRequest();
+ if (matchRequest == null)
+ return;
+
+ StartRequestingMatchStatus();
+ Task.Run(async () =>
+ {
+ try
+ {
+ HandleQuickMatchResponse(await qmService.QuickMatchAsync(matchRequest));
+ }
+ catch (Exception e)
+ {
+ string message = (e as ClientException)?.Message ?? "Error requesting match";
+ requestingMatchStatus = false;
+ ShowError(message);
+ qmService.ClearStatus();
+ }
+ });
+ }
+
+ private void StartRequestingMatchStatus()
+ {
+ if (requestingMatchStatus)
+ return;
+
+ const string baseMessage = "Requesting match";
+ string message = baseMessage;
+ int maxLength = baseMessage.Length + 4;
+ requestingMatchStatus = true;
+ Task.Run(() =>
+ {
+ while (requestingMatchStatus)
+ {
+ message = message.Length > maxLength ? message.Substring(0, baseMessage.Length) : message + ".";
+ qmService.ShowStatus(message);
+ Thread.Sleep(500);
+ }
+ });
+ }
+
+ private void HandleQuickMatchResponse(QmRequestResponse qmRequestResponse)
+ {
+ switch (true)
+ {
+ case true when qmRequestResponse.IsError:
+ case true when qmRequestResponse.IsFatal:
+ HandleQuickMatchErrorResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsUpdate:
+ HandleQuickMatchUpdateResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsSpawn:
+ HandleQuickMatchSpawnResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsQuit:
+ HandleQuickMatchQuitResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsQuit:
+ HandleQuickMatchQuitResponse(qmRequestResponse);
+ return;
+ case true when qmRequestResponse.IsWait:
+ HandleQuickMatchWaitResponse(qmRequestResponse);
+ return;
+ }
+ }
+
+ private void HandleQuickMatchSpawnResponse(QmRequestResponse qmRequestResponse)
+ {
+ requestingMatchStatus = false;
+ ShowError("qm spawn");
+ }
+
+ private void HandleQuickMatchUpdateResponse(QmRequestResponse qmRequestResponse)
+ {
+ requestingMatchStatus = false;
+ ShowError("qm update");
+ }
+
+ private void HandleQuickMatchQuitResponse(QmRequestResponse qmRequestResponse)
+ {
+ requestingMatchStatus = false;
+ ShowError("qm quit");
+ }
+
+ private void HandleQuickMatchWaitResponse(QmRequestResponse qmRequestResponse)
+ {
+ SoundPlayer.Play(matchFoundSoundEffect);
+ Thread.Sleep(qmRequestResponse.CheckBack * 1000);
+ RequestQuickMatch();
+ }
+
+ private void HandleQuickMatchErrorResponse(QmRequestResponse qmRequestResponse)
+ {
+ qmService.ClearStatus();
+ ShowError(qmRequestResponse.Message ?? qmRequestResponse.Description);
+ requestingMatchStatus = false;
+ }
+
+ private void QmRequestResponseEvent(object sender, QmRequestEventArgs args)
+ {
+ QmRequestResponse response = args.Response;
+ if (!response.IsSuccessful)
+ {
+ XNAMessageBox.Show(WindowManager, "Error", response.Message ?? response.Description ?? "Unknown error occurred.");
+ return;
+ }
+
+ XNAMessageBox.Show(WindowManager, "test", response.Type);
+ }
+
+ private QmRequest GetMatchRequest()
+ {
+ var userAccount = GetSelectedUserAccount();
+ if (userAccount == null)
+ {
+ XNAMessageBox.Show(WindowManager, "Error", "No ladder selected");
+ return null;
+ }
+
+ var side = GetSelectedSide();
+ if (side == null)
+ {
+ XNAMessageBox.Show(WindowManager, "Error", "No side selected");
+ return null;
+ }
+
+ return new QmRequest()
+ {
+ Ladder = userAccount.Ladder.Abbreviation,
+ PlayerName = userAccount.Username,
+ Side = side.Id
+ };
+ }
+
+ private QmSide GetSelectedSide()
+ => ddSides.SelectedItem?.Tag as QmSide;
+
+ private QmUserAccount GetSelectedUserAccount()
+ => ddUserAccounts.SelectedItem?.Tag as QmUserAccount;
+
+ private void BtnLogout_LeftClick(object sender, EventArgs eventArgs)
+ {
+ XNAMessageBox.ShowYesNoDialog(WindowManager, "Confirmation", "Are you sure you want to log out?", box =>
+ {
+ qmService.Logout();
+ LogoutEvent?.Invoke(this, null);
+ });
+ }
+
+ private void UserAccountsUpdated(IEnumerable userAccounts)
+ {
+ ddUserAccounts.Items.Clear();
+ foreach (QmUserAccount userAccount in userAccounts)
+ {
+ ddUserAccounts.AddItem(new XNADropDownItem()
+ {
+ Text = userAccount.Ladder.Name,
+ Tag = userAccount
+ });
+ }
+
+ if (ddUserAccounts.Items.Count == 0)
+ return;
+
+ string cachedLadder = qmService.GetCachedLadder();
+ if (!string.IsNullOrEmpty(cachedLadder))
+ ddUserAccounts.SelectedIndex = ddUserAccounts.Items.FindIndex(i => (i.Tag as QmUserAccount)?.Ladder.Abbreviation == cachedLadder);
+
+ if (ddUserAccounts.SelectedIndex < 0)
+ ddUserAccounts.SelectedIndex = 0;
+ }
+
+ ///
+ /// Called when the QM service has finished the login process
+ ///
+ ///
+ ///
+ private void UserAccountsEvent(object sender, QmUserAccountsEventArgs qmUserAccountsEventArgs)
+ => UserAccountsUpdated(qmUserAccountsEventArgs.UserAccounts);
+
+ ///
+ /// Called when the user has selected a UserAccount from the drop down
+ ///
+ ///
+ ///
+ private void UserAccountSelected(object sender, EventArgs eventArgs)
+ {
+ if (!(ddUserAccounts.SelectedItem?.Tag is QmUserAccount selectedUserAccount))
+ return;
+
+ UpdateNickames(selectedUserAccount);
+ UpdateSides(selectedUserAccount);
+ mapList.Clear();
+ qmService.SetLadder(selectedUserAccount.Ladder.Abbreviation);
+
+ FetchLadderMapsForAbbrAsync(selectedUserAccount.Ladder.Abbreviation);
+ }
+
+ private void FetchLadderMapsForAbbrAsync(string ladderAbbr)
+ {
+ qmService.ShowStatus("Fetching ladder maps...");
+ Task.Run(async () =>
+ {
+ try
+ {
+ await qmService.FetchLadderMapsForAbbrAsync(ladderAbbr);
+ qmService.ClearStatus();
+ }
+ catch (Exception e)
+ {
+ string message = (e as ClientException)?.Message ?? "Error fetching ladder maps";
+ ShowError(message);
+ }
+ });
+ }
+
+ ///
+ /// Update the nicknames drop down
+ ///
+ ///
+ private void UpdateNickames(QmUserAccount selectedUserAccount)
+ {
+ ddNicknames.Items.Clear();
+
+ ddNicknames.AddItem(new XNADropDownItem()
+ {
+ Text = selectedUserAccount.Username,
+ Tag = selectedUserAccount
+ });
+
+ ddNicknames.SelectedIndex = 0;
+ }
+
+ ///
+ /// Update the top Sides dropdown
+ ///
+ ///
+ private void UpdateSides(QmUserAccount selectedUserAccount)
+ {
+ ddSides.Items.Clear();
+
+ var ladder = qmService.GetLadderForId(selectedUserAccount.LadderId);
+
+ foreach (QmSide side in ladder.Sides)
+ {
+ ddSides.AddItem(new XNADropDownItem
+ {
+ Text = side.Name,
+ Tag = side
+ });
+ }
+
+ if (ddSides.Items.Count > 0)
+ ddSides.SelectedIndex = 0;
+ }
+
+ ///
+ /// Called when the QM service has fetched new ladder maps for the ladder selected
+ ///
+ ///
+ ///
+ private void LadderMapsEvent(object sender, QmLadderMapsEventArgs qmLadderMapsEventArgs)
+ {
+ mapList.Clear();
+ var ladderMaps = qmLadderMapsEventArgs?.Maps?.ToList() ?? new List();
+ if (!ladderMaps.Any())
+ return;
+
+ if (ddUserAccounts.SelectedItem?.Tag is not QmUserAccount selectedUserAccount)
+ return;
+
+ var ladder = qmService.GetLadderForId(selectedUserAccount.LadderId);
+
+ mapList.AddItems(ladderMaps.Select(ladderMap => new QuickMatchMapListItem(WindowManager, ladderMap, ladder)));
+ }
+
+ private void MatchedEvent(object sender, EventArgs eventArgs)
+ {
+ }
+
+ ///
+ /// Called when the user selects a map in the list
+ ///
+ ///
+ ///
+ private void MapSelected(object sender, QmMapSelectedEventArgs qmMapSelectedEventArgs)
+ {
+ if (qmMapSelectedEventArgs?.Map == null)
+ return;
+
+ var map = mapLoader.GetMapForSHA(qmMapSelectedEventArgs.Map.Hash);
+
+ mapPreviewBox.BackgroundTexture = map?.LoadPreviewTexture();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLoginPanel.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLoginPanel.cs
new file mode 100644
index 000000000..39c85a0af
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchLoginPanel.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchLoginPanel : QuickMatchPanel
+ {
+ private const string LoginErrorTitle = "Login Error";
+ private readonly QmService qmService;
+
+ private XNATextBox tbEmail;
+ private XNAPasswordBox tbPassword;
+ private bool loginInitialized;
+
+ public event EventHandler LoginEvent;
+
+ public QuickMatchLoginPanel(WindowManager windowManager) : base(windowManager)
+ {
+ qmService = QmService.GetInstance();
+ }
+
+ public override void Initialize()
+ {
+ Name = nameof(QuickMatchLoginPanel);
+
+ base.Initialize();
+
+ XNAClientButton btnLogin;
+ btnLogin = FindChild(nameof(btnLogin));
+ btnLogin.LeftClick += BtnLogin_LeftClick;
+
+ XNAClientButton btnCancel;
+ btnCancel = FindChild(nameof(btnCancel));
+ btnCancel.LeftClick += Exit_Click;
+
+ tbEmail = FindChild(nameof(tbEmail));
+ tbEmail.Text = qmService.GetCachedEmail() ?? string.Empty;
+
+ tbPassword = FindChild(nameof(tbPassword));
+
+ EnabledChanged += InitLogin;
+ }
+
+ public void InitLogin(object sender, EventArgs eventArgs)
+ {
+ if (!Enabled || loginInitialized)
+ return;
+
+ if (qmService.IsLoggedIn())
+ LoginAsync(qmService.RefreshAsync);
+
+ loginInitialized = true;
+ }
+
+ private void LoginAsync(Func qmServiceLoginAction)
+ {
+ qmService.ShowStatus("Logging in...");
+ Task.Run(async () =>
+ {
+ try
+ {
+ await qmServiceLoginAction();
+ qmService.ClearStatus();
+ LoginEvent?.Invoke(this, null);
+ }
+ catch (Exception e)
+ {
+ string message = (e as ClientException)?.Message ?? "Error logging in";
+ qmService.ClearStatus();
+ ShowError(message, LoginErrorTitle);
+ }
+ });
+ }
+
+ private void BtnLogin_LeftClick(object sender, EventArgs eventArgs)
+ {
+ if (!ValidateForm())
+ return;
+
+ LoginAsync(async () => await qmService.LoginAsync(tbEmail.Text, tbPassword.Password));
+ }
+
+ private bool ValidateForm()
+ {
+ if (string.IsNullOrEmpty(tbEmail.Text))
+ {
+ ShowError("No Email specified", LoginErrorTitle);
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(tbPassword.Text))
+ {
+ ShowError("No Password specified", LoginErrorTitle);
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapList.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapList.cs
new file mode 100644
index 000000000..313c9f436
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapList.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchMapList : XNAPanel
+ {
+ private const int MouseScrollRate = 6;
+ public const int ItemHeight = 22;
+ public event EventHandler MapSelected;
+
+ private XNALabel lblVeto;
+ private XNALabel lblSides;
+ private XNALabel lblMaps;
+ private XNAPanel panelMaps;
+ private XNAScrollBar scrollBar;
+
+ public int SidesX => lblSides?.X ?? 0;
+ public int SidesWidth => lblSides?.Width ?? 0;
+ public int MapsX => lblMaps?.X ?? 0;
+ public int MapsWidth => lblMaps?.Width ?? 0;
+
+ public QuickMatchMapList(WindowManager windowManager) : base(windowManager)
+ {
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ const int vetoGap = 16;
+ const int scrollBarWidth = 18;
+
+ lblVeto = new XNALabel(WindowManager);
+ lblVeto.Text = "Veto";
+ lblVeto.ClientRectangle = new Rectangle(0, 0, 18, 18);
+ AddChild(lblVeto);
+
+ lblSides = new XNALabel(WindowManager);
+ lblSides.Text = "Sides";
+ lblSides.ClientRectangle = new Rectangle(lblVeto.Right + vetoGap, 0, 100, 18);
+ AddChild(lblSides);
+
+ lblMaps = new XNALabel(WindowManager);
+ lblMaps.Text = "Maps";
+ lblMaps.ClientRectangle = new Rectangle(lblSides.Right, 0, Width - lblVeto.Width - lblSides.Width - vetoGap - scrollBarWidth, 18);
+ AddChild(lblMaps);
+
+ panelMaps = new XNAPanel(WindowManager);
+ panelMaps.DrawBorders = false;
+ panelMaps.ClientRectangle = new Rectangle(0, lblVeto.Bottom, Width, Height - lblVeto.Height);
+ panelMaps.DrawMode = ControlDrawMode.UNIQUE_RENDER_TARGET;
+
+ scrollBar = new XNAScrollBar(WindowManager);
+ scrollBar.ClientRectangle = new Rectangle(panelMaps.Width - 18, 0, scrollBarWidth, panelMaps.Height);
+ scrollBar.Scrolled += ScrollBarScrolled;
+ panelMaps.AddChild(scrollBar);
+
+ AddChild(panelMaps);
+
+ MouseScrolled += OnMouseScrolled;
+ }
+
+ private void OnMouseScrolled(object sender, EventArgs e)
+ {
+ int viewTop = GetNewScrollBarViewTop();
+ if (viewTop == scrollBar.ViewTop)
+ return;
+
+ scrollBar.ViewTop = viewTop;
+ RefreshScrollbar();
+ RefreshItemLocations();
+ }
+
+ public void AddItems(IEnumerable listItems)
+ {
+ foreach (QuickMatchMapListItem quickMatchMapListItem in listItems)
+ AddItem(quickMatchMapListItem);
+ }
+
+ private void AddItem(QuickMatchMapListItem listItem)
+ {
+ listItem.ClientRectangle = new Rectangle(0, MapItemCount * ItemHeight, Width - scrollBar.ScrollWidth, ItemHeight);
+ listItem.LeftClickMap += MapItem_LeftClick;
+ panelMaps.AddChild(listItem);
+
+ listItem.SetLocations(
+ new Rectangle(lblVeto.X, 0, lblVeto.Width, ItemHeight),
+ new Rectangle(lblSides.X, 0, lblSides.Width, ItemHeight),
+ new Rectangle(lblMaps.X, 0, lblMaps.Width, ItemHeight)
+ );
+ RefreshScrollbar();
+ RefreshItemOpenUp(listItem);
+ }
+
+ private void RefreshItemLocations()
+ {
+ int index = 0;
+ foreach (QuickMatchMapListItem quickMatchMapItem in MapItemChildren)
+ {
+ quickMatchMapItem.Y = (index++ * ItemHeight) - scrollBar.ViewTop;
+ RefreshItemOpenUp(quickMatchMapItem);
+ }
+ }
+
+ private void RefreshItemOpenUp(QuickMatchMapListItem quickMatchMapListItem)
+ => quickMatchMapListItem.SetOpenUp(quickMatchMapListItem.OpenedDownWindowBottom > scrollBar.GetWindowRectangle().Bottom);
+
+ private void ScrollBarScrolled(object sender, EventArgs eventArgs) => RefreshItemLocations();
+
+ private int GetNewScrollBarViewTop()
+ {
+ int scrollWheelValue = Cursor.ScrollWheelValue;
+ int viewTop = scrollBar.ViewTop - (scrollWheelValue * MouseScrollRate);
+ int maxViewTop = scrollBar.Length - scrollBar.DisplayedPixelCount;
+
+ if (viewTop < 0)
+ viewTop = 0;
+ else if (viewTop > maxViewTop)
+ viewTop = maxViewTop;
+
+ return viewTop;
+ }
+
+ public void RefreshScrollbar()
+ {
+ scrollBar.Length = MapItemChildren.Count() * ItemHeight;
+ scrollBar.DisplayedPixelCount = panelMaps.Height - 4;
+ scrollBar.Refresh();
+ }
+
+ private void MapItem_LeftClick(object sender, EventArgs eventArgs)
+ {
+ var selectedItem = sender as QuickMatchMapListItem;
+ foreach (QuickMatchMapListItem quickMatchMapItem in MapItemChildren)
+ quickMatchMapItem.Selected = quickMatchMapItem == selectedItem;
+
+ MapSelected?.Invoke(this, new QmMapSelectedEventArgs(selectedItem?.Map));
+ }
+
+ public void Clear()
+ {
+ foreach (QuickMatchMapListItem child in MapItemChildren.ToList())
+ panelMaps.RemoveChild(child);
+
+ RefreshScrollbar();
+ }
+
+ private int MapItemCount => MapItemChildren.Count();
+
+ private IEnumerable MapItemChildren
+ => panelMaps.Children.Select(c => c as QuickMatchMapListItem).Where(i => i != null);
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItem.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItem.cs
new file mode 100644
index 000000000..3eb941043
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchMapListItem.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Linq;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchMapListItem : XNAPanel
+ {
+ public event EventHandler LeftClickMap;
+
+ private readonly QmLadderMap ladderMap;
+ private readonly QmLadder ladder;
+ private XNAClientCheckBox cbVeto;
+ private XNAClientDropDown ddSide;
+ private XNAPanel panelMap;
+ private XNALabel lblMap;
+ private Color defaultTextColor;
+
+ private XNAPanel topBorder;
+ private XNAPanel bottomBorder;
+
+ public QmMap Map => ladderMap.Map;
+
+ private bool selected;
+
+ public bool Selected
+ {
+ get => selected;
+ set
+ {
+ selected = value;
+ panelMap.BackgroundTexture = selected ? AssetLoader.CreateTexture(new Color(255, 0, 0), 1, 1) : null;
+ }
+ }
+
+ public QuickMatchMapListItem(WindowManager windowManager, QmLadderMap ladderMap, QmLadder ladder) : base(windowManager)
+ {
+ this.ladderMap = ladderMap;
+ this.ladder = ladder;
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ DrawBorders = false;
+
+ topBorder = new XNAPanel(WindowManager);
+ topBorder.DrawBorders = true;
+ AddChild(topBorder);
+
+ bottomBorder = new XNAPanel(WindowManager);
+ bottomBorder.DrawBorders = true;
+ AddChild(bottomBorder);
+
+ cbVeto = new XNAClientCheckBox(WindowManager);
+ cbVeto.CheckedChanged += CbVeto_CheckChanged;
+
+ ddSide = new XNAClientDropDown(WindowManager);
+ defaultTextColor = ddSide.TextColor;
+ AddChild(ddSide);
+
+ panelMap = new XNAPanel(WindowManager);
+ panelMap.LeftClick += Map_LeftClicked;
+ panelMap.DrawBorders = false;
+ AddChild(panelMap);
+
+ lblMap = new XNALabel(WindowManager);
+ lblMap.LeftClick += Map_LeftClicked;
+ lblMap.ClientRectangle = new Rectangle(4, 2, panelMap.Width, panelMap.Height);
+ panelMap.AddChild(lblMap);
+ AddChild(cbVeto);
+
+ InitUI();
+ }
+
+ private void CbVeto_CheckChanged(object sender, EventArgs e)
+ {
+ ddSide.TextColor = cbVeto.Checked ? UISettings.ActiveSettings.DisabledItemColor : defaultTextColor;
+ lblMap.TextColor = cbVeto.Checked ? UISettings.ActiveSettings.DisabledItemColor : defaultTextColor;
+ ddSide.AllowDropDown = !cbVeto.Checked;
+ }
+
+ public void SetLocations(Rectangle vetoR, Rectangle ddSideR, Rectangle mapR)
+ {
+ cbVeto.ClientRectangle = vetoR;
+ ddSide.ClientRectangle = ddSideR;
+ panelMap.ClientRectangle = mapR;
+
+ topBorder.ClientRectangle = new Rectangle(panelMap.X, panelMap.Y, panelMap.Width, 1);
+ bottomBorder.ClientRectangle = new Rectangle(panelMap.X, panelMap.Bottom, panelMap.Width, 1);
+ }
+
+ private void Map_LeftClicked(object sender, EventArgs eventArgs) => LeftClickMap?.Invoke(this, EventArgs.Empty);
+
+ private void InitUI()
+ {
+ ddSide.Items.Clear();
+ foreach (int ladderMapAllowedSideId in ladderMap.AllowedSideIds)
+ {
+ var side = ladder.Sides.FirstOrDefault(s => s.LocalId == ladderMapAllowedSideId);
+ if (side == null)
+ continue;
+
+ ddSide.AddItem(new XNADropDownItem()
+ {
+ Text = side.Name,
+ Tag = side
+ });
+ }
+
+ if (ddSide.Items.Count > 0)
+ ddSide.SelectedIndex = 1;
+
+ lblMap.Text = ladderMap.Description;
+ }
+
+ public void SetOpenUp(bool openUp) => ddSide.OpenUp = openUp;
+
+ public bool IsVetoed() => cbVeto.Checked;
+
+ public int OpenedDownWindowBottom => GetWindowRectangle().Bottom + (ddSide.ItemHeight * ddSide.Items.Count);
+
+ public bool ContainsPointVertical(Point point) => Y < point.Y && Y + Height < point.Y;
+ }
+}
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchPanel.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchPanel.cs
new file mode 100644
index 000000000..6a4ac9b6c
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchPanel.cs
@@ -0,0 +1,20 @@
+using System;
+using ClientGUI;
+using Rampastring.XNAUI;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public abstract class QuickMatchPanel : INItializableWindow
+ {
+ public event EventHandler Exit;
+
+ protected QuickMatchPanel(WindowManager windowManager) : base(windowManager)
+ {
+ }
+
+ protected void Exit_Click(object sender, EventArgs args) => Exit?.Invoke(sender, args);
+
+ protected void ShowError(string error, string title = "Error")
+ => XNAMessageBox.Show(WindowManager, title, error);
+ }
+}
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchStatusMessageWindow.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchStatusMessageWindow.cs
new file mode 100644
index 000000000..479120e8d
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchStatusMessageWindow.cs
@@ -0,0 +1,59 @@
+using System;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchStatusMessageWindow : INItializableWindow
+ {
+ private int DefaultInternalWidth;
+
+ private XNAPanel statusWindowInternal { get; set; }
+
+ private XNALabel statusMessage { get; set; }
+
+ private Action buttonAction { get; set; }
+
+ public QuickMatchStatusMessageWindow(WindowManager windowManager) : base(windowManager)
+ {
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ statusWindowInternal = FindChild(nameof(statusWindowInternal));
+ DefaultInternalWidth = statusWindowInternal.ClientRectangle.Width;
+
+ statusMessage = FindChild(nameof(statusMessage));
+ }
+
+ public void Show(string message) => Show(new QmStatusMessageEventArgs(message));
+
+ public void Show(QmStatusMessageEventArgs statusMessageEventArgs)
+ {
+ statusMessage.Text = statusMessageEventArgs.Message;
+ buttonAction = statusMessageEventArgs.ButtonAction;
+
+ ResizeForText();
+ Enable();
+ }
+
+ private void ResizeForText()
+ {
+ var textDimensions = Renderer.GetTextDimensions(statusMessage.Text, statusMessage.FontIndex);
+
+ statusWindowInternal.Width = (int)Math.Max(DefaultInternalWidth, textDimensions.X + 60);
+ statusWindowInternal.X = (Width / 2) - (statusWindowInternal.Width / 2);
+ }
+
+ private void Button_LeftClick(object sender, EventArgs eventArgs)
+ {
+ Disable();
+ buttonAction?.Invoke();
+ }
+ }
+}
diff --git a/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchWindow.cs b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchWindow.cs
new file mode 100644
index 000000000..b39bebb9d
--- /dev/null
+++ b/DXMainClient/DXGUI/Multiplayer/QuickMatch/QuickMatchWindow.cs
@@ -0,0 +1,80 @@
+using System;
+using ClientGUI;
+using DTAClient.Domain.Multiplayer.CnCNet.QuickMatch;
+using Microsoft.Xna.Framework;
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.XNAControls;
+
+namespace DTAClient.DXGUI.Multiplayer.QuickMatch
+{
+ public class QuickMatchWindow : INItializableWindow
+ {
+ private readonly QmService qmService;
+
+ private QuickMatchLoginPanel loginPanel;
+ private QuickMatchLobbyPanel lobbyPanel;
+ private QuickMatchStatusMessageWindow statusWindow;
+
+ public QuickMatchWindow(WindowManager windowManager) : base(windowManager)
+ {
+ qmService = QmService.GetInstance();
+ }
+
+ public override void Initialize()
+ {
+ Name = nameof(QuickMatchWindow);
+ BackgroundTexture = AssetLoader.CreateTexture(new Color(0, 0, 0, 255), 1, 1);
+ PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED;
+
+ base.Initialize();
+
+ loginPanel = FindChild(nameof(QuickMatchLoginPanel));
+ loginPanel.LoginEvent += LoginEvent;
+ loginPanel.Exit += (sender, args) => Disable();
+
+ lobbyPanel = FindChild(nameof(QuickMatchLobbyPanel));
+ lobbyPanel.LogoutEvent += LogoutEvent;
+ lobbyPanel.Exit += (sender, args) => Disable();
+
+ statusWindow = FindChild(nameof(statusWindow));
+
+ WindowManager.CenterControlOnScreen(this);
+
+ qmService.StatusMessageEvent += StatusMessageEvent;
+
+ EnabledChanged += EnabledChangedEvent;
+ }
+
+ private void EnabledChangedEvent(object sender, EventArgs e)
+ {
+ if (!Enabled)
+ return;
+
+ loginPanel.Enable();
+ }
+
+ private void StatusMessageEvent(object sender, QmStatusMessageEventArgs qmStatusMessageEventArgs)
+ {
+ if (string.IsNullOrEmpty(qmStatusMessageEventArgs?.Message))
+ {
+ statusWindow.Disable();
+ return;
+ }
+
+ statusWindow.Show(qmStatusMessageEventArgs);
+ }
+
+ private void LoginEvent(object sender, EventArgs args)
+ {
+ lobbyPanel.Enable();
+ loginPanel.Disable();
+ }
+
+ private void LogoutEvent(object sender, EventArgs args)
+ {
+ loginPanel.Enable();
+ lobbyPanel.Disable();
+ qmService.ClearStatus();
+ }
+ }
+}
diff --git a/DXMainClient/DXMainClient.csproj b/DXMainClient/DXMainClient.csproj
index b8108e0a4..6e5dcc672 100644
--- a/DXMainClient/DXMainClient.csproj
+++ b/DXMainClient/DXMainClient.csproj
@@ -43,9 +43,11 @@
+
+
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmFetchDataEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmFetchDataEventArgs.cs
new file mode 100644
index 000000000..8ec934c40
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmFetchDataEventArgs.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmFetchDataEventArgs : EventArgs
+ {
+ public bool IsSuccess { get; set; }
+
+ public QmFetchDataEventArgs(bool isSuccess)
+ {
+ IsSuccess = isSuccess;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLadderMapsEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLadderMapsEventArgs.cs
new file mode 100644
index 000000000..f8f153001
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLadderMapsEventArgs.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLadderMapsEventArgs : EventArgs
+ {
+ public IEnumerable Maps { get; set; }
+
+ public QmLadderMapsEventArgs(IEnumerable maps)
+ {
+ Maps = maps;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLaddersEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLaddersEventArgs.cs
new file mode 100644
index 000000000..7378acaf9
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLaddersEventArgs.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLaddersEventArgs : EventArgs
+ {
+ public readonly IEnumerable Ladders;
+
+ public QmLaddersEventArgs(IEnumerable ladders)
+ {
+ Ladders = ladders;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLoginEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLoginEventArgs.cs
new file mode 100644
index 000000000..3eb171bce
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLoginEventArgs.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLoginEventArgs : EventArgs
+ {
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLogoutEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLogoutEventArgs.cs
new file mode 100644
index 000000000..5c1ecf4b8
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmLogoutEventArgs.cs
@@ -0,0 +1,6 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLogoutEventArgs : QmLoginEventArgs
+ {
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmMapSelectedEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmMapSelectedEventArgs.cs
new file mode 100644
index 000000000..898b0d05b
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmMapSelectedEventArgs.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmMapSelectedEventArgs : EventArgs
+ {
+ public QmMap Map { get; set; }
+
+ public QmMapSelectedEventArgs(QmMap map)
+ {
+ Map = map;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmRequestEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmRequestEventArgs.cs
new file mode 100644
index 000000000..276ac8bd4
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmRequestEventArgs.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmRequestEventArgs : EventArgs
+ {
+ public readonly QmRequestResponse Response;
+
+ public QmRequestEventArgs(QmRequestResponse qmRequestResponse)
+ {
+ Response = qmRequestResponse;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmStatusMessageEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmStatusMessageEventArgs.cs
new file mode 100644
index 000000000..888d370dd
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmStatusMessageEventArgs.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmStatusMessageEventArgs : EventArgs
+ {
+ public string Message { get; }
+
+ public string ButtonText { get; }
+
+ public Action ButtonAction { get; }
+
+ public QmStatusMessageEventArgs(string message, string buttonText = null, Action buttonAction = null)
+ {
+ Message = message;
+ ButtonText = buttonText;
+ ButtonAction = buttonAction;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmUserAccountsEventArgs.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmUserAccountsEventArgs.cs
new file mode 100644
index 000000000..74a505d2a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/EventArgs/QmUserAccountsEventArgs.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmUserAccountsEventArgs : EventArgs
+ {
+ public readonly IEnumerable UserAccounts;
+
+ public QmUserAccountsEventArgs(IEnumerable userAccounts)
+ {
+ UserAccounts = userAccounts;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmApiService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmApiService.cs
new file mode 100644
index 000000000..14d451391
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmApiService.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using Newtonsoft.Json;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmApiService
+ {
+ private HttpClient _httpClient;
+ private readonly QmSettings qmSettings;
+ private string _token;
+
+ public QmApiService()
+ {
+ qmSettings = QmSettingsService.GetInstance().GetSettings();
+ }
+
+ public void SetToken(string token)
+ {
+ _token = token;
+ }
+
+ public async Task> FetchLadderMapsForAbbrAsync(string ladderAbbreviation)
+ {
+ var httpClient = GetAuthenticatedClient();
+ string url = string.Format(qmSettings.GetLadderMapsUrl, ladderAbbreviation);
+ var response = await httpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException($"Error fetching ladder maps: {response.ReasonPhrase}");
+
+ return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task> FetchUserAccountsAsync()
+ {
+ var httpClient = GetAuthenticatedClient();
+ var response = await httpClient.GetAsync(qmSettings.GetUserAccountsUrl);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException($"Error fetching user accounts: {response.ReasonPhrase}");
+
+ return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task> FetchLaddersAsync()
+ {
+ var httpClient = GetAuthenticatedClient();
+ var response = await httpClient.GetAsync(qmSettings.GetLaddersUrl);
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException($"Error fetching ladders: {response.ReasonPhrase}");
+
+ return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync());
+ }
+
+ public async Task LoginAsync(string email, string password)
+ {
+ var httpClient = GetHttpClient();
+ var postBodyContent = new StringContent(JsonConvert.SerializeObject(new QMLoginRequest()
+ {
+ Email = email,
+ Password = password
+ }), Encoding.Default, "application/json");
+ var response = await httpClient.PostAsync(qmSettings.LoginUrl, postBodyContent);
+
+ return await HandleLoginResponse(response, "Error logging in: {0}, {1}");
+ }
+
+ public async Task RefreshAsync()
+ {
+ var httpClient = GetAuthenticatedClient();
+ var response = await httpClient.GetAsync(qmSettings.RefreshUrl);
+
+ return await HandleLoginResponse(response, "Error refreshing token: {0}, {1}");
+ }
+
+ private async Task HandleLoginResponse(HttpResponseMessage response, string unknownErrorFormat)
+ {
+ if (!response.IsSuccessStatusCode)
+ return await HandleFailedLoginResponse(response, unknownErrorFormat);
+
+ string responseBody = await response.Content.ReadAsStringAsync();
+ var authData = JsonConvert.DeserializeObject(responseBody);
+ if (authData == null)
+ throw new ClientException(responseBody);
+
+ _token = authData.Token;
+ return authData;
+ }
+
+ private async Task HandleFailedLoginResponse(HttpResponseMessage response, string unknownErrorFormat)
+ {
+ string responseBody = await response.Content.ReadAsStringAsync();
+ string message;
+ switch (response.StatusCode)
+ {
+ case HttpStatusCode.BadGateway:
+ message = "Server unreachable";
+ break;
+ case HttpStatusCode.Unauthorized:
+ message = "Invalid username/password";
+ break;
+ default:
+ message = string.Format(unknownErrorFormat, response.ReasonPhrase, responseBody);
+ break;
+ }
+
+ throw new ClientRequestException(message, response.StatusCode);
+ }
+
+
+ public bool IsServerAvailable()
+ {
+ var httpClient = GetAuthenticatedClient();
+ var response = httpClient.GetAsync(qmSettings.ServerStatusUrl).Result;
+ return response.IsSuccessStatusCode;
+ }
+
+ private HttpClient GetHttpClient() =>
+ _httpClient ?? (_httpClient = new HttpClient
+ {
+ BaseAddress = new Uri(qmSettings.BaseUrl),
+ Timeout = TimeSpan.FromSeconds(10)
+ });
+
+ private HttpClient GetAuthenticatedClient()
+ {
+ var httpClient = GetHttpClient();
+ httpClient.DefaultRequestHeaders.Clear();
+ httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_token}");
+ return httpClient;
+ }
+
+ public async Task QuickMatchAsync(QmRequest qmRequest)
+ {
+ var httpClient = GetAuthenticatedClient();
+ string url = string.Format(qmSettings.QuickMatchUrl, qmRequest.Ladder, qmRequest.PlayerName);
+ var response = await httpClient.PostAsync(url, new StringContent(JsonConvert.SerializeObject(qmRequest), Encoding.Default, "application/json"));
+
+ string responseBody = await response.Content.ReadAsStringAsync();
+ Logger.Log(responseBody);
+ var matchRequestResponse = JsonConvert.DeserializeObject(responseBody);
+
+ if (!response.IsSuccessStatusCode)
+ throw new ClientException($"Error requesting match: {response.ReasonPhrase}");
+
+ if (!matchRequestResponse.IsSuccessful)
+ throw new ClientException($"Error requesting match: {matchRequestResponse.Message ?? matchRequestResponse.Description ?? "unknown"}");
+
+ return matchRequestResponse;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmAuthData.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmAuthData.cs
new file mode 100644
index 000000000..a3296117a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmAuthData.cs
@@ -0,0 +1,9 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmAuthData
+ {
+ public string Token { get; set; }
+ public string Email { get; set; }
+ public string Name { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmData.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmData.cs
new file mode 100644
index 000000000..1288b9762
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmData.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmData
+ {
+ public List Ladders { get; set; }
+
+ public List UserAccounts { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadder.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadder.cs
new file mode 100644
index 000000000..0420c7207
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadder.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLadder
+ {
+ [JsonProperty("id")]
+ public long Id { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ [JsonProperty("abbreviation")]
+ public string Abbreviation { get; set; }
+
+ [JsonProperty("game")]
+ public string Game { get; set; }
+
+ [JsonProperty("clans_allowed")]
+ private int clansAllowed { get; set; }
+
+ [JsonIgnore]
+ public bool ClansAllowed => clansAllowed == 1;
+
+ [JsonProperty("game_object_schema_id")]
+ public int GameObjectSchemaId { get; set; }
+
+ [JsonProperty("map_pool_id")]
+ public int MapPoolId { get; set; }
+
+ [JsonProperty("private")]
+ private int _private { get; set; }
+
+ [JsonIgnore]
+ public bool IsPrivate => _private == 1;
+
+ [JsonProperty("sides")]
+ public IEnumerable Sides { get; set; }
+
+ [JsonProperty("vetoes")]
+ public int Vetoes { get; set; }
+
+ [JsonProperty("allowed_sides")]
+ public IEnumerable AllowedSideLocalIds { get; set; }
+
+ [JsonProperty("current")]
+ public string Current { get; set; }
+
+ [JsonProperty("qm_ladder_rules")]
+ public QmLadderRules LadderRules { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadderMap.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadderMap.cs
new file mode 100644
index 000000000..97d006046
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadderMap.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLadderMap
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("map_id")]
+ public int MapId { get; set; }
+
+ [JsonProperty("description")]
+ public string Description { get; set; }
+
+ [JsonProperty("bit_idx")]
+ public int BitIndex { get; set; }
+
+ [JsonProperty("valid")]
+ private int valid { get; set; }
+
+ [JsonIgnore]
+ public bool IsValid => valid == 1;
+
+ [JsonProperty("spawn_order")]
+ public string SpawnOrder { get; set; }
+
+ [JsonProperty("team1_spawn_order")]
+ public string Team1SpawnOrder { get; set; }
+
+ [JsonProperty("team2_spawn_order")]
+ public string Team2SpawnOrder { get; set; }
+
+ [JsonProperty("allowed_sides")]
+ public IEnumerable AllowedSideIds { get; set; }
+
+ [JsonProperty("admin_description")]
+ public string AdminDescription { get; set; }
+
+ [JsonProperty("map_pool_id")]
+ public int MapPoolId { get; set; }
+
+ [JsonProperty("rejectable")]
+ private int rejectable { get; set; }
+
+ [JsonIgnore]
+ public bool IsRejectable => rejectable == 1;
+
+ [JsonProperty("default_reject")]
+ private int defaultReject { get; set; }
+
+ [JsonIgnore]
+ public bool IsDefaultReject => defaultReject == 1;
+
+ [JsonProperty("hash")]
+ public string Hash { get; set; }
+
+ [JsonProperty("map")]
+ public QmMap Map { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadderRules.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadderRules.cs
new file mode 100644
index 000000000..29c63205c
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLadderRules.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLadderRules
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("player_count")]
+ public int PlayerCount { get; set; }
+
+ [JsonProperty("map_vetoes")]
+ public int MapVetoes { get; set; }
+
+ [JsonProperty("max_difference")]
+ public int MaxDifference { get; set; }
+
+ [JsonProperty("all_sides")]
+ private string allSides { get; set; }
+
+ [JsonIgnore]
+ public IEnumerable AllSides => allSides?.Split(',').Select(int.Parse) ?? new List();
+
+ [JsonProperty("allowed_sides")]
+ private string allowedSides { get; set; }
+
+ [JsonIgnore]
+ public IEnumerable AllowedSides => allSides?.Split(',').Select(int.Parse) ?? new List();
+
+ [JsonProperty("bail_time")]
+ public int BailTime { get; set; }
+
+ [JsonProperty("bail_fps")]
+ public int BailFps { get; set; }
+
+ [JsonProperty("tier2_rating")]
+ public int Tier2Rating { get; set; }
+
+ [JsonProperty("rating_per_second")]
+ public double RatingPerSecond { get; set; }
+
+ [JsonProperty("max_points_difference")]
+ public int MaxPointsDifference { get; set; }
+
+ [JsonProperty("points_per_second")]
+ public int PointsPerSecond { get; set; }
+
+ [JsonProperty("use_elo_points")]
+ private int useEloPoints { get; set; }
+
+ [JsonIgnore]
+ public bool UseEloPoints => useEloPoints == 1;
+
+ [JsonProperty("wol_k")]
+ public int WolK { get; set; }
+
+ [JsonProperty("show_map_preview")]
+ private int showMapPreview { get; set; }
+
+ [JsonIgnore]
+ public bool ShowMapPreview => showMapPreview == 1;
+
+ [JsonProperty("reduce_map_repeats")]
+ private int reduceMapRepeats { get; set; }
+
+ [JsonIgnore]
+ public bool ReduceMapRepeats => reduceMapRepeats == 1;
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginEvent.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginEvent.cs
new file mode 100644
index 000000000..5a8da2060
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginEvent.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmLoginEvent : EventArgs
+ {
+ // public QmLoginEventStatusEnum Status { get; set; }
+
+ public string Error { get; set; }
+
+ // public bool IsSuccess => Status == QmLoginEventStatusEnum.Success;
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginEventStatusEnum.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginEventStatusEnum.cs
new file mode 100644
index 000000000..42a19f38a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginEventStatusEnum.cs
@@ -0,0 +1,13 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public enum QmLoginEventStatusEnum2
+ {
+ Unknown,
+ Success,
+ Unauthorized,
+ Logout,
+ NoUserAccounts,
+ FailedRefresh,
+ FailedDataFetch,
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginRequest.cs
new file mode 100644
index 000000000..2553a343c
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmLoginRequest.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QMLoginRequest
+ {
+ [JsonProperty("email")]
+ public string Email { get; set; }
+ [JsonProperty("password")]
+ public string Password { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmMap.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmMap.cs
new file mode 100644
index 000000000..1e86dbc52
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmMap.cs
@@ -0,0 +1,19 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmMap
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("hash")]
+ public string Hash { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequest.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequest.cs
new file mode 100644
index 000000000..2234c0f1a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequest.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmRequest
+ {
+ [JsonProperty("type")]
+ public string Type { get; set; } = QmRequestTypes.MatchMeUp;
+
+ [JsonProperty("lan_ip")]
+ public string LanIP { get; set; }
+
+ [JsonProperty("lan_port")]
+ public string LanPort { get; set; }
+
+ [JsonProperty("ipv6_address")]
+ public string IPv6Address { get; set; }
+
+ [JsonProperty("ipv6_port")]
+ public string IPv6Port { get; set; }
+
+ [JsonProperty("ip_address")]
+ public string IPAddress { get; set; }
+
+ [JsonProperty("ip_port")]
+ public string IPPort { get; set; }
+
+ [JsonProperty("side")]
+ public int Side { get; set; }
+
+ [JsonProperty("map_bitfield")]
+ public string MapBitfield { get; set; }
+
+ [JsonProperty("version")]
+ public string Version { get; set; } = "2.0";
+
+ [JsonProperty("platform")]
+ public string Platform { get; set; }
+
+ [JsonProperty("map_sides")]
+ public string[] MapSides { get; set; }
+
+ [JsonProperty("ai_dat")]
+ public string CheatSeen { get; set; }
+
+ [JsonProperty("exe_hash")]
+ public string ExeHash { get; set; }
+
+ [JsonProperty("ddraw")]
+ public string DDrawHash { get; set; }
+
+ [JsonProperty("session")]
+ public string Session { get; set; }
+
+ [JsonIgnore]
+ public string Ladder { get; set; }
+
+ [JsonIgnore]
+ public string PlayerName { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestResponse.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestResponse.cs
new file mode 100644
index 000000000..454649990
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestResponse.cs
@@ -0,0 +1,38 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmRequestResponse
+ {
+ [JsonProperty("type")]
+ public string Type { get; set; }
+
+ [JsonProperty("description")]
+ public string Description { get; set; }
+
+ [JsonProperty("message")]
+ public string Message { get; set; }
+
+ [JsonProperty("checkback")]
+ public int CheckBack { get; set; }
+
+ [JsonProperty("no_sooner_than")]
+ public int NoSoonerThan { get; set; }
+
+ public bool IsError => IsType(QmResponseTypes.Error);
+
+ public bool IsFatal => IsType(QmResponseTypes.Fatal);
+
+ public bool IsSpawn => IsType(QmResponseTypes.Spawn);
+
+ public bool IsUpdate => IsType(QmResponseTypes.Update);
+
+ public bool IsQuit => IsType(QmResponseTypes.Quit);
+
+ public bool IsWait => IsType(QmResponseTypes.Wait);
+
+ public bool IsSuccessful => !IsError && !IsFatal;
+
+ private bool IsType(string type) => Type == type;
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestTypes.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestTypes.cs
new file mode 100644
index 000000000..7f5c88da3
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmRequestTypes.cs
@@ -0,0 +1,9 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public static class QmRequestTypes
+ {
+ public const string Quit = "quit";
+ public const string Update = "Update";
+ public const string MatchMeUp = "match me up";
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmResponseTypes.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmResponseTypes.cs
new file mode 100644
index 000000000..f0f0f6b68
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmResponseTypes.cs
@@ -0,0 +1,17 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public static class QmResponseTypes
+ {
+ public const string Error = "error";
+
+ public const string Fatal = "fatal";
+
+ public const string Spawn = "spawn";
+
+ public const string Update = "update";
+
+ public const string Quit = "quit";
+
+ public const string Wait = "please wait";
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmService.cs
new file mode 100644
index 000000000..05430698d
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmService.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ClientCore.Exceptions;
+using JWT;
+using JWT.Algorithms;
+using JWT.Exceptions;
+using JWT.Serializers;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmService
+ {
+ private readonly QmUserSettingsService userSettingsService;
+ private readonly QmApiService apiService;
+ private readonly QmUserSettings qmUserSettings;
+ private readonly QmData qmData;
+
+ private static QmService Instance;
+
+ public event EventHandler StatusMessageEvent;
+
+ public event EventHandler FetchDataEvent;
+
+ public event EventHandler LadderMapsEvent;
+
+ public event EventHandler MatchedEvent;
+
+ public event EventHandler UserAccountsEvent;
+
+ public event EventHandler LaddersEvent;
+
+ public event EventHandler QmRequestEvent;
+
+ private QmService()
+ {
+ userSettingsService = new QmUserSettingsService();
+ apiService = new QmApiService();
+ qmUserSettings = userSettingsService.LoadSettings();
+ qmData = new QmData();
+ }
+
+ public static QmService GetInstance() => Instance ?? (Instance = new QmService());
+
+ ///
+ /// Login process to cncnet
+ ///
+ ///
+ ///
+ public async Task LoginAsync(string email, string password)
+ {
+ var authData = await apiService.LoginAsync(email, password);
+ FinishLogin(authData, false, email);
+ }
+
+ ///
+ /// Attempts to refresh an existing auth token
+ ///
+ public async Task RefreshAsync()
+ {
+ try
+ {
+ var authData = await apiService.RefreshAsync();
+ FinishLogin(authData, false);
+ }
+ catch (Exception)
+ {
+ ClearAuthData();
+ throw;
+ }
+ }
+
+ ///
+ /// Simply clear all auth data from our settings
+ ///
+ public void Logout() => ClearAuthData();
+
+ private void ClearAuthData()
+ {
+ userSettingsService.ClearAuthData();
+ userSettingsService.SaveSettings();
+ }
+
+ public IEnumerable GetUserAccounts() => qmData?.UserAccounts;
+
+ public QmLadder GetLadderForId(int ladderId) => qmData.Ladders.FirstOrDefault(l => l.Id == ladderId);
+
+ public string GetCachedEmail() => qmUserSettings.Email;
+
+ public string GetCachedLadder() => qmUserSettings.Ladder;
+
+ public bool IsServerAvailable() => apiService.IsServerAvailable();
+
+ public bool IsLoggedIn()
+ {
+ if (qmUserSettings.AuthData == null)
+ return false;
+
+ try
+ {
+ DecodeToken(qmUserSettings.AuthData.Token);
+ }
+ catch (TokenExpiredException)
+ {
+ Logger.Log("QuickMatch token is expired");
+ return false;
+ }
+ catch (Exception e)
+ {
+ Logger.Log(e.StackTrace);
+ return false;
+ }
+
+ apiService.SetToken(qmUserSettings.AuthData.Token);
+
+ return true;
+ }
+
+ public void SetLadder(string ladder)
+ {
+ qmUserSettings.Ladder = ladder;
+ userSettingsService.SaveSettings();
+ }
+
+ public async Task FetchLaddersAndUserAccountsAsync()
+ {
+ var fetchLaddersTask = apiService.FetchLaddersAsync();
+ var fetchUserAccountsTask = apiService.FetchUserAccountsAsync();
+
+ await Task.WhenAll(fetchLaddersTask, fetchUserAccountsTask);
+ qmData.Ladders = fetchLaddersTask.Result.ToList();
+ qmData.UserAccounts = fetchUserAccountsTask.Result.ToList();
+
+ if (!qmData.Ladders.Any())
+ throw new ClientException("No quick match ladders currently found.");
+
+ if (!qmData.UserAccounts.Any())
+ throw new ClientException("No user accounts found in quick match. Are you registered for this month?");
+
+ return qmData;
+ }
+
+ public async Task FetchLadderMapsForAbbrAsync(string ladderAbbr)
+ {
+ var ladderMaps = await apiService.FetchLadderMapsForAbbrAsync(ladderAbbr);
+
+ LadderMapsEvent?.Invoke(this, new QmLadderMapsEventArgs(ladderMaps));
+ }
+
+ private void FinishLogin(QmAuthData authData, bool refresh, string email = null)
+ {
+ qmUserSettings.AuthData = authData;
+ qmUserSettings.Email = email ?? qmUserSettings.Email;
+ userSettingsService.SaveSettings();
+ }
+
+ ///
+ /// We only need to verify the expiration date of the token so that we can refresh or request a new one if it is expired.
+ /// We do not need to worry about the signature. The API will handle that validation when the token is used.
+ ///
+ ///
+ private static void DecodeToken(string token)
+ {
+ IJsonSerializer serializer = new JsonNetSerializer();
+ IDateTimeProvider provider = new UtcDateTimeProvider();
+ ValidationParameters validationParameters = ValidationParameters.Default;
+ validationParameters.ValidateSignature = false;
+ IJwtValidator validator = new JwtValidator(serializer, provider, validationParameters);
+ IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
+ IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); // symmetric
+ IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder, algorithm);
+
+ decoder.Decode(token, "nosecret", verify: true);
+ }
+
+ public async Task QuickMatchAsync(QmRequest qmRequest)
+ => await apiService.QuickMatchAsync(qmRequest);
+
+ public void ShowStatus(string statusMessage, string buttonText = null, Action buttonAction = null)
+ => StatusMessageEvent?.Invoke(this, new QmStatusMessageEventArgs(statusMessage, buttonText, buttonAction));
+
+ public void ClearStatus()
+ => StatusMessageEvent?.Invoke(this, null);
+ }
+}
\ No newline at end of file
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettings.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettings.cs
new file mode 100644
index 000000000..b8c196edf
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettings.cs
@@ -0,0 +1,25 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmSettings
+ {
+ public const string DefaultBaseUrl = "https://ladder.cncnet.org";
+ public const string DefaultLoginUrl = "/api/v1/auth/login";
+ public const string DefaultRefreshUrl = "/api/v1/auth/refresh";
+ public const string DefaultServerStatusUrl = "/api/v1/ping";
+ public const string DefaultGetUserAccountsUrl = "/api/v1/user/account";
+ public const string DefaultGetLaddersUrl = "/api/v1/ladder";
+ public const string DefaultGetLadderMapsUrl = "/api/v1/qm/ladder/{0}/maps";
+ public const string DefaultQuickMatchUrl = "/api/v1/qm/{0}/{1}";
+
+ public string BaseUrl { get; set; } = DefaultBaseUrl;
+ public string LoginUrl { get; set; } = DefaultLoginUrl;
+ public string RefreshUrl { get; set; } = DefaultRefreshUrl;
+ public string ServerStatusUrl { get; set; } = DefaultServerStatusUrl;
+ public string GetUserAccountsUrl { get; set; } = DefaultGetUserAccountsUrl;
+ public string GetLaddersUrl { get; set; } = DefaultGetLaddersUrl;
+ public string GetLadderMapsUrl { get; set; } = DefaultGetLadderMapsUrl;
+ public string QuickMatchUrl { get; set; } = DefaultQuickMatchUrl;
+
+ public string MatchFoundSoundFile { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettingsService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettingsService.cs
new file mode 100644
index 000000000..13d8ed57e
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSettingsService.cs
@@ -0,0 +1,82 @@
+using System.IO;
+using ClientCore;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmSettingsService
+ {
+ private static QmSettingsService Instance;
+ private static readonly string SettingsFile = ClientConfiguration.Instance.QuickMatchPath;
+
+ private const string BasicSectionKey = "Basic";
+ private const string SoundsSectionKey = "Sounds";
+
+ private const string BaseUrlKey = "BaseUrl";
+ private const string LoginUrlKey = "LoginUrl";
+ private const string RefreshUrlKey = "RefreshUrl";
+ private const string ServerStatusUrlKey = "ServerStatusUrl";
+ private const string GetUserAccountsUrlKey = "GetUserAccountsUrl";
+ private const string GetLaddersUrlKey = "GetLaddersUrl";
+ private const string GetLadderMapsUrlKey = "GetLadderMapsUrl";
+
+ private const string MatchFoundSoundFileKey = "MatchFoundSoundFile";
+
+ private QmSettings qmSettings;
+
+ private QmSettingsService()
+ {
+ }
+
+ public static QmSettingsService GetInstance() => Instance ?? (Instance = new QmSettingsService());
+
+ public QmSettings GetSettings() => qmSettings ?? LoadSettings();
+
+ private QmSettings LoadSettings()
+ {
+ qmSettings = new QmSettings();
+ if (!File.Exists(SettingsFile))
+ SaveSettings(); // init the settings file
+
+ var iniFile = new IniFile(SettingsFile);
+ var basicSection = iniFile.GetSection(BasicSectionKey);
+ if (basicSection == null)
+ return qmSettings;
+
+ qmSettings.BaseUrl = basicSection.GetStringValue(BaseUrlKey, QmSettings.DefaultBaseUrl);
+ qmSettings.LoginUrl = basicSection.GetStringValue(LoginUrlKey, QmSettings.DefaultLoginUrl);
+ qmSettings.RefreshUrl = basicSection.GetStringValue(RefreshUrlKey, QmSettings.DefaultRefreshUrl);
+ qmSettings.ServerStatusUrl = basicSection.GetStringValue(ServerStatusUrlKey, QmSettings.DefaultServerStatusUrl);
+ qmSettings.GetUserAccountsUrl = basicSection.GetStringValue(GetUserAccountsUrlKey, QmSettings.DefaultGetUserAccountsUrl);
+ qmSettings.GetLaddersUrl = basicSection.GetStringValue(GetLaddersUrlKey, QmSettings.DefaultGetLaddersUrl);
+ qmSettings.GetLadderMapsUrl = basicSection.GetStringValue(GetLadderMapsUrlKey, QmSettings.DefaultGetLadderMapsUrl);
+
+ var soundsSection = iniFile.GetSection(SoundsSectionKey);
+ if (soundsSection == null)
+ return qmSettings;
+
+ string matchFoundSoundFile = soundsSection.GetStringValue(MatchFoundSoundFileKey, null);
+ if (File.Exists(matchFoundSoundFile))
+ qmSettings.MatchFoundSoundFile = matchFoundSoundFile;
+
+ return qmSettings;
+ }
+
+
+ public void SaveSettings()
+ {
+ var iniFile = new IniFile();
+ var basicSection = new IniSection(BasicSectionKey);
+ basicSection.AddKey(BaseUrlKey, qmSettings.BaseUrl);
+ basicSection.AddKey(LoginUrlKey, qmSettings.LoginUrl);
+ basicSection.AddKey(RefreshUrlKey, qmSettings.RefreshUrl);
+ basicSection.AddKey(ServerStatusUrlKey, qmSettings.ServerStatusUrl);
+ basicSection.AddKey(GetUserAccountsUrlKey, qmSettings.GetUserAccountsUrl);
+ basicSection.AddKey(GetLaddersUrlKey, qmSettings.GetLaddersUrl);
+ basicSection.AddKey(GetLadderMapsUrlKey, qmSettings.GetLadderMapsUrl);
+
+ iniFile.AddSection(basicSection);
+ iniFile.WriteIniFile(SettingsFile);
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSide.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSide.cs
new file mode 100644
index 000000000..4e3615872
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmSide.cs
@@ -0,0 +1,19 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmSide
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("local_id")]
+ public int LocalId { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStatusMessage.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStatusMessage.cs
new file mode 100644
index 000000000..a37bf27f7
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStatusMessage.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmStatusMessage : EventArgs
+ {
+ public string Message { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStatusMessageButton.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStatusMessageButton.cs
new file mode 100644
index 000000000..d96d1b0a7
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmStatusMessageButton.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmStatusMessageButton
+ {
+ public string Text { get; }
+
+ public Action Action { get; }
+
+ public QmStatusMessageButton(string message, Action action)
+ {
+ Text = message;
+ Action = action;
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserAccount.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserAccount.cs
new file mode 100644
index 000000000..19ee47e7a
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserAccount.cs
@@ -0,0 +1,22 @@
+using Newtonsoft.Json;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmUserAccount
+ {
+ [JsonProperty("id")]
+ public long Id { get; set; }
+
+ [JsonProperty("username")]
+ public string Username { get; set; }
+
+ [JsonProperty("ladder_id")]
+ public int LadderId { get; set; }
+
+ [JsonProperty("card_id")]
+ public int CardId { get; set; }
+
+ [JsonProperty("ladder")]
+ public QmLadder Ladder { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettings.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettings.cs
new file mode 100644
index 000000000..6f71b2995
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettings.cs
@@ -0,0 +1,9 @@
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmUserSettings
+ {
+ public string Email { get; set; }
+ public string Ladder { get; set; }
+ public QmAuthData AuthData { get; set; }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettingsService.cs b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettingsService.cs
new file mode 100644
index 000000000..73cddad18
--- /dev/null
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/QuickMatch/QmUserSettingsService.cs
@@ -0,0 +1,75 @@
+using System;
+using System.IO;
+using ClientCore;
+using Newtonsoft.Json;
+using Rampastring.Tools;
+
+namespace DTAClient.Domain.Multiplayer.CnCNet.QuickMatch
+{
+ public class QmUserSettingsService
+ {
+ private static readonly string SettingsFile = $"{ProgramConstants.ClientUserFilesPath}QuickMatchSettings.ini";
+
+ private const string BasicSectionKey = "Basic";
+ private const string AuthDataKey = "AuthData";
+ private const string EmailKey = "Email";
+ private const string LadderKey = "Ladder";
+
+ private QmUserSettings qmUserSettings;
+
+ public QmUserSettings LoadSettings()
+ {
+ if (qmUserSettings != null)
+ return qmUserSettings;
+
+ qmUserSettings = new QmUserSettings();
+ if (!File.Exists(SettingsFile))
+ return qmUserSettings;
+
+ var iniFile = new IniFile(SettingsFile);
+ var basicSection = iniFile.GetSection(BasicSectionKey);
+ if (basicSection == null)
+ return qmUserSettings;
+
+ qmUserSettings.AuthData = GetAuthData(basicSection);
+ qmUserSettings.Email = basicSection.GetStringValue(EmailKey, null);
+ qmUserSettings.Ladder = basicSection.GetStringValue(LadderKey, null);
+
+ return qmUserSettings;
+ }
+
+ private static QmAuthData GetAuthData(IniSection section)
+ {
+ if (!section.KeyExists(AuthDataKey))
+ return null;
+
+ string authDataValue = section.GetStringValue(AuthDataKey, null);
+ if (string.IsNullOrEmpty(authDataValue))
+ return null;
+
+ try
+ {
+ return JsonConvert.DeserializeObject(authDataValue);
+ }
+ catch (Exception e)
+ {
+ Logger.Log(e.StackTrace);
+ return null;
+ }
+ }
+
+ public void ClearAuthData() => qmUserSettings.AuthData = null;
+
+ public void SaveSettings()
+ {
+ var iniFile = new IniFile();
+ var basicSection = new IniSection(BasicSectionKey);
+ basicSection.AddKey(EmailKey, qmUserSettings.Email ?? string.Empty);
+ basicSection.AddKey(LadderKey, qmUserSettings.Ladder ?? string.Empty);
+ basicSection.AddKey(AuthDataKey, JsonConvert.SerializeObject(qmUserSettings.AuthData) ?? string.Empty);
+
+ iniFile.AddSection(basicSection);
+ iniFile.WriteIniFile(SettingsFile);
+ }
+ }
+}
diff --git a/DXMainClient/Domain/Multiplayer/MapLoader.cs b/DXMainClient/Domain/Multiplayer/MapLoader.cs
index 67020840f..8f1ab0b7a 100644
--- a/DXMainClient/Domain/Multiplayer/MapLoader.cs
+++ b/DXMainClient/Domain/Multiplayer/MapLoader.cs
@@ -21,6 +21,8 @@ public class MapLoader
private const string GameModeAliasesSection = "GameModeAliases";
private const int CurrentCustomMapCacheVersion = 1;
+ private static MapLoader Instance;
+
///
/// List of game modes.
///
@@ -46,6 +48,12 @@ public class MapLoader
///
private string[] AllowedGameModes = ClientConfiguration.Instance.AllowedCustomGameModes.Split(',');
+ private MapLoader()
+ {
+ }
+
+ public static MapLoader GetInstance() => Instance ?? (Instance = new MapLoader());
+
///
/// Loads multiplayer map info asynchonously.
///
@@ -331,5 +339,8 @@ private void AddMapToGameModes(Map map, bool enableLogging)
}
}
}
+
+ public Map GetMapForSHA(string sha1)
+ => GameModeMaps.Select(gmm => gmm.Map).FirstOrDefault(m => m.SHA1 == sha1);
}
}
diff --git a/Docs/INISystem.md b/Docs/INISystem.md
index 1269a1974..a40786983 100644
--- a/Docs/INISystem.md
+++ b/Docs/INISystem.md
@@ -7,8 +7,8 @@ The `[ParserConstants]` section of the `GlobalThemeSettings.ini` file contains c
### Predefined System Constants
-`RESOLUTION_WIDTH`: the width of the window when it is initialized
-`RESOLUTION_HEIGHT`: the height of the window when it is initialized
+`RESOLUTION_WIDTH`: the width of the window when it is initialized
+`RESOLUTION_HEIGHT`: the height of the window when it is initialized
### User Defined Constants
@@ -22,7 +22,7 @@ $X=MY_EXAMPLE_CONSTANT
```
_NOTE: Constants can only be used in [dynamic control properties](#dynamic-control-properties)_
-## Control properties:
+## Control properties:
Below lists basic and dynamic control properties. Ordering of properties is important. If there is a property that relies on the size of a control, the properties must set the size of that control first.
@@ -31,121 +31,121 @@ Basic control properties cannot use constants
#### XNAControl
-`X` = `{integer}` the X location of the control
-`Y` = `{integer}` the Y location of the control
-`Location` = `{comma separated integers}` the X and Y location of the control.
-`Width` = `{integer}` the Width of the control
-`Height` = `{integer}` the Height of the control
-`Size` = `{comma separated integers}` the Width and Height of the control.
-`Text` = `{string}` the text to display for the control (ex: buttons, labels, etc...)
-`Visible` = `{true/false or yes/no}` whether or not the control should be visible by default
-`Enabled` = `{true/false or yes/no}` whether or not the control should be enabled by default
-`DistanceFromRightBorder` = `{integer}` the distance of the right edge of this control from the right edge of its parent. This control MUST have a parent.
-`DistanceFromBottomBorder` = `{integer}` the distance of the bottom edge of this control from the bottom edge of its parent. This control MUST have a parent.
-`FillWidth` = `{integer}` this will set the width of this control to fill the parent/window MINUS this value, starting from the its X position
-`FillHeight` = `{integer}` this will set the height of this control to fill the parent/window MINUS this value, starting from the its Y position
-`DrawOrder`
-`UpdateOrder`
-`RemapColor`
+`X` = `{integer}` the X location of the control
+`Y` = `{integer}` the Y location of the control
+`Location` = `{comma separated integers}` the X and Y location of the control.
+`Width` = `{integer}` the Width of the control
+`Height` = `{integer}` the Height of the control
+`Size` = `{comma separated integers}` the Width and Height of the control.
+`Text` = `{string}` the text to display for the control (ex: buttons, labels, etc...)
+`Visible` = `{true/false or yes/no}` whether or not the control should be visible by default
+`Enabled` = `{true/false or yes/no}` whether or not the control should be enabled by default
+`DistanceFromRightBorder` = `{integer}` the distance of the right edge of this control from the right edge of its parent. This control MUST have a parent.
+`DistanceFromBottomBorder` = `{integer}` the distance of the bottom edge of this control from the bottom edge of its parent. This control MUST have a parent.
+`FillWidth` = `{integer}` this will set the width of this control to fill the parent/window MINUS this value, starting from the its X position
+`FillHeight` = `{integer}` this will set the height of this control to fill the parent/window MINUS this value, starting from the its Y position
+`DrawOrder`
+`UpdateOrder`
+`RemapColor`
#### XNAPanel
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`BorderColor`
-`DrawMode`
-`AlphaRate`
-`BackgroundTexture`
-`SolidColorBackgroundTexture`
-`DrawBorders`
-`Padding`
+`BorderColor`
+`DrawMode`
+`AlphaRate`
+`BackgroundTexture`
+`SolidColorBackgroundTexture`
+`DrawBorders`
+`Padding`
#### XNAExtraPanel
-_(inherits XNAPanel)_
+_(inherits [XNAPanel](#xnapanel))_
-`BackgroundTexture`
+`BackgroundTexture`
#### XNALabel
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`RemapColor`
-`TextColor`
-`FontIndex`
-`AnchorPoint`
-`TextAnchor`
-`TextShadowDistance`
+`RemapColor`
+`TextColor`
+`FontIndex`
+`AnchorPoint`
+`TextAnchor`
+`TextShadowDistance`
#### XNAButton
-_(inherits XNAControl)_
-
-`TextColorIdle`
-`TextColorHover`
-`HoverSoundEffect`
-`ClickSoundEffect`
-`AdaptiveText`
-`AlphaRate`
-`FontIndex`
-`IdleTexture`
-`HoverTexture`
-`TextShadowDistance`
+_(inherits [XNAControl](#xnacontrol))_
+
+`TextColorIdle`
+`TextColorHover`
+`HoverSoundEffect`
+`ClickSoundEffect`
+`AdaptiveText`
+`AlphaRate`
+`FontIndex`
+`IdleTexture`
+`HoverTexture`
+`TextShadowDistance`
#### XNAClientButton
-_(inherits XNAButton)_
+_(inherits [XNAButton](#xnabutton))_
-`MatchTextureSize`
+`MatchTextureSize`
#### XNALinkButton
-_(inherits XNAClientButton)_
+_(inherits [XNAClientButton](#xnaclientbutton))_
-`URL`
-`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
+`URL`
+`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
#### XNACheckbox
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`FontIndex`
-`IdleColor`
-`HighlightColor`
-`AlphaRate`
-`AllowChecking`
-`Checked`
+`FontIndex`
+`IdleColor`
+`HighlightColor`
+`AlphaRate`
+`AllowChecking`
+`Checked`
#### XNAClientCheckbox
-_(inherits XNACheckBox)_
+_(inherits [XNACheckBox](#xnacheckbox))_
`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
#### XNADropDown
-_(inherits XNAControl)_
-
-`OpenUp`
-`DropDownTexture`
-`DropDownOpenTexture`
-`ItemHeight`
-`ClickSoundEffect`
-`FontIndex`
-`BorderColor`
-`FocusColor`
-`BackColor`
-`~~DisabledItemColor~~`
-`OptionN`
+_(inherits [XNAControl](#xnacontrol))_
+
+`OpenUp`
+`DropDownTexture`
+`DropDownOpenTexture`
+`ItemHeight`
+`ClickSoundEffect`
+`FontIndex`
+`BorderColor`
+`FocusColor`
+`BackColor`
+`~~DisabledItemColor~~`
+`OptionN`
#### XNAClientDropDown
-_(inherits XNADropDown)_
+_(inherits [XNADropDown](#xnadropdown))_
-`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
+`ToolTip` = {string} tooltip for checkbox. '@' can be used for newlines
#### XNATabControl
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`RemapColor`
-`TextColor`
-`TextColorDisabled`
-`RemoveTabIndexN`
+`RemapColor`
+`TextColor`
+`TextColorDisabled`
+`RemoveTabIndexN`
#### XNATextBox
-_(inherits XNAControl)_
+_(inherits [XNAControl](#xnacontrol))_
-`MaximumTextLength`
+`MaximumTextLength`
### Basic Control Property Examples
```
@@ -176,49 +176,49 @@ Following controls are only available as children of `XNAOptionsPanel` and deriv
##### SettingCheckBox
_(inherits XNAClientCheckBox)_
-`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
-`WriteSettingValue` = `{true/false or yes/no}` enable to write a specific string value to setting INI key instead of the checked state of the checkbox. Defaults to `false`.
-`EnabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is checked.
-`DisabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is not checked.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
-`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
-`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
+`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
+`WriteSettingValue` = `{true/false or yes/no}` enable to write a specific string value to setting INI key instead of the checked state of the checkbox. Defaults to `false`.
+`EnabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is checked.
+`DisabledSettingValue` = `{string}` value to write to setting INI key if `WriteSettingValue` is set and checkbox is not checked.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
+`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
##### FileSettingCheckBox
_(inherits XNAClientCheckBox)_
-`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
-`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
-`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
-`CheckAvailability` = `{true/false or yes/no}` if set, whether or not the checkbox can be (un)checked depends on if the files to copy are actually present. Defaults to `false`.
-`ResetUnavailableValue` = `{true/false or yes/no}` if set together with `CheckAvailability`, checkbox set to a value that is unavailable will be reset back to `DefaultValue`. Defaults to `false`.
-`EnabledFileN` = `{comma-separated strings}` files to copy if checkbox is checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
-`DisabledFileN` = `{comma-separated strings}` files to copy if checkbox is not checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
+`DefaultValue` = `{true/false or yes/no}` default state of the checkbox. Value of `Checked` will be used if it is set and this isn't. Otherwise defaults to `false`.
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_Checked`.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`ParentCheckBoxName` = `{string}` name of a `XNAClientCheckBox` control to use as a parent checkbox that is required to either be checked or unchecked, depending on value of `ParentCheckBoxRequiredValue` for this checkbox to be enabled. Only works if name can be resolved to an existing control belonging to same parent as current checkbox.
+`ParentCheckBoxRequiredValue` = `{true/false or yes/no}` state required from the parent checkbox for this one to be enabled. Defaults to `true`.
+`CheckAvailability` = `{true/false or yes/no}` if set, whether or not the checkbox can be (un)checked depends on if the files to copy are actually present. Defaults to `false`.
+`ResetUnavailableValue` = `{true/false or yes/no}` if set together with `CheckAvailability`, checkbox set to a value that is unavailable will be reset back to `DefaultValue`. Defaults to `false`.
+`EnabledFileN` = `{comma-separated strings}` files to copy if checkbox is checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
+`DisabledFileN` = `{comma-separated strings}` files to copy if checkbox is not checked. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
##### SettingDropDown
_(inherits XNAClientDropDown)_
-`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
-`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_SelectedIndex`.
-`WriteSettingValue` = `{true/false or yes/no}` enable to write selected item value to the setting INI key instead of the checked state of the checkbox. Defaults to `false`.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
+`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_Value` if `WriteSettingValue` is set, otherwise `CONTROLNAME_SelectedIndex`.
+`WriteSettingValue` = `{true/false or yes/no}` enable to write selected item value to the setting INI key instead of the checked state of the checkbox. Defaults to `false`.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
##### FileSettingDropDown
_(inherits XNAClientDropDown)_
-`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
-`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
-`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
-`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_SelectedIndex`.
-`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
-`ItemXFileN` = `{comma-separated strings}` files to copy when dropdown item X is selected. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
+`Items` = `{comma-separated strings}` comma-separated list of strings to include as items to display on the dropdown control.
+`DefaultValue` = `{integer}` default item index of the dropdown. Defaults to 0 (first item).
+`SettingSection` = `{string}` name of the section in settings INI the setting is saved to. Defaults to `CustomSettings`.
+`SettingKey` = `{string}` name of the key in settings INI the setting is saved to. Defaults to `CONTROLNAME_SelectedIndex`.
+`RestartRequired` = `{true/false or yes/no}` whether or not this setting requires restarting the client to apply. Defaults to `false`.
+`ItemXFileN` = `{comma-separated strings}` files to copy when dropdown item X is selected. N starts from 0 and is incremented by 1 until no value is found. Expects 2-3 comma-separated strings in following format: source path relative to game root folder, destination path relative to game root folder and a [file operation option](#appendix-file-operation-options).
##### Appendix: File Operation Options
@@ -234,11 +234,11 @@ Dynamic Control Properties CAN use constants
These can ONLY be used in parent controls that inherit the `INItializableWindow` class
-`$X` = ``{integer}`` the X location of the control
-`$Y` = ``{integer}`` the Y location of the control
-`$Width` = ``{integer}`` the Width of the control
-`$Height` = ``{integer}`` the Height of the control
-`$TextAnchor`
+`$X` = ``{integer}`` the X location of the control
+`$Y` = ``{integer}`` the Y location of the control
+`$Width` = ``{integer}`` the Width of the control
+`$Height` = ``{integer}`` the Height of the control
+`$TextAnchor`
### Dynamic Control Property Examples
```