diff --git a/OpenRA.Game/FileFormats/ReplayMetadata.cs b/OpenRA.Game/FileFormats/ReplayMetadata.cs index d7d16d8011..0e86442aad 100644 --- a/OpenRA.Game/FileFormats/ReplayMetadata.cs +++ b/OpenRA.Game/FileFormats/ReplayMetadata.cs @@ -53,6 +53,8 @@ namespace OpenRA.FileFormats if (endGameTimestampUtc.Kind == DateTimeKind.Unspecified) throw new ArgumentException("The 'Kind' property of the timestamp must be specified", "endGameTimestampUtc"); EndTimestampUtc = endGameTimestampUtc.ToUniversalTime(); + + Outcome = outcome; } ReplayMetadata(BinaryReader reader) diff --git a/OpenRA.Game/Widgets/ListLayout.cs b/OpenRA.Game/Widgets/ListLayout.cs index 83779fc4ce..e80bf8c08b 100644 --- a/OpenRA.Game/Widgets/ListLayout.cs +++ b/OpenRA.Game/Widgets/ListLayout.cs @@ -22,7 +22,8 @@ namespace OpenRA.Widgets widget.ContentHeight = widget.ItemSpacing; w.Bounds.Y = widget.ContentHeight; - widget.ContentHeight += w.Bounds.Height + widget.ItemSpacing; + if (!widget.CollapseHiddenChildren || w.IsVisible()) + widget.ContentHeight += w.Bounds.Height + widget.ItemSpacing; } public void AdjustChildren() @@ -31,7 +32,8 @@ namespace OpenRA.Widgets foreach (var w in widget.Children) { w.Bounds.Y = widget.ContentHeight; - widget.ContentHeight += w.Bounds.Height + widget.ItemSpacing; + if (!widget.CollapseHiddenChildren || w.IsVisible()) + widget.ContentHeight += w.Bounds.Height + widget.ItemSpacing; } } } diff --git a/OpenRA.Game/Widgets/ScrollPanelWidget.cs b/OpenRA.Game/Widgets/ScrollPanelWidget.cs index c721cbab88..150b5048ea 100644 --- a/OpenRA.Game/Widgets/ScrollPanelWidget.cs +++ b/OpenRA.Game/Widgets/ScrollPanelWidget.cs @@ -30,6 +30,7 @@ namespace OpenRA.Widgets public ILayout Layout; public int MinimumThumbSize = 10; public ScrollPanelAlign Align = ScrollPanelAlign.Top; + public bool CollapseHiddenChildren = false; protected float ListOffset = 0; protected bool UpPressed = false; protected bool DownPressed = false; diff --git a/OpenRA.Mods.RA/Widgets/Logic/ReplayBrowserLogic.cs b/OpenRA.Mods.RA/Widgets/Logic/ReplayBrowserLogic.cs index 9311f814c9..52e1dbac19 100644 --- a/OpenRA.Mods.RA/Widgets/Logic/ReplayBrowserLogic.cs +++ b/OpenRA.Mods.RA/Widgets/Logic/ReplayBrowserLogic.cs @@ -15,21 +15,23 @@ using System.IO; using System.Linq; using OpenRA.FileFormats; using OpenRA.Network; +using OpenRA.Primitives; using OpenRA.Widgets; namespace OpenRA.Mods.RA.Widgets.Logic { public class ReplayBrowserLogic { + static Filter filter = new Filter(); + Widget panel; ScrollPanelWidget playerList; ScrollItemWidget playerTemplate, playerHeader; + List replays; + Dictionary replayVis = new Dictionary(); - MapPreview selectedMap = MapCache.UnknownMap; Dictionary selectedSpawns; - string selectedFilename; - string selectedDuration; - bool selectedValid; + ReplayMetadata selectedReplay; [ObjectCreator.UseCtor] public ReplayBrowserLogic(Widget widget, Action onExit, Action onStart) @@ -52,47 +54,315 @@ namespace OpenRA.Mods.RA.Widgets.Logic rl.RemoveChildren(); if (Directory.Exists(dir)) { - List replays; - using (new Support.PerfTimer("Load replays")) { replays = Directory .GetFiles(dir, "*.rep") .Select((filename) => ReplayMetadata.Read(filename)) .Where((r) => r != null) - .OrderByDescending((r) => Path.GetFileName(r.FilePath)) + .OrderByDescending(r => r.StartTimestampUtc) .ToList(); } foreach (var replay in replays) AddReplay(rl, replay, template); - SelectReplay(replays.FirstOrDefault()); + ApplyFilter(); } var watch = panel.Get("WATCH_BUTTON"); - watch.IsDisabled = () => !selectedValid || selectedMap.Status != MapStatus.Available; + watch.IsDisabled = () => selectedReplay == null || selectedReplay.MapPreview.Status != MapStatus.Available; watch.OnClick = () => { WatchReplay(); onStart(); }; - panel.Get("REPLAY_INFO").IsVisible = () => selectedFilename != null; + panel.Get("REPLAY_INFO").IsVisible = () => selectedReplay != null; var preview = panel.Get("MAP_PREVIEW"); preview.SpawnClients = () => selectedSpawns; - preview.Preview = () => selectedMap; + preview.Preview = () => selectedReplay != null ? selectedReplay.MapPreview : null; var title = panel.GetOrNull("MAP_TITLE"); if (title != null) - title.GetText = () => selectedMap.Title; + title.GetText = () => selectedReplay != null ? selectedReplay.MapPreview.Title : null; var type = panel.GetOrNull("MAP_TYPE"); if (type != null) - type.GetText = () => selectedMap.Type; + type.GetText = () => selectedReplay.MapPreview.Type; - panel.Get("DURATION").GetText = () => selectedDuration; + panel.Get("DURATION").GetText = () => WidgetUtils.FormatTimeSeconds((int)selectedReplay.Duration.TotalSeconds); + + SetupFilters(); + } + + void SetupFilters() + { + // + // Game type + // + { + var ddb = panel.GetOrNull("FLT_GAMETYPE_DROPDOWNBUTTON"); + if (ddb != null) + { + // Using list to maintain the order + var options = new List> + { + new KeyValuePair(GameType.Any, ddb.GetText()), + new KeyValuePair(GameType.Singleplayer, "Singleplayer"), + new KeyValuePair(GameType.Multiplayer, "Multiplayer") + }; + var lookup = options.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + ddb.GetText = () => lookup[filter.Type]; + ddb.OnMouseDown = _ => + { + Func, ScrollItemWidget, ScrollItemWidget> setupItem = (option, tpl) => + { + var item = ScrollItemWidget.Setup( + tpl, + () => filter.Type == option.Key, + () => { filter.Type = option.Key; ApplyFilter(); } + ); + item.Get("LABEL").GetText = () => option.Value; + return item; + }; + + ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", options.Count * 30, options, setupItem); + }; + } + } + + // + // Date type + // + { + var ddb = panel.GetOrNull("FLT_DATE_DROPDOWNBUTTON"); + if (ddb != null) + { + // Using list to maintain the order + var options = new List> + { + new KeyValuePair(DateType.Any, ddb.GetText()), + new KeyValuePair(DateType.Today, "Today"), + new KeyValuePair(DateType.LastWeek, "Last Week"), + new KeyValuePair(DateType.LastMonth, "Last Month") + }; + var lookup = options.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + ddb.GetText = () => lookup[filter.Date]; + ddb.OnMouseDown = _ => + { + Func, ScrollItemWidget, ScrollItemWidget> setupItem = (option, tpl) => + { + var item = ScrollItemWidget.Setup( + tpl, + () => filter.Date == option.Key, + () => { filter.Date = option.Key; ApplyFilter(); } + ); + item.Get("LABEL").GetText = () => option.Value; + return item; + }; + + ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", options.Count * 30, options, setupItem); + }; + } + } + + // + // Duration + // + { + var ddb = panel.GetOrNull("FLT_DURATION_DROPDOWNBUTTON"); + if (ddb != null) + { + // Using list to maintain the order + var options = new List> + { + new KeyValuePair(DurationType.Any, ddb.GetText()), + new KeyValuePair(DurationType.VeryShort, "Under 5 min"), + new KeyValuePair(DurationType.Short, "Short (10 min)"), + new KeyValuePair(DurationType.Medium, "Medium (30 min)"), + new KeyValuePair(DurationType.Long, "Long (60+ min)") + }; + var lookup = options.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + ddb.GetText = () => lookup[filter.Duration]; + ddb.OnMouseDown = _ => + { + Func, ScrollItemWidget, ScrollItemWidget> setupItem = (option, tpl) => + { + var item = ScrollItemWidget.Setup( + tpl, + () => filter.Duration == option.Key, + () => { filter.Duration = option.Key; ApplyFilter(); } + ); + item.Get("LABEL").GetText = () => option.Value; + return item; + }; + + ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", options.Count * 30, options, setupItem); + }; + } + } + + // + // Outcome + // + { + var ddb = panel.GetOrNull("FLT_OUTCOME_DROPDOWNBUTTON"); + if (ddb != null) + { + // Using list to maintain the order + var options = new List> + { + new KeyValuePair(WinState.Undefined, ddb.GetText()), + new KeyValuePair(WinState.Won, "Won"), + new KeyValuePair(WinState.Lost, "Lost") + }; + var lookup = options.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + ddb.GetText = () => lookup[filter.Outcome]; + ddb.OnMouseDown = _ => + { + Func, ScrollItemWidget, ScrollItemWidget> setupItem = (option, tpl) => + { + var item = ScrollItemWidget.Setup( + tpl, + () => filter.Outcome == option.Key, + () => { filter.Outcome = option.Key; ApplyFilter(); } + ); + item.Get("LABEL").GetText = () => option.Value; + return item; + }; + + ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", options.Count * 30, options, setupItem); + }; + } + } + + // + // Players + // + { + var ddb = panel.GetOrNull("FLT_PLAYER_DROPDOWNBUTTON"); + if (ddb != null) + { + var options = new HashSet(replays.SelectMany(r => r.Session.Value.Clients.Select(c => c.Name)), StringComparer.OrdinalIgnoreCase).ToList(); + options.Sort(StringComparer.OrdinalIgnoreCase); + options.Insert(0, null); // no filter + + var nobodyText = ddb.GetText(); + ddb.GetText = () => string.IsNullOrEmpty(filter.PlayerName) ? nobodyText : filter.PlayerName; + ddb.OnMouseDown = _ => + { + Func setupItem = (option, tpl) => + { + var item = ScrollItemWidget.Setup( + tpl, + () => string.Compare(filter.PlayerName, option, true) == 0, + () => { filter.PlayerName = option; ApplyFilter(); } + ); + item.Get("LABEL").GetText = () => option ?? nobodyText; + return item; + }; + + ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", options.Count * 30, options, setupItem); + }; + } + } + } + + bool EvaluateReplayVisibility(ReplayMetadata replay) + { + // Game type + if ((filter.Type == GameType.Multiplayer && replay.Session.Value.IsSinglePlayer) || (filter.Type == GameType.Singleplayer && !replay.Session.Value.IsSinglePlayer)) + return false; + + // Date type + if (filter.Date != DateType.Any) + { + TimeSpan t; + switch (filter.Date) + { + case DateType.Today: + t = TimeSpan.FromDays(1d); + break; + + case DateType.LastWeek: + t = TimeSpan.FromDays(7d); + break; + + case DateType.LastMonth: + default: + t = TimeSpan.FromDays(30d); + break; + } + if (replay.StartTimestampUtc < DateTime.UtcNow.Subtract(t)) + return false; + } + + // Duration + if (filter.Duration != DurationType.Any) + { + double minutes = replay.Duration.TotalMinutes; + switch (filter.Duration) + { + case DurationType.VeryShort: + if (minutes >= 5) + return false; + break; + + case DurationType.Short: + if (minutes < 5 || minutes >= 20) + return false; + break; + + case DurationType.Medium: + if (minutes < 20 || minutes >= 60) + return false; + break; + + case DurationType.Long: + if (minutes < 60) + return false; + break; + } + } + + // Outcome + if (filter.Outcome != WinState.Undefined && filter.Outcome != replay.Outcome) + return false; + + // Player + if (!string.IsNullOrEmpty(filter.PlayerName)) + { + var player = replay.Session.Value.Clients.Find(c => string.Compare(filter.PlayerName, c.Name, true) == 0); + if (player == null) + return false; + } + + return true; + } + + void ApplyFilter() + { + foreach (var replay in replays) + replayVis[replay] = EvaluateReplayVisibility(replay); + + if (selectedReplay == null || replayVis[selectedReplay] == false) + SelectFirstVisibleReplay(); + + panel.Get("REPLAY_LIST").Layout.AdjustChildren(); + } + + void SelectFirstVisibleReplay() + { + SelectReplay(replays.FirstOrDefault(r => replayVis[r])); } void SelectReplay(ReplayMetadata replay) { + selectedReplay = replay; + selectedSpawns = (selectedReplay != null) ? LobbyUtils.GetSpawnClients(selectedReplay.Session.Value, selectedReplay.MapPreview) : null; + if (replay == null) return; @@ -100,12 +370,6 @@ namespace OpenRA.Mods.RA.Widgets.Logic { var lobby = replay.Session.Value; - selectedFilename = replay.FilePath; - selectedMap = Game.modData.MapCache[lobby.GlobalSettings.Map]; - selectedSpawns = LobbyUtils.GetSpawnClients(lobby, selectedMap); - selectedDuration = WidgetUtils.FormatTimeSeconds((int)replay.Duration.TotalSeconds); - selectedValid = true; - var clients = lobby.Clients.Where(c => c.Slot != null) .GroupBy(c => c.Team) .OrderBy(g => g.Key); @@ -153,17 +417,15 @@ namespace OpenRA.Mods.RA.Widgets.Logic catch (Exception e) { Log.Write("debug", "Exception while parsing replay: {0}", e); - selectedFilename = null; - selectedValid = false; - selectedMap = MapCache.UnknownMap; + SelectReplay(null); } } void WatchReplay() { - if (selectedFilename != null) + if (selectedReplay != null) { - Game.JoinReplay(selectedFilename); + Game.JoinReplay(selectedReplay.FilePath); Ui.CloseWindow(); } } @@ -171,12 +433,43 @@ namespace OpenRA.Mods.RA.Widgets.Logic void AddReplay(ScrollPanelWidget list, ReplayMetadata replay, ScrollItemWidget template) { var item = ScrollItemWidget.Setup(template, - () => selectedFilename == replay.FilePath, + () => selectedReplay == replay, () => SelectReplay(replay), () => WatchReplay()); - var f = Path.GetFileName(replay.FilePath); + var f = Path.GetFileNameWithoutExtension(replay.FilePath); item.Get("TITLE").GetText = () => f; + item.IsVisible = () => { bool visible; return replayVis.TryGetValue(replay, out visible) && visible; }; list.AddChild(item); } + + class Filter + { + public GameType Type; + public DateType Date; + public DurationType Duration; + public WinState Outcome = WinState.Undefined; + public string PlayerName; + } + enum GameType + { + Any, + Singleplayer, + Multiplayer + } + enum DateType + { + Any, + Today, + LastWeek, + LastMonth + } + enum DurationType + { + Any, + VeryShort, + Short, + Medium, + Long + } } } diff --git a/mods/ra/chrome/replaybrowser.yaml b/mods/ra/chrome/replaybrowser.yaml index 7ef5acbea5..43e42e1723 100644 --- a/mods/ra/chrome/replaybrowser.yaml +++ b/mods/ra/chrome/replaybrowser.yaml @@ -2,45 +2,144 @@ Background@REPLAYBROWSER_PANEL: Logic: ReplayBrowserLogic X: (WINDOW_RIGHT - WIDTH)/2 Y: (WINDOW_BOTTOM - HEIGHT)/2 - Width: 530 + Width: 490 Height: 535 Children: - Label@REPLAYBROWSER_LABEL_TITLE: - Y: 20 - Width: PARENT_RIGHT - Height: 25 - Text: Choose Replay - Align: Center - Font: Bold - ScrollPanel@REPLAY_LIST: + Container@FILTERS: X: 20 - Y: 50 - Width: 282 - Height: 430 + Y: 20 + Width: 280 + Height: 180 Children: - ScrollItem@REPLAY_TEMPLATE: - Width: PARENT_RIGHT-27 + Label@FILTERS_TITLE: + Width: PARENT_RIGHT Height: 25 - X: 2 - Visible: false + Font: Bold + Align: Center + Text: Filter + Label@FLT_GAMETYPE_DESC: + X: 0 + Y: 30 + Width: 80 + Height: 25 + Text: Type: + Align: Right + DropDownButton@FLT_GAMETYPE_DROPDOWNBUTTON: + X: 85 + Y: 30 + Width: 160 + Height: 25 + Font: Regular + Text: Any + Label@FLT_DATE_DESC: + X: 0 + Y: 60 + Width: 80 + Height: 25 + Text: Date: + Align: Right + DropDownButton@FLT_DATE_DROPDOWNBUTTON: + X: 85 + Y: 60 + Width: 160 + Height: 25 + Font: Regular + Text: Any + Label@FLT_PLAYER_DESC: + X: 0 + Y: 90 + Width: 80 + Height: 25 + Text: Player: + Align: Right + DropDownButton@FLT_PLAYER_DROPDOWNBUTTON: + X: 85 + Y: 90 + Width: 160 + Height: 25 + Font: Regular + Text: Anyone + Label@FLT_OUTCOME_DESC: + X: 0 + Y: 120 + Width: 80 + Height: 25 + Text: Outcome: + Align: Right + DropDownButton@FLT_OUTCOME_DROPDOWNBUTTON: + X: 85 + Y: 120 + Width: 160 + Height: 25 + Font: Regular + Text: Any + Label@FLT_DURATION_DESC: + X: 0 + Y: 150 + Width: 80 + Height: 25 + Text: Duration: + Align: Right + DropDownButton@FLT_DURATION_DROPDOWNBUTTON: + X: 85 + Y: 150 + Width: 160 + Height: 25 + Font: Regular + Text: Any + Container@REPLAY_LIST_CONTAINER: + X: 20 + Y: 210 + Width: 245 + Height: PARENT_BOTTOM - 270 + Children: + Label@REPLAYBROWSER_LABEL_TITLE: + Width: PARENT_RIGHT + Height: 25 + Text: Choose Replay + Align: Center + Font: Bold + ScrollPanel@REPLAY_LIST: + X: 0 + Y: 30 + Width: PARENT_RIGHT + Height: PARENT_BOTTOM - 25 + CollapseHiddenChildren: True Children: - Label@TITLE: - X: 10 - Width: PARENT_RIGHT-20 + ScrollItem@REPLAY_TEMPLATE: + Width: PARENT_RIGHT-27 Height: 25 - Background@MAP_BG: + X: 2 + Visible: false + Children: + Label@TITLE: + X: 10 + Width: PARENT_RIGHT-20 + Height: 25 + Container@MAP_BG_CONTAINER: X: PARENT_RIGHT-WIDTH-20 - Y: 50 + Y: 20 Width: 194 Height: 194 - Background: dialog3 Children: - MapPreview@MAP_PREVIEW: - X: 1 - Y: 1 - Width: 192 - Height: 192 - TooltipContainer: TOOLTIP_CONTAINER + Label@MAP_BG_TITLE: + Width: PARENT_RIGHT + Height: 25 + Text: Preview + Align: Center + Font: Bold + Background@MAP_BG: + Y: 30 + Width: 194 + Height: 194 + Background: dialog3 + Children: + MapPreview@MAP_PREVIEW: + X: 1 + Y: 1 + Width: 192 + Height: 192 + TooltipContainer: TOOLTIP_CONTAINER Container@REPLAY_INFO: X: PARENT_RIGHT-WIDTH - 20 Y: 50