From 96d0a2e9681032d3fcde4370f06d0c8b4c493f95 Mon Sep 17 00:00:00 2001 From: Alex Lambson Date: Mon, 1 Aug 2022 21:48:48 -0600 Subject: [PATCH] - Add a file watcher that will detect when a map is added or deleted, then update the game mode dropdown according to the new map list - Add and use `CustomMapsDirectory` field to `MapLoader` to avoid hard coded strings. - Add `GetLoadedMapBySha1` to remove some duplicate `GameModeMaps.Find()`. - Add a function `RefreshGameModeDropdown` to modify the dropdown after client is loaded. The function is a bit complicated due to doing inline modifications to the items and keeping the selected mode. Issue: https://github.com/CnCNet/xna-cncnet-client/issues/352 PR: https://github.com/CnCNet/xna-cncnet-client/pull/358/ --- DXMainClient/DXGUI/Generic/LoadingScreen.cs | 1 + .../Multiplayer/GameLobby/CnCNetGameLobby.cs | 6 +- .../Multiplayer/GameLobby/GameLobbyBase.cs | 61 +++++ .../GameLobby/MultiplayerGameLobby.cs | 4 +- DXMainClient/Domain/Multiplayer/MapLoader.cs | 208 ++++++++++++++++-- .../Domain/Multiplayer/MapLoaderEventArgs.cs | 18 ++ 6 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 DXMainClient/Domain/Multiplayer/MapLoaderEventArgs.cs diff --git a/DXMainClient/DXGUI/Generic/LoadingScreen.cs b/DXMainClient/DXGUI/Generic/LoadingScreen.cs index 357eb8c8c..eba1f3e11 100644 --- a/DXMainClient/DXGUI/Generic/LoadingScreen.cs +++ b/DXMainClient/DXGUI/Generic/LoadingScreen.cs @@ -81,6 +81,7 @@ private void LoadMaps() { mapLoader = new MapLoader(); mapLoader.LoadMaps(); + mapLoader.StartCustomMapFileWatcher(); } private void Finish() diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs index 7592acb04..a196147ca 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs @@ -1595,14 +1595,14 @@ private void MapSharer_HandleMapDownloadComplete(SHA1EventArgs e) { string mapFileName = MapSharer.GetMapFileName(e.SHA1, e.MapName); Logger.Log("Map " + mapFileName + " downloaded, parsing."); - string mapPath = "Maps/Custom/" + mapFileName; + string mapPath = MapLoader.CustomMapsDirectory + mapFileName; Map map = MapLoader.LoadCustomMap(mapPath, out string returnMessage); if (map != null) { AddNotice(returnMessage); if (lastMapSHA1 == e.SHA1) { - GameModeMap = GameModeMaps.Find(gmm => gmm.Map.SHA1 == lastMapSHA1); + GameModeMap = MapLoader.GetLoadedMapBySha1(lastMapSHA1); ChangeMap(GameModeMap); } } @@ -1804,7 +1804,7 @@ private void DownloadMapByIdCommand(string parameters) sha1 = sha1.Replace("?", ""); // See if the user already has this map, with any filename, before attempting to download it. - GameModeMap loadedMap = GameModeMaps.Find(gmm => gmm.Map.SHA1 == sha1); + GameModeMap loadedMap = MapLoader.GetLoadedMapBySha1(sha1); if (loadedMap != null) { diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs index 59aeca7d2..7bb737f5b 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs @@ -58,7 +58,10 @@ DiscordHandler discordHandler ) : base(windowManager) { _iniSectionName = iniName; + MapLoader = mapLoader; + MapLoader.GameModeMapsUpdated += MapLoader_GameModeMapsUpdated; + this.isMultiplayer = isMultiplayer; this.discordHandler = discordHandler; } @@ -2318,5 +2321,63 @@ public bool LoadGameOptionPreset(string name) } protected abstract bool AllowPlayerOptionsChange(); + + /// + /// Handle the GameModeMapsUpdated event from the MapLoader. + /// + /// Updates the gamemode dropdown for new maps being added while the client is running + /// + /// + /// + private void MapLoader_GameModeMapsUpdated(object sender, MapLoaderEventArgs e) + { + RefreshGameModeDropdown(); + } + + /// + /// Update the gamemode dropdown. + /// + /// Allows us to show gamemodes for maps that were loaded after the client was started. + /// This function will do in-place modifications to `ddGameModeMapFilter.Items`. + /// + public void RefreshGameModeDropdown() + { + // Use a hashset to store the existing gamemodes in the dropdown for instant lookups. + // This is the set of existing dropdown items. Add anything from GameModeMaps, that isn't in this set, to the dropdown. + HashSet existingDdGameModes = new HashSet(ddGameModeMapFilter.Items.Select(ddItem => ddItem.Text)); + // This is the updated list of game modes. Anything not in this set, that is in existingDdGameModes, should be removed from the dropdown. + HashSet gameModeUpdated = new HashSet(GameModeMaps.GameModes.Select(gm => gm.UIName)); + // Don't accidentally remove favorite maps item. + gameModeUpdated.Add(FavoriteMapsLabel); + + XNADropDownItem currentItem = ddGameModeMapFilter.SelectedItem; + + Logger.Log($"Updating game modes dropdown display: lobbyType={this.GetType().Name}"); + + // Add any new game modes. + foreach (GameMode gm in GameModeMaps.GameModes) + { + //skip the game mode if it is already in the dropdown. + if (existingDdGameModes.Contains(gm.UIName)) + continue; + + // If the gamemode was not present, then add it. + ddGameModeMapFilter.AddItem(CreateGameFilterItem(gm.UIName, new GameModeMapFilter(GetGameModeMaps(gm)))); + } + + // Now remove game modes that should no longer be displayed. + ddGameModeMapFilter.Items.RemoveAll(ddItem => !gameModeUpdated.Contains(ddItem.Text)); + + // Make sure we keep the same game mode selected after adding or removing game modes. + // If the game mode is no longer available then switch to 0, aka, favorite maps. + int newIndex = 0; + for (int i = 0; i < ddGameModeMapFilter.Items.Count; i++) + { + if (ddGameModeMapFilter.Items[i].Text == currentItem.Text) + newIndex = i; + } + + ddGameModeMapFilter.SelectedIndex = newIndex; + } } } diff --git a/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs b/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs index 46c48463d..de248a390 100644 --- a/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs +++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/MultiplayerGameLobby.cs @@ -44,7 +44,7 @@ public MultiplayerGameLobby(WindowManager windowManager, string iniName, s => SetMaxAhead(s)), new ChatBoxCommand("PROTOCOLVERSION", "Change ProtocolVersion (default 2) (game host only)".L10N("UI:Main:ChatboxCommandProtocolVersionHelp"), true, s => SetProtocolVersion(s)), - new ChatBoxCommand("LOADMAP", "Load a custom map with given filename from /Maps/Custom/ folder.".L10N("UI:Main:ChatboxCommandLoadMapHelp"), true, LoadCustomMap), + new ChatBoxCommand("LOADMAP", $"Load a custom map with given filename from {MapLoader.CustomMapsDirectory} folder.".L10N("UI:Main:ChatboxCommandLoadMapHelp"), true, LoadCustomMap), new ChatBoxCommand("RANDOMSTARTS", "Enables completely random starting locations (Tiberian Sun based games only).".L10N("UI:Main:ChatboxCommandRandomStartsHelp"), true, s => SetStartingLocationClearance(s)), new ChatBoxCommand("ROLL", "Roll dice, for example /roll 3d6".L10N("UI:Main:ChatboxCommandRollHelp"), false, RollDiceCommand), @@ -487,7 +487,7 @@ private void RollDiceCommand(string dieType) /// Name of the map given as a parameter, without file extension. private void LoadCustomMap(string mapName) { - Map map = MapLoader.LoadCustomMap($"Maps/Custom/{mapName}", out string resultMessage); + Map map = MapLoader.LoadCustomMap($"{MapLoader.CustomMapsDirectory}{mapName}", out string resultMessage); if (map != null) { AddNotice(resultMessage); diff --git a/DXMainClient/Domain/Multiplayer/MapLoader.cs b/DXMainClient/Domain/Multiplayer/MapLoader.cs index 429c7a945..588c72c21 100644 --- a/DXMainClient/Domain/Multiplayer/MapLoader.cs +++ b/DXMainClient/Domain/Multiplayer/MapLoader.cs @@ -21,6 +21,12 @@ public class MapLoader private const string GameModeAliasesSection = "GameModeAliases"; private const int CurrentCustomMapCacheVersion = 1; + /// + /// The relative path to the folder where custom maps are stored. + /// This is the public version of CUSTOM_MAPS_DIRECTORY ending in a slash for convenience. + /// + public const string CustomMapsDirectory = CUSTOM_MAPS_DIRECTORY + "/"; + /// /// List of game modes. /// @@ -33,6 +39,11 @@ public class MapLoader /// public event EventHandler MapLoadingComplete; + /// + /// An event that will be fired when a new map is loaded while the client is already running. + /// + public static event EventHandler GameModeMapsUpdated; + /// /// A list of game mode aliases. /// Every game mode entry that exists in this dictionary will get @@ -46,6 +57,28 @@ public class MapLoader /// private string[] AllowedGameModes = ClientConfiguration.Instance.AllowedCustomGameModes.Split(','); + private FileSystemWatcher customMapFileWatcher; + + /// + /// Check to see if a map matching the sha1 ID is already loaded. + /// + /// The map ID to search the loaded maps for. + /// + public bool IsMapAlreadyLoaded(string sha1) + { + return GetLoadedMapBySha1(sha1) != null; + } + + /// + /// Search the loaded maps for the sha1, return the map if a match is found. + /// + /// The map ID to search the loaded maps for. + /// The map matching the sha1 if one was found. + public GameModeMap GetLoadedMapBySha1(string sha1) + { + return GameModeMaps.Find(gmm => gmm.Map.SHA1 == sha1); + } + /// /// Loads multiplayer map info asynchonously. /// @@ -55,6 +88,121 @@ public void LoadMapsAsync() thread.Start(); } + /// + /// Start the file watcher for the custom map directory. + /// + /// This will refresh the game modes and map lists when a change is detected. + /// + public void StartCustomMapFileWatcher() + { + customMapFileWatcher = new FileSystemWatcher($"{ProgramConstants.GamePath}{CustomMapsDirectory}"); + + customMapFileWatcher.Filter = $"*{MAP_FILE_EXTENSION}"; + customMapFileWatcher.NotifyFilter = NotifyFilters.Attributes + | NotifyFilters.CreationTime + | NotifyFilters.DirectoryName + | NotifyFilters.FileName + | NotifyFilters.LastAccess + | NotifyFilters.LastWrite + | NotifyFilters.Security + | NotifyFilters.Size; + + customMapFileWatcher.Created += HandleCustomMapFolder_Created; + customMapFileWatcher.Deleted += HandleCustomMapFolder_Deleted; + customMapFileWatcher.Renamed += HandleCustomMapFolder_Renamed; + customMapFileWatcher.Error += HandleCustomMapFolder_Error; + + customMapFileWatcher.IncludeSubdirectories = false; + customMapFileWatcher.EnableRaisingEvents = true; + } + + /// + /// Handle a file being moved / copied / created in the custom map directory. + /// + /// Adds the map to the GameModeMaps and updates the UI. + /// + /// Sent by the file system watcher + /// Sent by the file system watcher + public void HandleCustomMapFolder_Created(object sender, FileSystemEventArgs e) + { + // Get the map filename without the extension. + // The extension gets added in LoadCustomMap so we need to excise it to avoid "file.map.map". + string name = e.Name.EndsWith(MAP_FILE_EXTENSION) ? e.Name.Remove(e.Name.Length - MAP_FILE_EXTENSION.Length) : e.Name; + string relativeMapPath = $"{CustomMapsDirectory}{name}"; + Map map = LoadCustomMap(relativeMapPath, out string result); + + if (map == null) + { + Logger.Log($"Failed to load map file that was create / moved: mapPath={name}, reason={result}"); + } + } + + /// + /// Handle a .map file being removed from the custom map directory. + /// + /// This function will attempt to remove the map from the client if it was deleted from the folder + /// + /// Sent by the file system watcher + /// Sent by the file system watcher. + public void HandleCustomMapFolder_Deleted(object sender, FileSystemEventArgs e) + { + Logger.Log($"Map was deleted: map={e.Name}"); + // The way we're detecting the loaded map is hacky, but we don't + // have the sha1 to work with. + foreach (GameMode gameMode in GameModes) + { + gameMode.Maps.RemoveAll(map => map.CompleteFilePath.EndsWith(e.Name)); + } + + RemoveEmptyGameModesAndUpdateGameModeMaps(); + GameModeMapsUpdated?.Invoke(null, new MapLoaderEventArgs(null)); + } + + /// + /// Handle a file being renamed in the custom map folder. + /// + /// If a file is renamed from "something.map" to "somethingelse.map" then there is a high likelyhood + /// that nothing will change in the client because the map data was already loaded. + /// + /// This is mainly here because Final Alert 2 will often export as ".yrm" which requires a rename. + /// + /// + /// + public void HandleCustomMapFolder_Renamed(object sender, RenamedEventArgs e) + { + string name = e.Name.EndsWith(MAP_FILE_EXTENSION) ? e.Name.Remove(e.Name.Length - MAP_FILE_EXTENSION.Length) : e.Name; + string relativeMapPath = $"{CustomMapsDirectory}{name}"; + + // Check if the user is renaming a non ".map" file. + // This is just for logging to help debug. + if (!e.OldName.EndsWith(MAP_FILE_EXTENSION)) + { + Logger.Log($"Renaming file changed the file extension. User is likely renaming a '.yrm' from Final Alert 2: old={e.OldName}, new={e.Name}"); + } + + Map map = LoadCustomMap(relativeMapPath, out string result); + + if (map == null) + { + Logger.Log($"Failed to load renamed map file. Map is likely already loaded: original={e.OldName}, new={e.Name}, reason={result}"); + } + } + + /// + /// Handle errors in the filewatcher. + /// + /// Not much to do other than log a stack trace. + /// + /// + /// + public void HandleCustomMapFolder_Error(object sender, ErrorEventArgs e) + { + Exception exc = e.GetException(); + Logger.Log($"The custom map folder file watcher crashed: error={exc.Message}"); + Logger.Log("Stack Trace:"); + Logger.Log(exc.StackTrace); + } + /// /// Load maps based on INI info as well as those in the custom maps directory. /// @@ -71,12 +219,20 @@ public void LoadMaps() LoadMultiMaps(mpMapsIni); LoadCustomMaps(); - GameModes.RemoveAll(g => g.Maps.Count < 1); - GameModeMaps = new GameModeMapCollection(GameModes); + RemoveEmptyGameModesAndUpdateGameModeMaps(); MapLoadingComplete?.Invoke(this, EventArgs.Empty); } + /// + /// Remove any game modes that do not have any maps loaded and update `GameModeMaps` for the new `GameModes`. + /// + private void RemoveEmptyGameModesAndUpdateGameModeMaps() + { + GameModes.RemoveAll(g => g.Maps.Count < 1); + GameModeMaps = new GameModeMapCollection(GameModes); + } + private void LoadMultiMaps(IniFile mpMapsIni) { List keys = mpMapsIni.GetSectionKeys(MultiMapsSection); @@ -241,12 +397,15 @@ private ConcurrentDictionary LoadCustomMapCache() /// /// Attempts to load a custom map. + /// + /// This should only be used after maps are loaded at startup. /// - /// The path to the map file relative to the game directory. + /// The path to the map file relative to the game directory. Don't include the file-extension. /// When method returns, contains a message reporting whether or not loading the map failed and how. /// The map if loading it was succesful, otherwise false. public Map LoadCustomMap(string mapPath, out string resultMessage) { + // Create the full path to the map file. string customMapFilePath = SafePath.CombineFilePath(ProgramConstants.GamePath, FormattableString.Invariant($"{mapPath}{MAP_FILE_EXTENSION}")); FileInfo customMapFile = SafePath.GetFile(customMapFilePath); @@ -262,34 +421,37 @@ public Map LoadCustomMap(string mapPath, out string resultMessage) Map map = new Map(mapPath, customMapFilePath); - if (map.SetInfoFromCustomMap()) + // Make sure we can get the map info from the .map file. + if (!map.SetInfoFromCustomMap()) { - foreach (GameMode gm in GameModes) - { - if (gm.Maps.Find(m => m.SHA1 == map.SHA1) != null) - { - Logger.Log("LoadCustomMap: Custom map " + customMapFile.FullName + " is already loaded!"); - resultMessage = $"Map {customMapFile.FullName} is already loaded."; + Logger.Log("LoadCustomMap: Loading map " + customMapFile.FullName + " failed!"); + resultMessage = $"Loading map {customMapFile.FullName} failed!"; - return null; - } - } + return null; + } - Logger.Log("LoadCustomMap: Map " + customMapFile.FullName + " added succesfully."); + // Make sure we don't accidentally load the same map twice. + // This checks the sha1, so duplicate maps in two .map files with different filenames can still be detected. + if (IsMapAlreadyLoaded(map.SHA1)) + { + Logger.Log("LoadCustomMap: Custom map " + customMapFile.FullName + " is already loaded!"); + resultMessage = $"Map {customMapFile.FullName} is already loaded."; - AddMapToGameModes(map, true); - var gameModes = GameModes.Where(gm => gm.Maps.Contains(map)); - GameModeMaps.AddRange(gameModes.Select(gm => new GameModeMap(gm, map, false))); + return null; + } - resultMessage = $"Map {customMapFile.FullName} loaded succesfully."; - return map; - } + AddMapToGameModes(map, true); + var gameModes = GameModes.Where(gm => gm.Maps.Contains(map)); + GameModeMaps.AddRange(gameModes.Select(gm => new GameModeMap(gm, map, false))); + + // Notify the UI to update the gamemodes dropdown. + GameModeMapsUpdated?.Invoke(null, new MapLoaderEventArgs(map)); - Logger.Log("LoadCustomMap: Loading map " + customMapFile.FullName + " failed!"); - resultMessage = $"Loading map {customMapFile.FullName} failed!"; + resultMessage = $"Map {customMapFile.FullName} loaded succesfully."; + Logger.Log("LoadCustomMap: Map " + customMapFile.FullName + " added succesfully."); - return null; + return map; } public void DeleteCustomMap(GameModeMap gameModeMap) diff --git a/DXMainClient/Domain/Multiplayer/MapLoaderEventArgs.cs b/DXMainClient/Domain/Multiplayer/MapLoaderEventArgs.cs new file mode 100644 index 000000000..6dbdef636 --- /dev/null +++ b/DXMainClient/Domain/Multiplayer/MapLoaderEventArgs.cs @@ -0,0 +1,18 @@ +using System; + +namespace DTAClient.Domain.Multiplayer +{ + /// + /// Events args for MapLoader.GameModeMapsUpdated events. + /// + public class MapLoaderEventArgs : EventArgs + { + public MapLoaderEventArgs(Map map) + { + Map = map; + } + + public Map Map { get; private set; } + + } +} \ No newline at end of file