diff --git a/OpenRA.Mods.Common/OpenRA.Mods.Common.csproj b/OpenRA.Mods.Common/OpenRA.Mods.Common.csproj index 933a24b273..d3291d3e3d 100644 --- a/OpenRA.Mods.Common/OpenRA.Mods.Common.csproj +++ b/OpenRA.Mods.Common/OpenRA.Mods.Common.csproj @@ -836,6 +836,7 @@ + diff --git a/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs index fb410b43f9..084e84bd8d 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs @@ -10,15 +10,7 @@ #endregion using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Net; -using System.Text; -using BeaconLib; using OpenRA.Network; -using OpenRA.Server; -using OpenRA.Traits; using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic @@ -27,246 +19,20 @@ namespace OpenRA.Mods.Common.Widgets.Logic { static readonly Action DoNothing = () => { }; - readonly Color incompatibleVersionColor; - readonly Color incompatibleProtectedGameColor; - readonly Color protectedGameColor; - readonly Color incompatibleWaitingGameColor; - readonly Color waitingGameColor; - readonly Color incompatibleGameStartedColor; - readonly Color gameStartedColor; - readonly Color incompatibleGameColor; - readonly ModData modData; - readonly WebServices services; - readonly Probe lanGameProbe; - - readonly Widget serverList; - readonly ScrollItemWidget serverTemplate; - readonly ScrollItemWidget headerTemplate; - readonly Widget noticeContainer; - readonly Widget clientContainer; - readonly ScrollPanelWidget clientList; - readonly ScrollItemWidget clientTemplate, clientHeader; - readonly MapPreviewWidget mapPreview; - readonly ButtonWidget joinButton; - readonly int joinButtonY; - - GameServer currentServer; - MapPreview currentMap; - bool showNotices; - - Action onStart; - Action onExit; - - enum SearchStatus { Fetching, Failed, NoGames, Hidden } - - SearchStatus searchStatus = SearchStatus.Fetching; - - Download currentQuery; - IEnumerable lanGameLocations; - - public string ProgressLabelText() - { - switch (searchStatus) - { - case SearchStatus.Failed: return "Failed to query server list."; - case SearchStatus.NoGames: return "No games found. Try changing filters."; - default: return ""; - } - } + readonly Action onStart; + readonly Action onExit; + readonly ServerListLogic serverListLogic; [ObjectCreator.UseCtor] public MultiplayerLogic(Widget widget, ModData modData, Action onStart, Action onExit, string directConnectHost, int directConnectPort) { - this.modData = modData; + // MultiplayerLogic is a superset of the ServerListLogic + // but cannot be a direct subclass because it needs to pass object-level state to the constructor + serverListLogic = new ServerListLogic(widget, modData, Join); + this.onStart = onStart; this.onExit = onExit; - services = modData.Manifest.Get(); - - incompatibleVersionColor = ChromeMetrics.Get("IncompatibleVersionColor"); - incompatibleGameColor = ChromeMetrics.Get("IncompatibleGameColor"); - incompatibleProtectedGameColor = ChromeMetrics.Get("IncompatibleProtectedGameColor"); - protectedGameColor = ChromeMetrics.Get("ProtectedGameColor"); - waitingGameColor = ChromeMetrics.Get("WaitingGameColor"); - incompatibleWaitingGameColor = ChromeMetrics.Get("IncompatibleWaitingGameColor"); - gameStartedColor = ChromeMetrics.Get("GameStartedColor"); - incompatibleGameStartedColor = ChromeMetrics.Get("IncompatibleGameStartedColor"); - - serverList = widget.Get("SERVER_LIST"); - headerTemplate = serverList.Get("HEADER_TEMPLATE"); - serverTemplate = serverList.Get("SERVER_TEMPLATE"); - - noticeContainer = widget.GetOrNull("NOTICE_CONTAINER"); - if (noticeContainer != null) - { - noticeContainer.IsVisible = () => showNotices; - noticeContainer.Get("OUTDATED_VERSION_LABEL").IsVisible = () => services.ModVersionStatus == ModVersionStatus.Outdated; - noticeContainer.Get("UNKNOWN_VERSION_LABEL").IsVisible = () => services.ModVersionStatus == ModVersionStatus.Unknown; - noticeContainer.Get("PLAYTEST_AVAILABLE_LABEL").IsVisible = () => services.ModVersionStatus == ModVersionStatus.PlaytestAvailable; - } - - var noticeWatcher = widget.Get("NOTICE_WATCHER"); - if (noticeWatcher != null && noticeContainer != null) - { - var containerHeight = noticeContainer.Bounds.Height; - noticeWatcher.OnTick = () => - { - var show = services.ModVersionStatus != ModVersionStatus.NotChecked && services.ModVersionStatus != ModVersionStatus.Latest; - if (show != showNotices) - { - var dir = show ? 1 : -1; - serverList.Bounds.Y += dir * containerHeight; - serverList.Bounds.Height -= dir * containerHeight; - showNotices = show; - } - }; - } - - joinButton = widget.Get("JOIN_BUTTON"); - joinButton.IsVisible = () => currentServer != null; - joinButton.IsDisabled = () => !currentServer.IsJoinable; - joinButton.OnClick = () => Join(currentServer); - joinButtonY = joinButton.Bounds.Y; - - // Display the progress label over the server list - // The text is only visible when the list is empty - var progressText = widget.Get("PROGRESS_LABEL"); - progressText.IsVisible = () => searchStatus != SearchStatus.Hidden; - progressText.GetText = ProgressLabelText; - - var gs = Game.Settings.Game; - Action toggleFilterFlag = f => - { - gs.MPGameFilters ^= f; - Game.Settings.Save(); - RefreshServerList(); - }; - - var filtersPanel = Ui.LoadWidget("MULTIPLAYER_FILTER_PANEL", null, new WidgetArgs()); - var showWaitingCheckbox = filtersPanel.GetOrNull("WAITING_FOR_PLAYERS"); - if (showWaitingCheckbox != null) - { - showWaitingCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Waiting); - showWaitingCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Waiting); - } - - var showEmptyCheckbox = filtersPanel.GetOrNull("EMPTY"); - if (showEmptyCheckbox != null) - { - showEmptyCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Empty); - showEmptyCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Empty); - } - - var showAlreadyStartedCheckbox = filtersPanel.GetOrNull("ALREADY_STARTED"); - if (showAlreadyStartedCheckbox != null) - { - showAlreadyStartedCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Started); - showAlreadyStartedCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Started); - } - - var showProtectedCheckbox = filtersPanel.GetOrNull("PASSWORD_PROTECTED"); - if (showProtectedCheckbox != null) - { - showProtectedCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Protected); - showProtectedCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Protected); - } - - var showIncompatibleCheckbox = filtersPanel.GetOrNull("INCOMPATIBLE_VERSION"); - if (showIncompatibleCheckbox != null) - { - showIncompatibleCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Incompatible); - showIncompatibleCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Incompatible); - } - - var filtersButton = widget.GetOrNull("FILTERS_DROPDOWNBUTTON"); - if (filtersButton != null) - { - filtersButton.IsDisabled = () => searchStatus == SearchStatus.Fetching; - filtersButton.OnMouseDown = _ => - { - filtersButton.RemovePanel(); - filtersButton.AttachPanel(filtersPanel); - }; - } - - var reloadButton = widget.GetOrNull("RELOAD_BUTTON"); - if (reloadButton != null) - { - reloadButton.IsDisabled = () => searchStatus == SearchStatus.Fetching; - reloadButton.OnClick = RefreshServerList; - - var reloadIcon = reloadButton.GetOrNull("IMAGE_RELOAD"); - if (reloadIcon != null) - { - var disabledFrame = 0; - var disabledImage = "disabled-" + disabledFrame.ToString(); - reloadIcon.GetImageName = () => searchStatus == SearchStatus.Fetching ? disabledImage : reloadIcon.ImageName; - - var reloadTicker = reloadIcon.Get("ANIMATION"); - if (reloadTicker != null) - { - reloadTicker.OnTick = () => - { - disabledFrame = searchStatus == SearchStatus.Fetching ? (disabledFrame + 1) % 12 : 0; - disabledImage = "disabled-" + disabledFrame.ToString(); - }; - } - } - } - - mapPreview = widget.GetOrNull("SELECTED_MAP_PREVIEW"); - if (mapPreview != null) - mapPreview.Preview = () => currentMap; - - var mapTitle = widget.GetOrNull("SELECTED_MAP"); - if (mapTitle != null) - { - var font = Game.Renderer.Fonts[mapTitle.Font]; - var title = new CachedTransform(m => m == null ? "No Server Selected" : - WidgetUtils.TruncateText(m.Title, mapTitle.Bounds.Width, font)); - mapTitle.GetText = () => title.Update(currentMap); - } - - var ip = widget.GetOrNull("SELECTED_IP"); - if (ip != null) - { - ip.IsVisible = () => currentServer != null; - ip.GetText = () => currentServer.Address; - } - - var status = widget.GetOrNull("SELECTED_STATUS"); - if (status != null) - { - status.IsVisible = () => currentServer != null; - status.GetText = () => GetStateLabel(currentServer); - status.GetColor = () => GetStateColor(currentServer, status); - } - - var modVersion = widget.GetOrNull("SELECTED_MOD_VERSION"); - if (modVersion != null) - { - modVersion.IsVisible = () => currentServer != null; - modVersion.GetColor = () => currentServer.IsCompatible ? modVersion.TextColor : incompatibleVersionColor; - - var font = Game.Renderer.Fonts[modVersion.Font]; - var version = new CachedTransform(s => WidgetUtils.TruncateText(s.ModLabel, mapTitle.Bounds.Width, font)); - modVersion.GetText = () => version.Update(currentServer); - } - - var players = widget.GetOrNull("SELECTED_PLAYERS"); - if (players != null) - { - players.IsVisible = () => currentServer != null && !currentServer.Clients.Any(); - players.GetText = () => PlayersLabel(currentServer); - } - - clientContainer = widget.Get("CLIENT_LIST_CONTAINER"); - clientList = Ui.LoadWidget("MULTIPLAYER_CLIENT_LIST", clientContainer, new WidgetArgs()) as ScrollPanelWidget; - clientList.IsVisible = () => currentServer != null && currentServer.Clients.Any(); - clientHeader = clientList.Get("HEADER"); - clientTemplate = clientList.Get("TEMPLATE"); - clientList.RemoveChildren(); - var directConnectButton = widget.Get("DIRECTCONNECT_BUTTON"); directConnectButton.OnClick = () => { @@ -291,20 +57,6 @@ namespace OpenRA.Mods.Common.Widgets.Logic widget.Get("BACK_BUTTON").OnClick = () => { Ui.CloseWindow(); onExit(); }; - lanGameLocations = new List(); - try - { - lanGameProbe = new Probe("OpenRALANGame"); - lanGameProbe.BeaconsUpdated += locations => lanGameLocations = locations; - lanGameProbe.Start(); - } - catch (Exception ex) - { - Log.Write("debug", "BeaconLib.Probe: " + ex.Message); - } - - RefreshServerList(); - if (directConnectHost != null) { // The connection window must be opened at the end of the tick for the widget hierarchy to @@ -325,346 +77,6 @@ namespace OpenRA.Mods.Common.Widgets.Logic } } - string PlayersLabel(GameServer game) - { - return "{0}{1}{2}".F( - "{0} Player{1}".F(game.Players > 0 ? game.Players.ToString() : "No", game.Players != 1 ? "s" : ""), - game.Bots > 0 ? ", {0} Bot{1}".F(game.Bots, game.Bots != 1 ? "s" : "") : "", - game.Spectators > 0 ? ", {0} Spectator{1}".F(game.Spectators, game.Spectators != 1 ? "s" : "") : ""); - } - - void RefreshServerList() - { - // Query in progress - if (currentQuery != null) - return; - - searchStatus = SearchStatus.Fetching; - - Action onComplete = i => - { - currentQuery = null; - - List games = null; - if (i.Error == null) - { - try - { - var data = Encoding.UTF8.GetString(i.Result); - var yaml = MiniYaml.FromString(data); - - games = yaml.Select(a => new GameServer(a.Value)) - .Where(gs => gs.Address != null) - .ToList(); - } - catch - { - searchStatus = SearchStatus.Failed; - } - } - - var lanGames = new List(); - foreach (var bl in lanGameLocations) - { - var game = MiniYaml.FromString(bl.Data)[0].Value; - var idNode = game.Nodes.FirstOrDefault(n => n.Key == "Id"); - - // Skip beacons created by this instance and replace Id by expected int value - if (idNode != null && idNode.Value.Value != Platform.SessionGUID.ToString()) - { - idNode.Value.Value = "-1"; - - // Rewrite the server address with the correct IP - var addressNode = game.Nodes.FirstOrDefault(n => n.Key == "Address"); - if (addressNode != null) - addressNode.Value.Value = bl.Address.ToString().Split(':')[0] + ":" + addressNode.Value.Value.Split(':')[1]; - - try - { - lanGames.Add(new GameServer(game)); - } - catch { } - } - } - - var groupedLanGames = lanGames.GroupBy(gs => gs.Address).Select(g => g.Last()); - if (games != null) - games.AddRange(groupedLanGames); - else if (groupedLanGames.Any()) - games = groupedLanGames.ToList(); - - Game.RunAfterTick(() => RefreshServerListInner(games)); - }; - - var queryURL = services.ServerList + "?protocol={0}&engine={1}&mod={2}&version={3}".F( - GameServer.ProtocolVersion, - Uri.EscapeUriString(Game.EngineVersion), - Uri.EscapeUriString(Game.ModData.Manifest.Id), - Uri.EscapeUriString(Game.ModData.Manifest.Metadata.Version)); - - currentQuery = new Download(queryURL, _ => { }, onComplete); - } - - int GroupSortOrder(GameServer testEntry) - { - // Games that we can't join are sorted last - if (!testEntry.IsCompatible) - return testEntry.Mod == modData.Manifest.Id ? 1 : 0; - - // Games for the current mod+version are sorted first - if (testEntry.Mod == modData.Manifest.Id) - return testEntry.Version == modData.Manifest.Metadata.Version ? 4 : 3; - - // Followed by games for different mods that are joinable - return 2; - } - - void SelectServer(GameServer server) - { - currentServer = server; - currentMap = server != null ? modData.MapCache[server.Map] : null; - - clientList.RemoveChildren(); - if (server == null || !server.Clients.Any()) - { - joinButton.Bounds.Y = joinButtonY; - return; - } - - joinButton.Bounds.Y = clientContainer.Bounds.Bottom; - - var players = server.Clients - .Where(c => !c.IsSpectator) - .GroupBy(p => p.Team) - .OrderBy(g => g.Key); - - var teams = new Dictionary>(); - var noTeams = players.Count() == 1; - foreach (var p in players) - { - var label = noTeams ? "Players" : p.Key == 0 ? "No Team" : "Team {0}".F(p.Key); - teams.Add(label, p); - } - - if (server.Clients.Any(c => c.IsSpectator)) - teams.Add("Spectators", server.Clients.Where(c => c.IsSpectator)); - - // Can only show factions if the server is running the same mod - var disableFactionDisplay = server.Mod != modData.Manifest.Id; - - if (mapPreview != null) - { - var spawns = currentMap.SpawnPoints; - var occupants = server.Clients - .Where(c => (c.SpawnPoint - 1 >= 0) && (c.SpawnPoint - 1 < spawns.Length)) - .ToDictionary(c => spawns[c.SpawnPoint - 1], c => new SpawnOccupant(c, disableFactionDisplay)); - - mapPreview.SpawnOccupants = () => occupants; - } - - var factionInfo = modData.DefaultRules.Actors["world"].TraitInfos(); - foreach (var kv in teams) - { - var group = kv.Key; - if (group.Length > 0) - { - var header = ScrollItemWidget.Setup(clientHeader, () => true, () => { }); - header.Get("LABEL").GetText = () => group; - clientList.AddChild(header); - } - - foreach (var option in kv.Value) - { - var o = option; - - var item = ScrollItemWidget.Setup(clientTemplate, () => false, () => { }); - if (!o.IsSpectator && !disableFactionDisplay) - { - var label = item.Get("LABEL"); - var font = Game.Renderer.Fonts[label.Font]; - var name = WidgetUtils.TruncateText(o.Name, label.Bounds.Width, font); - label.GetText = () => name; - label.GetColor = () => o.Color.RGB; - - var flag = item.Get("FLAG"); - flag.IsVisible = () => true; - flag.GetImageCollection = () => "flags"; - flag.GetImageName = () => (factionInfo != null && factionInfo.Any(f => f.InternalName == o.Faction)) ? o.Faction : "Random"; - } - else - { - var label = item.Get("NOFLAG_LABEL"); - var font = Game.Renderer.Fonts[label.Font]; - var name = WidgetUtils.TruncateText(o.Name, label.Bounds.Width, font); - label.GetText = () => name; - label.GetColor = () => o.Color.RGB; - } - - clientList.AddChild(item); - } - } - } - - void RefreshServerListInner(List games) - { - ScrollItemWidget nextServerRow = null; - List rows = null; - - if (games != null) - rows = LoadGameRows(games, out nextServerRow); - - Game.RunAfterTick(() => - { - serverList.RemoveChildren(); - SelectServer(null); - - if (games == null) - { - searchStatus = SearchStatus.Failed; - return; - } - - if (!rows.Any()) - { - searchStatus = SearchStatus.NoGames; - return; - } - - searchStatus = SearchStatus.Hidden; - - // Search for any unknown maps - if (Game.Settings.Game.AllowDownloading) - modData.MapCache.QueryRemoteMapDetails(services.MapRepository, games.Where(g => !Filtered(g)).Select(g => g.Map)); - - foreach (var row in rows) - serverList.AddChild(row); - - if (nextServerRow != null) - nextServerRow.OnClick(); - }); - } - - List LoadGameRows(List games, out ScrollItemWidget nextServerRow) - { - nextServerRow = null; - var rows = new List(); - var mods = games.GroupBy(g => g.ModLabel) - .OrderByDescending(g => GroupSortOrder(g.First())) - .ThenByDescending(g => g.Count()); - - foreach (var modGames in mods) - { - if (modGames.All(Filtered)) - continue; - - var header = ScrollItemWidget.Setup(headerTemplate, () => true, () => { }); - - var headerTitle = modGames.First().ModLabel; - header.Get("LABEL").GetText = () => headerTitle; - rows.Add(header); - - Func listOrder = g => - { - // Servers waiting for players are always first - if (g.State == (int)ServerState.WaitingPlayers && g.Players > 0) - return 0; - - // Then servers with spectators - if (g.State == (int)ServerState.WaitingPlayers && g.Spectators > 0) - return 1; - - // Then active games - if (g.State >= (int)ServerState.GameStarted) - return 2; - - // Empty servers are shown at the end because a flood of empty servers - // at the top of the game list make the community look dead - return 3; - }; - - foreach (var modGamesByState in modGames.GroupBy(listOrder).OrderBy(g => g.Key)) - { - // Sort 'Playing' games by Started, others by number of players - foreach (var game in modGamesByState.Key == 2 ? modGamesByState.OrderByDescending(g => g.Started) : modGamesByState.OrderByDescending(g => g.Players)) - { - if (Filtered(game)) - continue; - - var canJoin = game.IsJoinable; - var item = ScrollItemWidget.Setup(serverTemplate, () => currentServer == game, () => SelectServer(game), () => Join(game)); - var title = item.GetOrNull("TITLE"); - if (title != null) - { - var font = Game.Renderer.Fonts[title.Font]; - var label = WidgetUtils.TruncateText(game.Name, title.Bounds.Width, font); - title.GetText = () => label; - title.GetColor = () => canJoin ? title.TextColor : incompatibleGameColor; - } - - var password = item.GetOrNull("PASSWORD_PROTECTED"); - if (password != null) - { - password.IsVisible = () => game.Protected; - password.GetImageName = () => canJoin ? "protected" : "protected-disabled"; - } - - var players = item.GetOrNull("PLAYERS"); - if (players != null) - { - var label = "{0} / {1}".F(game.Players + game.Bots, game.MaxPlayers + game.Bots) - + (game.Spectators > 0 ? " + {0}".F(game.Spectators) : ""); - - var color = canJoin ? players.TextColor : incompatibleGameColor; - players.GetText = () => label; - players.GetColor = () => color; - - if (game.Clients.Any()) - { - var displayClients = game.Clients.Select(c => c.Name); - if (game.Clients.Length > 10) - displayClients = displayClients - .Take(9) - .Append("+ {0} other players".F(game.Clients.Length - 9)); - - var tooltip = displayClients.JoinWith("\n"); - players.GetTooltipText = () => tooltip; - } - else - players.GetTooltipText = null; - } - - var state = item.GetOrNull("STATUS"); - if (state != null) - { - var label = game.State >= (int)ServerState.GameStarted ? - "Playing" : "Waiting"; - state.GetText = () => label; - - var color = GetStateColor(game, state, !canJoin); - state.GetColor = () => color; - } - - var location = item.GetOrNull("LOCATION"); - if (location != null) - { - var font = Game.Renderer.Fonts[location.Font]; - var cachedServerLocation = game.Id != -1 ? GeoIP.LookupCountry(game.Address.Split(':')[0]) : "Local Network"; - var label = WidgetUtils.TruncateText(cachedServerLocation, location.Bounds.Width, font); - location.GetText = () => label; - location.GetColor = () => canJoin ? location.TextColor : incompatibleGameColor; - } - - if (currentServer != null && game.Address == currentServer.Address) - nextServerRow = item; - - rows.Add(item); - } - } - } - - return rows; - } - void OpenLobby() { // Close the multiplayer browser @@ -703,76 +115,13 @@ namespace OpenRA.Mods.Common.Widgets.Logic ConnectionLogic.Connect(host, port, "", OpenLobby, DoNothing); } - static string GetStateLabel(GameServer game) - { - if (game == null) - return ""; - - if (game.State == (int)ServerState.GameStarted) - { - var label = "In progress"; - - if (game.PlayTime > 0) - { - var totalMinutes = Math.Ceiling(game.PlayTime / 60.0); - label += " for {0} minute{1}".F(totalMinutes, totalMinutes > 1 ? "s" : ""); - } - - return label; - } - - if (game.State == (int)ServerState.WaitingPlayers) - return game.Protected ? "Password protected" : "Waiting for players"; - - if (game.State == (int)ServerState.ShuttingDown) - return "Server shutting down"; - - return "Unknown server state"; - } - - Color GetStateColor(GameServer game, LabelWidget label, bool darkened = false) - { - if (!game.Protected && game.State == (int)ServerState.WaitingPlayers) - return darkened ? incompatibleWaitingGameColor : waitingGameColor; - - if (game.Protected && game.State == (int)ServerState.WaitingPlayers) - return darkened ? incompatibleProtectedGameColor : protectedGameColor; - - if (game.State == (int)ServerState.GameStarted) - return darkened ? incompatibleGameStartedColor : gameStartedColor; - - return label.TextColor; - } - - bool Filtered(GameServer game) - { - var filters = Game.Settings.Game.MPGameFilters; - if (game.State == (int)ServerState.GameStarted && !filters.HasFlag(MPGameFilters.Started)) - return true; - - if (game.State == (int)ServerState.WaitingPlayers && !filters.HasFlag(MPGameFilters.Waiting) && game.Players != 0) - return true; - - if ((game.Players + game.Spectators) == 0 && !filters.HasFlag(MPGameFilters.Empty)) - return true; - - if (!game.IsCompatible && !filters.HasFlag(MPGameFilters.Incompatible)) - return true; - - if (game.Protected && !filters.HasFlag(MPGameFilters.Protected)) - return true; - - return false; - } - bool disposed; protected override void Dispose(bool disposing) { if (disposing && !disposed) { disposed = true; - if (lanGameProbe != null) - lanGameProbe.Dispose(); + serverListLogic.Dispose(); } base.Dispose(disposing); diff --git a/OpenRA.Mods.Common/Widgets/Logic/ServerListLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/ServerListLogic.cs new file mode 100644 index 0000000000..d04940495c --- /dev/null +++ b/OpenRA.Mods.Common/Widgets/Logic/ServerListLogic.cs @@ -0,0 +1,714 @@ +#region Copyright & License Information +/* + * Copyright 2007-2017 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Net; +using System.Text; +using BeaconLib; +using OpenRA.Network; +using OpenRA.Server; +using OpenRA.Traits; +using OpenRA.Widgets; + +namespace OpenRA.Mods.Common.Widgets.Logic +{ + public class ServerListLogic : ChromeLogic + { + readonly Color incompatibleVersionColor; + readonly Color incompatibleProtectedGameColor; + readonly Color protectedGameColor; + readonly Color incompatibleWaitingGameColor; + readonly Color waitingGameColor; + readonly Color incompatibleGameStartedColor; + readonly Color gameStartedColor; + readonly Color incompatibleGameColor; + readonly ModData modData; + readonly WebServices services; + readonly Probe lanGameProbe; + + readonly Widget serverList; + readonly ScrollItemWidget serverTemplate; + readonly ScrollItemWidget headerTemplate; + readonly Widget noticeContainer; + readonly Widget clientContainer; + readonly ScrollPanelWidget clientList; + readonly ScrollItemWidget clientTemplate, clientHeader; + readonly MapPreviewWidget mapPreview; + readonly ButtonWidget joinButton; + readonly int joinButtonY; + + readonly Action onJoin; + + GameServer currentServer; + MapPreview currentMap; + bool showNotices; + + enum SearchStatus { Fetching, Failed, NoGames, Hidden } + + SearchStatus searchStatus = SearchStatus.Fetching; + + Download currentQuery; + IEnumerable lanGameLocations; + + public string ProgressLabelText() + { + switch (searchStatus) + { + case SearchStatus.Failed: return "Failed to query server list."; + case SearchStatus.NoGames: return "No games found. Try changing filters."; + default: return ""; + } + } + + [ObjectCreator.UseCtor] + public ServerListLogic(Widget widget, ModData modData, Action onJoin) + { + this.modData = modData; + this.onJoin = onJoin; + + services = modData.Manifest.Get(); + + incompatibleVersionColor = ChromeMetrics.Get("IncompatibleVersionColor"); + incompatibleGameColor = ChromeMetrics.Get("IncompatibleGameColor"); + incompatibleProtectedGameColor = ChromeMetrics.Get("IncompatibleProtectedGameColor"); + protectedGameColor = ChromeMetrics.Get("ProtectedGameColor"); + waitingGameColor = ChromeMetrics.Get("WaitingGameColor"); + incompatibleWaitingGameColor = ChromeMetrics.Get("IncompatibleWaitingGameColor"); + gameStartedColor = ChromeMetrics.Get("GameStartedColor"); + incompatibleGameStartedColor = ChromeMetrics.Get("IncompatibleGameStartedColor"); + + serverList = widget.Get("SERVER_LIST"); + headerTemplate = serverList.Get("HEADER_TEMPLATE"); + serverTemplate = serverList.Get("SERVER_TEMPLATE"); + + noticeContainer = widget.GetOrNull("NOTICE_CONTAINER"); + if (noticeContainer != null) + { + noticeContainer.IsVisible = () => showNotices; + noticeContainer.Get("OUTDATED_VERSION_LABEL").IsVisible = () => services.ModVersionStatus == ModVersionStatus.Outdated; + noticeContainer.Get("UNKNOWN_VERSION_LABEL").IsVisible = () => services.ModVersionStatus == ModVersionStatus.Unknown; + noticeContainer.Get("PLAYTEST_AVAILABLE_LABEL").IsVisible = () => services.ModVersionStatus == ModVersionStatus.PlaytestAvailable; + } + + var noticeWatcher = widget.Get("NOTICE_WATCHER"); + if (noticeWatcher != null && noticeContainer != null) + { + var containerHeight = noticeContainer.Bounds.Height; + noticeWatcher.OnTick = () => + { + var show = services.ModVersionStatus != ModVersionStatus.NotChecked && services.ModVersionStatus != ModVersionStatus.Latest; + if (show != showNotices) + { + var dir = show ? 1 : -1; + serverList.Bounds.Y += dir * containerHeight; + serverList.Bounds.Height -= dir * containerHeight; + showNotices = show; + } + }; + } + + joinButton = widget.GetOrNull("JOIN_BUTTON"); + if (joinButton != null) + { + joinButton.IsVisible = () => currentServer != null; + joinButton.IsDisabled = () => !currentServer.IsJoinable; + joinButton.OnClick = () => onJoin(currentServer); + joinButtonY = joinButton.Bounds.Y; + } + + // Display the progress label over the server list + // The text is only visible when the list is empty + var progressText = widget.Get("PROGRESS_LABEL"); + progressText.IsVisible = () => searchStatus != SearchStatus.Hidden; + progressText.GetText = ProgressLabelText; + + var gs = Game.Settings.Game; + Action toggleFilterFlag = f => + { + gs.MPGameFilters ^= f; + Game.Settings.Save(); + RefreshServerList(); + }; + + var filtersButton = widget.GetOrNull("FILTERS_DROPDOWNBUTTON"); + if (filtersButton != null) + { + // HACK: MULTIPLAYER_FILTER_PANEL doesn't follow our normal procedure for dropdown creation + // but we still need to be able to set the dropdown width based on the parent + // The yaml should use PARENT_RIGHT instead of DROPDOWN_WIDTH + var filtersPanel = Ui.LoadWidget("MULTIPLAYER_FILTER_PANEL", filtersButton, new WidgetArgs()); + filtersButton.Children.Remove(filtersPanel); + + var showWaitingCheckbox = filtersPanel.GetOrNull("WAITING_FOR_PLAYERS"); + if (showWaitingCheckbox != null) + { + showWaitingCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Waiting); + showWaitingCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Waiting); + } + + var showEmptyCheckbox = filtersPanel.GetOrNull("EMPTY"); + if (showEmptyCheckbox != null) + { + showEmptyCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Empty); + showEmptyCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Empty); + } + + var showAlreadyStartedCheckbox = filtersPanel.GetOrNull("ALREADY_STARTED"); + if (showAlreadyStartedCheckbox != null) + { + showAlreadyStartedCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Started); + showAlreadyStartedCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Started); + } + + var showProtectedCheckbox = filtersPanel.GetOrNull("PASSWORD_PROTECTED"); + if (showProtectedCheckbox != null) + { + showProtectedCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Protected); + showProtectedCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Protected); + } + + var showIncompatibleCheckbox = filtersPanel.GetOrNull("INCOMPATIBLE_VERSION"); + if (showIncompatibleCheckbox != null) + { + showIncompatibleCheckbox.IsChecked = () => gs.MPGameFilters.HasFlag(MPGameFilters.Incompatible); + showIncompatibleCheckbox.OnClick = () => toggleFilterFlag(MPGameFilters.Incompatible); + } + + filtersButton.IsDisabled = () => searchStatus == SearchStatus.Fetching; + filtersButton.OnMouseDown = _ => + { + filtersButton.RemovePanel(); + filtersButton.AttachPanel(filtersPanel); + }; + } + + var reloadButton = widget.GetOrNull("RELOAD_BUTTON"); + if (reloadButton != null) + { + reloadButton.IsDisabled = () => searchStatus == SearchStatus.Fetching; + reloadButton.OnClick = RefreshServerList; + + var reloadIcon = reloadButton.GetOrNull("IMAGE_RELOAD"); + if (reloadIcon != null) + { + var disabledFrame = 0; + var disabledImage = "disabled-" + disabledFrame.ToString(); + reloadIcon.GetImageName = () => searchStatus == SearchStatus.Fetching ? disabledImage : reloadIcon.ImageName; + + var reloadTicker = reloadIcon.Get("ANIMATION"); + if (reloadTicker != null) + { + reloadTicker.OnTick = () => + { + disabledFrame = searchStatus == SearchStatus.Fetching ? (disabledFrame + 1) % 12 : 0; + disabledImage = "disabled-" + disabledFrame.ToString(); + }; + } + } + } + + mapPreview = widget.GetOrNull("SELECTED_MAP_PREVIEW"); + if (mapPreview != null) + mapPreview.Preview = () => currentMap; + + var mapTitle = widget.GetOrNull("SELECTED_MAP"); + if (mapTitle != null) + { + var font = Game.Renderer.Fonts[mapTitle.Font]; + var title = new CachedTransform(m => m == null ? "No Server Selected" : + WidgetUtils.TruncateText(m.Title, mapTitle.Bounds.Width, font)); + mapTitle.GetText = () => title.Update(currentMap); + } + + var ip = widget.GetOrNull("SELECTED_IP"); + if (ip != null) + { + ip.IsVisible = () => currentServer != null; + ip.GetText = () => currentServer.Address; + } + + var status = widget.GetOrNull("SELECTED_STATUS"); + if (status != null) + { + status.IsVisible = () => currentServer != null; + status.GetText = () => GetStateLabel(currentServer); + status.GetColor = () => GetStateColor(currentServer, status); + } + + var modVersion = widget.GetOrNull("SELECTED_MOD_VERSION"); + if (modVersion != null) + { + modVersion.IsVisible = () => currentServer != null; + modVersion.GetColor = () => currentServer.IsCompatible ? modVersion.TextColor : incompatibleVersionColor; + + var font = Game.Renderer.Fonts[modVersion.Font]; + var version = new CachedTransform(s => WidgetUtils.TruncateText(s.ModLabel, mapTitle.Bounds.Width, font)); + modVersion.GetText = () => version.Update(currentServer); + } + + var players = widget.GetOrNull("SELECTED_PLAYERS"); + if (players != null) + { + players.IsVisible = () => currentServer != null && (clientContainer == null || !currentServer.Clients.Any()); + players.GetText = () => PlayersLabel(currentServer); + } + + clientContainer = widget.GetOrNull("CLIENT_LIST_CONTAINER"); + if (clientContainer != null) + { + clientList = Ui.LoadWidget("MULTIPLAYER_CLIENT_LIST", clientContainer, new WidgetArgs()) as ScrollPanelWidget; + clientList.IsVisible = () => currentServer != null && currentServer.Clients.Any(); + clientHeader = clientList.Get("HEADER"); + clientTemplate = clientList.Get("TEMPLATE"); + clientList.RemoveChildren(); + } + + lanGameLocations = new List(); + try + { + lanGameProbe = new Probe("OpenRALANGame"); + lanGameProbe.BeaconsUpdated += locations => lanGameLocations = locations; + lanGameProbe.Start(); + } + catch (Exception ex) + { + Log.Write("debug", "BeaconLib.Probe: " + ex.Message); + } + + RefreshServerList(); + } + + string PlayersLabel(GameServer game) + { + return "{0}{1}{2}".F( + "{0} Player{1}".F(game.Players > 0 ? game.Players.ToString() : "No", game.Players != 1 ? "s" : ""), + game.Bots > 0 ? ", {0} Bot{1}".F(game.Bots, game.Bots != 1 ? "s" : "") : "", + game.Spectators > 0 ? ", {0} Spectator{1}".F(game.Spectators, game.Spectators != 1 ? "s" : "") : ""); + } + + void RefreshServerList() + { + // Query in progress + if (currentQuery != null) + return; + + searchStatus = SearchStatus.Fetching; + + Action onComplete = i => + { + currentQuery = null; + + List games = null; + if (i.Error == null) + { + try + { + var data = Encoding.UTF8.GetString(i.Result); + var yaml = MiniYaml.FromString(data); + + games = yaml.Select(a => new GameServer(a.Value)) + .Where(gs => gs.Address != null) + .ToList(); + } + catch + { + searchStatus = SearchStatus.Failed; + } + } + + var lanGames = new List(); + foreach (var bl in lanGameLocations) + { + var game = MiniYaml.FromString(bl.Data)[0].Value; + var idNode = game.Nodes.FirstOrDefault(n => n.Key == "Id"); + + // Skip beacons created by this instance and replace Id by expected int value + if (idNode != null && idNode.Value.Value != Platform.SessionGUID.ToString()) + { + idNode.Value.Value = "-1"; + + // Rewrite the server address with the correct IP + var addressNode = game.Nodes.FirstOrDefault(n => n.Key == "Address"); + if (addressNode != null) + addressNode.Value.Value = bl.Address.ToString().Split(':')[0] + ":" + addressNode.Value.Value.Split(':')[1]; + + try + { + lanGames.Add(new GameServer(game)); + } + catch { } + } + } + + var groupedLanGames = lanGames.GroupBy(gs => gs.Address).Select(g => g.Last()); + if (games != null) + games.AddRange(groupedLanGames); + else if (groupedLanGames.Any()) + games = groupedLanGames.ToList(); + + Game.RunAfterTick(() => RefreshServerListInner(games)); + }; + + var queryURL = services.ServerList + "?protocol={0}&engine={1}&mod={2}&version={3}".F( + GameServer.ProtocolVersion, + Uri.EscapeUriString(Game.EngineVersion), + Uri.EscapeUriString(Game.ModData.Manifest.Id), + Uri.EscapeUriString(Game.ModData.Manifest.Metadata.Version)); + + currentQuery = new Download(queryURL, _ => { }, onComplete); + } + + int GroupSortOrder(GameServer testEntry) + { + // Games that we can't join are sorted last + if (!testEntry.IsCompatible) + return testEntry.Mod == modData.Manifest.Id ? 1 : 0; + + // Games for the current mod+version are sorted first + if (testEntry.Mod == modData.Manifest.Id) + return testEntry.Version == modData.Manifest.Metadata.Version ? 4 : 3; + + // Followed by games for different mods that are joinable + return 2; + } + + void SelectServer(GameServer server) + { + currentServer = server; + currentMap = server != null ? modData.MapCache[server.Map] : null; + + if (server == null || !server.Clients.Any()) + { + if (joinButton != null) + joinButton.Bounds.Y = joinButtonY; + + return; + } + + if (joinButton != null) + joinButton.Bounds.Y = clientContainer.Bounds.Bottom; + + // Can only show factions if the server is running the same mod + var disableFactionDisplay = server.Mod != modData.Manifest.Id; + + if (server != null && mapPreview != null) + { + var spawns = currentMap.SpawnPoints; + var occupants = server.Clients + .Where(c => (c.SpawnPoint - 1 >= 0) && (c.SpawnPoint - 1 < spawns.Length)) + .ToDictionary(c => spawns[c.SpawnPoint - 1], c => new SpawnOccupant(c, disableFactionDisplay)); + + mapPreview.SpawnOccupants = () => occupants; + } + + if (clientList == null) + return; + + clientList.RemoveChildren(); + + var players = server.Clients + .Where(c => !c.IsSpectator) + .GroupBy(p => p.Team) + .OrderBy(g => g.Key); + + var teams = new Dictionary>(); + var noTeams = players.Count() == 1; + foreach (var p in players) + { + var label = noTeams ? "Players" : p.Key == 0 ? "No Team" : "Team {0}".F(p.Key); + teams.Add(label, p); + } + + if (server.Clients.Any(c => c.IsSpectator)) + teams.Add("Spectators", server.Clients.Where(c => c.IsSpectator)); + + var factionInfo = modData.DefaultRules.Actors["world"].TraitInfos(); + foreach (var kv in teams) + { + var group = kv.Key; + if (group.Length > 0) + { + var header = ScrollItemWidget.Setup(clientHeader, () => true, () => { }); + header.Get("LABEL").GetText = () => group; + clientList.AddChild(header); + } + + foreach (var option in kv.Value) + { + var o = option; + + var item = ScrollItemWidget.Setup(clientTemplate, () => false, () => { }); + if (!o.IsSpectator && !disableFactionDisplay) + { + var label = item.Get("LABEL"); + var font = Game.Renderer.Fonts[label.Font]; + var name = WidgetUtils.TruncateText(o.Name, label.Bounds.Width, font); + label.GetText = () => name; + label.GetColor = () => o.Color.RGB; + + var flag = item.Get("FLAG"); + flag.IsVisible = () => true; + flag.GetImageCollection = () => "flags"; + flag.GetImageName = () => (factionInfo != null && factionInfo.Any(f => f.InternalName == o.Faction)) ? o.Faction : "Random"; + } + else + { + var label = item.Get("NOFLAG_LABEL"); + var font = Game.Renderer.Fonts[label.Font]; + var name = WidgetUtils.TruncateText(o.Name, label.Bounds.Width, font); + label.GetText = () => name; + label.GetColor = () => o.Color.RGB; + } + + clientList.AddChild(item); + } + } + } + + void RefreshServerListInner(List games) + { + ScrollItemWidget nextServerRow = null; + List rows = null; + + if (games != null) + rows = LoadGameRows(games, out nextServerRow); + + Game.RunAfterTick(() => + { + serverList.RemoveChildren(); + SelectServer(null); + + if (games == null) + { + searchStatus = SearchStatus.Failed; + return; + } + + if (!rows.Any()) + { + searchStatus = SearchStatus.NoGames; + return; + } + + searchStatus = SearchStatus.Hidden; + + // Search for any unknown maps + if (Game.Settings.Game.AllowDownloading) + modData.MapCache.QueryRemoteMapDetails(services.MapRepository, games.Where(g => !Filtered(g)).Select(g => g.Map)); + + foreach (var row in rows) + serverList.AddChild(row); + + if (nextServerRow != null) + nextServerRow.OnClick(); + }); + } + + List LoadGameRows(List games, out ScrollItemWidget nextServerRow) + { + nextServerRow = null; + var rows = new List(); + var mods = games.GroupBy(g => g.ModLabel) + .OrderByDescending(g => GroupSortOrder(g.First())) + .ThenByDescending(g => g.Count()); + + foreach (var modGames in mods) + { + if (modGames.All(Filtered)) + continue; + + var header = ScrollItemWidget.Setup(headerTemplate, () => true, () => { }); + + var headerTitle = modGames.First().ModLabel; + header.Get("LABEL").GetText = () => headerTitle; + rows.Add(header); + + Func listOrder = g => + { + // Servers waiting for players are always first + if (g.State == (int)ServerState.WaitingPlayers && g.Players > 0) + return 0; + + // Then servers with spectators + if (g.State == (int)ServerState.WaitingPlayers && g.Spectators > 0) + return 1; + + // Then active games + if (g.State >= (int)ServerState.GameStarted) + return 2; + + // Empty servers are shown at the end because a flood of empty servers + // at the top of the game list make the community look dead + return 3; + }; + + foreach (var modGamesByState in modGames.GroupBy(listOrder).OrderBy(g => g.Key)) + { + // Sort 'Playing' games by Started, others by number of players + foreach (var game in modGamesByState.Key == 2 ? modGamesByState.OrderByDescending(g => g.Started) : modGamesByState.OrderByDescending(g => g.Players)) + { + if (Filtered(game)) + continue; + + var canJoin = game.IsJoinable; + var item = ScrollItemWidget.Setup(serverTemplate, () => currentServer == game, () => SelectServer(game), () => onJoin(game)); + var title = item.GetOrNull("TITLE"); + if (title != null) + { + var font = Game.Renderer.Fonts[title.Font]; + var label = WidgetUtils.TruncateText(game.Name, title.Bounds.Width, font); + title.GetText = () => label; + title.GetColor = () => canJoin ? title.TextColor : incompatibleGameColor; + } + + var password = item.GetOrNull("PASSWORD_PROTECTED"); + if (password != null) + { + password.IsVisible = () => game.Protected; + password.GetImageName = () => canJoin ? "protected" : "protected-disabled"; + } + + var players = item.GetOrNull("PLAYERS"); + if (players != null) + { + var label = "{0} / {1}".F(game.Players + game.Bots, game.MaxPlayers + game.Bots) + + (game.Spectators > 0 ? " + {0}".F(game.Spectators) : ""); + + var color = canJoin ? players.TextColor : incompatibleGameColor; + players.GetText = () => label; + players.GetColor = () => color; + + if (game.Clients.Any()) + { + var displayClients = game.Clients.Select(c => c.Name); + if (game.Clients.Length > 10) + displayClients = displayClients + .Take(9) + .Append("+ {0} other players".F(game.Clients.Length - 9)); + + var tooltip = displayClients.JoinWith("\n"); + players.GetTooltipText = () => tooltip; + } + else + players.GetTooltipText = null; + } + + var state = item.GetOrNull("STATUS"); + if (state != null) + { + var label = game.State >= (int)ServerState.GameStarted ? + "Playing" : "Waiting"; + state.GetText = () => label; + + var color = GetStateColor(game, state, !canJoin); + state.GetColor = () => color; + } + + var location = item.GetOrNull("LOCATION"); + if (location != null) + { + var font = Game.Renderer.Fonts[location.Font]; + var cachedServerLocation = game.Id != -1 ? GeoIP.LookupCountry(game.Address.Split(':')[0]) : "Local Network"; + var label = WidgetUtils.TruncateText(cachedServerLocation, location.Bounds.Width, font); + location.GetText = () => label; + location.GetColor = () => canJoin ? location.TextColor : incompatibleGameColor; + } + + if (currentServer != null && game.Address == currentServer.Address) + nextServerRow = item; + + rows.Add(item); + } + } + } + + return rows; + } + + static string GetStateLabel(GameServer game) + { + if (game == null) + return ""; + + if (game.State == (int)ServerState.GameStarted) + { + var label = "In progress"; + + if (game.PlayTime > 0) + { + var totalMinutes = Math.Ceiling(game.PlayTime / 60.0); + label += " for {0} minute{1}".F(totalMinutes, totalMinutes > 1 ? "s" : ""); + } + + return label; + } + + if (game.State == (int)ServerState.WaitingPlayers) + return game.Protected ? "Password protected" : "Waiting for players"; + + if (game.State == (int)ServerState.ShuttingDown) + return "Server shutting down"; + + return "Unknown server state"; + } + + Color GetStateColor(GameServer game, LabelWidget label, bool darkened = false) + { + if (!game.Protected && game.State == (int)ServerState.WaitingPlayers) + return darkened ? incompatibleWaitingGameColor : waitingGameColor; + + if (game.Protected && game.State == (int)ServerState.WaitingPlayers) + return darkened ? incompatibleProtectedGameColor : protectedGameColor; + + if (game.State == (int)ServerState.GameStarted) + return darkened ? incompatibleGameStartedColor : gameStartedColor; + + return label.TextColor; + } + + bool Filtered(GameServer game) + { + var filters = Game.Settings.Game.MPGameFilters; + if (game.State == (int)ServerState.GameStarted && !filters.HasFlag(MPGameFilters.Started)) + return true; + + if (game.State == (int)ServerState.WaitingPlayers && !filters.HasFlag(MPGameFilters.Waiting) && game.Players != 0) + return true; + + if ((game.Players + game.Spectators) == 0 && !filters.HasFlag(MPGameFilters.Empty)) + return true; + + if (!game.IsCompatible && !filters.HasFlag(MPGameFilters.Incompatible)) + return true; + + if (game.Protected && !filters.HasFlag(MPGameFilters.Protected)) + return true; + + return false; + } + + bool disposed; + protected override void Dispose(bool disposing) + { + if (disposing && !disposed) + { + disposed = true; + if (lanGameProbe != null) + lanGameProbe.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/mods/cnc/chrome/multiplayer-browserpanels.yaml b/mods/cnc/chrome/multiplayer-browserpanels.yaml index af3cccdbed..a6c3671aae 100644 --- a/mods/cnc/chrome/multiplayer-browserpanels.yaml +++ b/mods/cnc/chrome/multiplayer-browserpanels.yaml @@ -40,7 +40,7 @@ ScrollPanel@MULTIPLAYER_CLIENT_LIST: Shadow: True ScrollPanel@MULTIPLAYER_FILTER_PANEL: - Width: 147 + Width: PARENT_RIGHT Height: 130 Background: panel-black Children: diff --git a/mods/common/chrome/multiplayer-browserpanels.yaml b/mods/common/chrome/multiplayer-browserpanels.yaml index 93133d3c1f..2651074fcc 100644 --- a/mods/common/chrome/multiplayer-browserpanels.yaml +++ b/mods/common/chrome/multiplayer-browserpanels.yaml @@ -41,7 +41,7 @@ ScrollPanel@MULTIPLAYER_CLIENT_LIST: Shadow: True ScrollPanel@MULTIPLAYER_FILTER_PANEL: - Width: 158 + Width: PARENT_RIGHT Height: 130 Children: Checkbox@WAITING_FOR_PLAYERS: