1 #region Copyright & License Information
2 /*
3  * Copyright 2007-2020 The OpenRA Developers (see AUTHORS)
4  * This file is part of OpenRA, which is free software. It is made
5  * available to you under the terms of the GNU General Public License
6  * as published by the Free Software Foundation, either version 3 of
7  * the License, or (at your option) any later version. For more
8  * information, see COPYING.
9  */
10 #endregion
11 
12 using System;
13 using System.Collections.Generic;
14 using System.ComponentModel;
15 using System.Diagnostics.CodeAnalysis;
16 using System.Globalization;
17 using System.IO;
18 using System.Linq;
19 using System.Net;
20 using OpenRA.Primitives;
21 using OpenRA.Widgets;
22 
23 namespace OpenRA.Mods.Common.Widgets.Logic
24 {
25 	public class MainMenuLogic : ChromeLogic
26 	{
27 		protected enum MenuType { Main, Singleplayer, Extras, MapEditor, StartupPrompts, None }
28 
29 		protected enum MenuPanel { None, Missions, Skirmish, Multiplayer, MapEditor, Replays, GameSaves }
30 
31 		protected MenuType menuType = MenuType.Main;
32 		readonly Widget rootMenu;
33 		readonly ScrollPanelWidget newsPanel;
34 		readonly Widget newsTemplate;
35 		readonly LabelWidget newsStatus;
36 
37 		// Update news once per game launch
38 		static bool fetchedNews;
39 
40 		protected static MenuPanel lastGameState = MenuPanel.None;
41 
42 		bool newsOpen;
43 
SwitchMenu(MenuType type)44 		void SwitchMenu(MenuType type)
45 		{
46 			menuType = type;
47 
48 			// Update button mouseover
49 			Game.RunAfterTick(Ui.ResetTooltips);
50 		}
51 
52 		[ObjectCreator.UseCtor]
MainMenuLogic(Widget widget, World world, ModData modData)53 		public MainMenuLogic(Widget widget, World world, ModData modData)
54 		{
55 			rootMenu = widget;
56 			rootMenu.Get<LabelWidget>("VERSION_LABEL").Text = modData.Manifest.Metadata.Version;
57 
58 			// Menu buttons
59 			var mainMenu = widget.Get("MAIN_MENU");
60 			mainMenu.IsVisible = () => menuType == MenuType.Main;
61 
62 			mainMenu.Get<ButtonWidget>("SINGLEPLAYER_BUTTON").OnClick = () => SwitchMenu(MenuType.Singleplayer);
63 
64 			mainMenu.Get<ButtonWidget>("MULTIPLAYER_BUTTON").OnClick = OpenMultiplayerPanel;
65 
66 			mainMenu.Get<ButtonWidget>("CONTENT_BUTTON").OnClick = () =>
67 			{
68 				// Switching mods changes the world state (by disposing it),
69 				// so we can't do this inside the input handler.
70 				Game.RunAfterTick(() =>
71 				{
72 					var content = modData.Manifest.Get<ModContent>();
73 					Game.InitializeMod(content.ContentInstallerMod, new Arguments(new[] { "Content.Mod=" + modData.Manifest.Id }));
74 				});
75 			};
76 
77 			mainMenu.Get<ButtonWidget>("SETTINGS_BUTTON").OnClick = () =>
78 			{
79 				SwitchMenu(MenuType.None);
80 				Game.OpenWindow("SETTINGS_PANEL", new WidgetArgs
81 				{
82 					{ "onExit", () => SwitchMenu(MenuType.Main) }
83 				});
84 			};
85 
86 			mainMenu.Get<ButtonWidget>("EXTRAS_BUTTON").OnClick = () => SwitchMenu(MenuType.Extras);
87 
88 			mainMenu.Get<ButtonWidget>("QUIT_BUTTON").OnClick = Game.Exit;
89 
90 			// Singleplayer menu
91 			var singleplayerMenu = widget.Get("SINGLEPLAYER_MENU");
92 			singleplayerMenu.IsVisible = () => menuType == MenuType.Singleplayer;
93 
94 			var missionsButton = singleplayerMenu.Get<ButtonWidget>("MISSIONS_BUTTON");
95 			missionsButton.OnClick = OpenMissionBrowserPanel;
96 
97 			var hasCampaign = modData.Manifest.Missions.Any();
98 			var hasMissions = modData.MapCache
99 				.Any(p => p.Status == MapStatus.Available && p.Visibility.HasFlag(MapVisibility.MissionSelector));
100 
101 			missionsButton.Disabled = !hasCampaign && !hasMissions;
102 
103 			var hasMaps = modData.MapCache.Any(p => p.Visibility.HasFlag(MapVisibility.Lobby));
104 			var skirmishButton = singleplayerMenu.Get<ButtonWidget>("SKIRMISH_BUTTON");
105 			skirmishButton.OnClick = StartSkirmishGame;
106 			skirmishButton.Disabled = !hasMaps;
107 
108 			var loadButton = singleplayerMenu.Get<ButtonWidget>("LOAD_BUTTON");
109 			loadButton.IsDisabled = () => !GameSaveBrowserLogic.IsLoadPanelEnabled(modData.Manifest);
110 			loadButton.OnClick = OpenGameSaveBrowserPanel;
111 
112 			singleplayerMenu.Get<ButtonWidget>("BACK_BUTTON").OnClick = () => SwitchMenu(MenuType.Main);
113 
114 			// Extras menu
115 			var extrasMenu = widget.Get("EXTRAS_MENU");
116 			extrasMenu.IsVisible = () => menuType == MenuType.Extras;
117 
118 			extrasMenu.Get<ButtonWidget>("REPLAYS_BUTTON").OnClick = OpenReplayBrowserPanel;
119 
120 			extrasMenu.Get<ButtonWidget>("MUSIC_BUTTON").OnClick = () =>
121 			{
122 				SwitchMenu(MenuType.None);
123 				Ui.OpenWindow("MUSIC_PANEL", new WidgetArgs
124 				{
125 					{ "onExit", () => SwitchMenu(MenuType.Extras) },
126 					{ "world", world }
127 				});
128 			};
129 
130 			extrasMenu.Get<ButtonWidget>("MAP_EDITOR_BUTTON").OnClick = () => SwitchMenu(MenuType.MapEditor);
131 
132 			var assetBrowserButton = extrasMenu.GetOrNull<ButtonWidget>("ASSETBROWSER_BUTTON");
133 			if (assetBrowserButton != null)
134 				assetBrowserButton.OnClick = () =>
135 				{
136 					SwitchMenu(MenuType.None);
137 					Game.OpenWindow("ASSETBROWSER_PANEL", new WidgetArgs
138 					{
139 						{ "onExit", () => SwitchMenu(MenuType.Extras) },
140 					});
141 				};
142 
143 			extrasMenu.Get<ButtonWidget>("CREDITS_BUTTON").OnClick = () =>
144 			{
145 				SwitchMenu(MenuType.None);
146 				Ui.OpenWindow("CREDITS_PANEL", new WidgetArgs
147 				{
148 					{ "onExit", () => SwitchMenu(MenuType.Extras) },
149 				});
150 			};
151 
152 			extrasMenu.Get<ButtonWidget>("BACK_BUTTON").OnClick = () => SwitchMenu(MenuType.Main);
153 
154 			// Map editor menu
155 			var mapEditorMenu = widget.Get("MAP_EDITOR_MENU");
156 			mapEditorMenu.IsVisible = () => menuType == MenuType.MapEditor;
157 
158 			// Loading into the map editor
159 			Game.BeforeGameStart += RemoveShellmapUI;
160 
161 			var onSelect = new Action<string>(uid => LoadMapIntoEditor(modData.MapCache[uid].Uid));
162 
163 			var newMapButton = widget.Get<ButtonWidget>("NEW_MAP_BUTTON");
164 			newMapButton.OnClick = () =>
165 			{
166 				SwitchMenu(MenuType.None);
167 				Game.OpenWindow("NEW_MAP_BG", new WidgetArgs()
168 				{
169 					{ "onSelect", onSelect },
170 					{ "onExit", () => SwitchMenu(MenuType.MapEditor) }
171 				});
172 			};
173 
174 			var loadMapButton = widget.Get<ButtonWidget>("LOAD_MAP_BUTTON");
175 			loadMapButton.OnClick = () =>
176 			{
177 				SwitchMenu(MenuType.None);
178 				Game.OpenWindow("MAPCHOOSER_PANEL", new WidgetArgs()
179 				{
180 					{ "initialMap", null },
181 					{ "initialTab", MapClassification.User },
182 					{ "onExit", () => SwitchMenu(MenuType.MapEditor) },
183 					{ "onSelect", onSelect },
184 					{ "filter", MapVisibility.Lobby | MapVisibility.Shellmap | MapVisibility.MissionSelector },
185 				});
186 			};
187 
188 			loadMapButton.Disabled = !hasMaps;
189 
190 			mapEditorMenu.Get<ButtonWidget>("BACK_BUTTON").OnClick = () => SwitchMenu(MenuType.Extras);
191 
192 			var newsBG = widget.GetOrNull("NEWS_BG");
193 			if (newsBG != null)
194 			{
195 				newsBG.IsVisible = () => Game.Settings.Game.FetchNews && menuType != MenuType.None && menuType != MenuType.StartupPrompts;
196 
197 				newsPanel = Ui.LoadWidget<ScrollPanelWidget>("NEWS_PANEL", null, new WidgetArgs());
198 				newsTemplate = newsPanel.Get("NEWS_ITEM_TEMPLATE");
199 				newsPanel.RemoveChild(newsTemplate);
200 
201 				newsStatus = newsPanel.Get<LabelWidget>("NEWS_STATUS");
202 				SetNewsStatus("Loading news");
203 			}
204 
205 			Game.OnRemoteDirectConnect += OnRemoteDirectConnect;
206 
207 			// Check for updates in the background
208 			var webServices = modData.Manifest.Get<WebServices>();
209 			if (Game.Settings.Debug.CheckVersion)
210 				webServices.CheckModVersion();
211 
212 			var updateLabel = rootMenu.GetOrNull("UPDATE_NOTICE");
213 			if (updateLabel != null)
214 				updateLabel.IsVisible = () => !newsOpen && menuType != MenuType.None &&
215 					menuType != MenuType.StartupPrompts &&
216 					webServices.ModVersionStatus == ModVersionStatus.Outdated;
217 
218 			var playerProfile = widget.GetOrNull("PLAYER_PROFILE_CONTAINER");
219 			if (playerProfile != null)
220 			{
221 				Func<bool> minimalProfile = () => Ui.CurrentWindow() != null;
222 				Game.LoadWidget(world, "LOCAL_PROFILE_PANEL", playerProfile, new WidgetArgs()
223 				{
224 					{ "minimalProfile", minimalProfile }
225 				});
226 			}
227 
228 			menuType = MenuType.StartupPrompts;
229 
230 			Action onIntroductionComplete = () =>
231 			{
232 				Action onSysInfoComplete = () =>
233 				{
234 					LoadAndDisplayNews(webServices.GameNews, newsBG);
235 					SwitchMenu(MenuType.Main);
236 				};
237 
238 				if (SystemInfoPromptLogic.ShouldShowPrompt())
239 				{
240 					Ui.OpenWindow("MAINMENU_SYSTEM_INFO_PROMPT", new WidgetArgs
241 					{
242 						{ "onComplete", onSysInfoComplete }
243 					});
244 				}
245 				else
246 					onSysInfoComplete();
247 			};
248 
249 			if (IntroductionPromptLogic.ShouldShowPrompt())
250 			{
251 				Game.OpenWindow("MAINMENU_INTRODUCTION_PROMPT", new WidgetArgs
252 				{
253 					{ "onComplete", onIntroductionComplete }
254 				});
255 			}
256 			else
257 				onIntroductionComplete();
258 
259 			Game.OnShellmapLoaded += OpenMenuBasedOnLastGame;
260 		}
261 
LoadAndDisplayNews(string newsURL, Widget newsBG)262 		void LoadAndDisplayNews(string newsURL, Widget newsBG)
263 		{
264 			if (newsBG != null && Game.Settings.Game.FetchNews)
265 			{
266 				var cacheFile = Platform.ResolvePath(Platform.SupportDirPrefix, "news.yaml");
267 				var currentNews = ParseNews(cacheFile);
268 				if (currentNews != null)
269 					DisplayNews(currentNews);
270 
271 				var newsButton = newsBG.GetOrNull<DropDownButtonWidget>("NEWS_BUTTON");
272 				if (newsButton != null)
273 				{
274 					if (!fetchedNews)
275 					{
276 						// Send the mod and engine version to support version-filtered news (update prompts)
277 						newsURL += "?version={0}&mod={1}&modversion={2}".F(
278 							Uri.EscapeUriString(Game.EngineVersion),
279 							Uri.EscapeUriString(Game.ModData.Manifest.Id),
280 							Uri.EscapeUriString(Game.ModData.Manifest.Metadata.Version));
281 
282 						// Parameter string is blank if the player has opted out
283 						newsURL += SystemInfoPromptLogic.CreateParameterString();
284 
285 						new Download(newsURL, cacheFile, e => { },
286 							e => NewsDownloadComplete(e, cacheFile, currentNews,
287 								() => OpenNewsPanel(newsButton)));
288 					}
289 
290 					newsButton.OnClick = () => OpenNewsPanel(newsButton);
291 				}
292 			}
293 		}
294 
OpenNewsPanel(DropDownButtonWidget button)295 		void OpenNewsPanel(DropDownButtonWidget button)
296 		{
297 			newsOpen = true;
298 			button.AttachPanel(newsPanel, () => newsOpen = false);
299 		}
300 
OnRemoteDirectConnect(string host, int port)301 		void OnRemoteDirectConnect(string host, int port)
302 		{
303 			SwitchMenu(MenuType.None);
304 			Ui.OpenWindow("MULTIPLAYER_PANEL", new WidgetArgs
305 			{
306 				{ "onStart", RemoveShellmapUI },
307 				{ "onExit", () => SwitchMenu(MenuType.Main) },
308 				{ "directConnectHost", host },
309 				{ "directConnectPort", port },
310 			});
311 		}
312 
LoadMapIntoEditor(string uid)313 		void LoadMapIntoEditor(string uid)
314 		{
315 			ConnectionLogic.Connect(IPAddress.Loopback.ToString(),
316 				Game.CreateLocalServer(uid),
317 				"",
318 				() => { Game.LoadEditor(uid); },
319 				() => { Game.CloseServer(); SwitchMenu(MenuType.MapEditor); });
320 
321 			lastGameState = MenuPanel.MapEditor;
322 		}
323 
SetNewsStatus(string message)324 		void SetNewsStatus(string message)
325 		{
326 			message = WidgetUtils.WrapText(message, newsStatus.Bounds.Width, Game.Renderer.Fonts[newsStatus.Font]);
327 			newsStatus.GetText = () => message;
328 		}
329 
330 		class NewsItem
331 		{
332 			public string Title;
333 			public string Author;
334 			public DateTime DateTime;
335 			public string Content;
336 		}
337 
ParseNews(string path)338 		NewsItem[] ParseNews(string path)
339 		{
340 			if (!File.Exists(path))
341 				return null;
342 
343 			try
344 			{
345 				return MiniYaml.FromFile(path).Select(node =>
346 				{
347 					var nodesDict = node.Value.ToDictionary();
348 					return new NewsItem
349 					{
350 						Title = nodesDict["Title"].Value,
351 						Author = nodesDict["Author"].Value,
352 						DateTime = FieldLoader.GetValue<DateTime>("DateTime", node.Key),
353 						Content = nodesDict["Content"].Value
354 					};
355 				}).ToArray();
356 			}
357 			catch (Exception ex)
358 			{
359 				SetNewsStatus("Failed to parse news: {0}".F(ex.Message));
360 			}
361 
362 			return null;
363 		}
364 
NewsDownloadComplete(AsyncCompletedEventArgs e, string cacheFile, NewsItem[] oldNews, Action onNewsDownloaded)365 		void NewsDownloadComplete(AsyncCompletedEventArgs e, string cacheFile, NewsItem[] oldNews, Action onNewsDownloaded)
366 		{
367 			Game.RunAfterTick(() => // run on the main thread
368 			{
369 				if (e.Error != null)
370 				{
371 					SetNewsStatus("Failed to retrieve news: {0}".F(Download.FormatErrorMessage(e.Error)));
372 					return;
373 				}
374 
375 				fetchedNews = true;
376 				var newNews = ParseNews(cacheFile);
377 				if (newNews == null)
378 					return;
379 
380 				DisplayNews(newNews);
381 
382 				if (oldNews == null || newNews.Any(n => !oldNews.Select(c => c.DateTime).Contains(n.DateTime)))
383 					onNewsDownloaded();
384 			});
385 		}
386 
DisplayNews(IEnumerable<NewsItem> newsItems)387 		void DisplayNews(IEnumerable<NewsItem> newsItems)
388 		{
389 			newsPanel.RemoveChildren();
390 			SetNewsStatus("");
391 
392 			foreach (var i in newsItems)
393 			{
394 				var item = i;
395 
396 				var newsItem = newsTemplate.Clone();
397 
398 				var titleLabel = newsItem.Get<LabelWidget>("TITLE");
399 				titleLabel.GetText = () => item.Title;
400 
401 				var authorDateTimeLabel = newsItem.Get<LabelWidget>("AUTHOR_DATETIME");
402 				var authorDateTime = authorDateTimeLabel.Text.F(item.Author, item.DateTime.ToLocalTime());
403 				authorDateTimeLabel.GetText = () => authorDateTime;
404 
405 				var contentLabel = newsItem.Get<LabelWidget>("CONTENT");
406 				var content = item.Content.Replace("\\n", "\n");
407 				content = WidgetUtils.WrapText(content, contentLabel.Bounds.Width, Game.Renderer.Fonts[contentLabel.Font]);
408 				contentLabel.GetText = () => content;
409 				contentLabel.Bounds.Height = Game.Renderer.Fonts[contentLabel.Font].Measure(content).Y;
410 				newsItem.Bounds.Height += contentLabel.Bounds.Height;
411 
412 				newsPanel.AddChild(newsItem);
413 				newsPanel.Layout.AdjustChildren();
414 			}
415 		}
416 
RemoveShellmapUI()417 		void RemoveShellmapUI()
418 		{
419 			rootMenu.Parent.RemoveChild(rootMenu);
420 		}
421 
StartSkirmishGame()422 		void StartSkirmishGame()
423 		{
424 			var map = Game.ModData.MapCache.ChooseInitialMap(Game.Settings.Server.Map, Game.CosmeticRandom);
425 			Game.Settings.Server.Map = map;
426 			Game.Settings.Save();
427 
428 			ConnectionLogic.Connect(IPAddress.Loopback.ToString(),
429 				Game.CreateLocalServer(map),
430 				"",
431 				OpenSkirmishLobbyPanel,
432 				() => { Game.CloseServer(); SwitchMenu(MenuType.Main); });
433 		}
434 
OpenMissionBrowserPanel()435 		void OpenMissionBrowserPanel()
436 		{
437 			SwitchMenu(MenuType.None);
438 			Game.OpenWindow("MISSIONBROWSER_PANEL", new WidgetArgs
439 			{
440 				{ "onExit", () => SwitchMenu(MenuType.Singleplayer) },
441 				{ "onStart", () => { RemoveShellmapUI(); lastGameState = MenuPanel.Missions; } }
442 			});
443 		}
444 
OpenSkirmishLobbyPanel()445 		void OpenSkirmishLobbyPanel()
446 		{
447 			SwitchMenu(MenuType.None);
448 			Game.OpenWindow("SERVER_LOBBY", new WidgetArgs
449 			{
450 				{ "onExit", () => { Game.Disconnect(); SwitchMenu(MenuType.Singleplayer); } },
451 				{ "onStart", () => { RemoveShellmapUI(); lastGameState = MenuPanel.Skirmish; } },
452 				{ "skirmishMode", true }
453 			});
454 		}
455 
OpenMultiplayerPanel()456 		void OpenMultiplayerPanel()
457 		{
458 			SwitchMenu(MenuType.None);
459 			Ui.OpenWindow("MULTIPLAYER_PANEL", new WidgetArgs
460 			{
461 				{ "onStart", () => { RemoveShellmapUI(); lastGameState = MenuPanel.Multiplayer; } },
462 				{ "onExit", () => SwitchMenu(MenuType.Main) },
463 				{ "directConnectHost", null },
464 				{ "directConnectPort", 0 },
465 			});
466 		}
467 
OpenReplayBrowserPanel()468 		void OpenReplayBrowserPanel()
469 		{
470 			SwitchMenu(MenuType.None);
471 			Ui.OpenWindow("REPLAYBROWSER_PANEL", new WidgetArgs
472 			{
473 				{ "onExit", () => SwitchMenu(MenuType.Extras) },
474 				{ "onStart", () => { RemoveShellmapUI(); lastGameState = MenuPanel.Replays; } }
475 			});
476 		}
477 
OpenGameSaveBrowserPanel()478 		void OpenGameSaveBrowserPanel()
479 		{
480 			SwitchMenu(MenuType.None);
481 			Ui.OpenWindow("GAMESAVE_BROWSER_PANEL", new WidgetArgs
482 			{
483 				{ "onExit", () => SwitchMenu(MenuType.Singleplayer) },
484 				{ "onStart", () => { RemoveShellmapUI(); lastGameState = MenuPanel.GameSaves; } },
485 				{ "isSavePanel", false },
486 				{ "world", null }
487 			});
488 		}
489 
Dispose(bool disposing)490 		protected override void Dispose(bool disposing)
491 		{
492 			if (disposing)
493 			{
494 				Game.OnRemoteDirectConnect -= OnRemoteDirectConnect;
495 				Game.BeforeGameStart -= RemoveShellmapUI;
496 			}
497 
498 			Game.OnShellmapLoaded -= OpenMenuBasedOnLastGame;
499 			base.Dispose(disposing);
500 		}
501 
OpenMenuBasedOnLastGame()502 		void OpenMenuBasedOnLastGame()
503 		{
504 			switch (lastGameState)
505 			{
506 				case MenuPanel.Missions:
507 					OpenMissionBrowserPanel();
508 					break;
509 
510 				case MenuPanel.Replays:
511 					OpenReplayBrowserPanel();
512 					break;
513 
514 				case MenuPanel.Skirmish:
515 					StartSkirmishGame();
516 					break;
517 
518 				case MenuPanel.Multiplayer:
519 					OpenMultiplayerPanel();
520 					break;
521 
522 				case MenuPanel.MapEditor:
523 					SwitchMenu(MenuType.MapEditor);
524 					break;
525 
526 				case MenuPanel.GameSaves:
527 					SwitchMenu(MenuType.Singleplayer);
528 					break;
529 			}
530 
531 			lastGameState = MenuPanel.None;
532 		}
533 	}
534 }
535