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..a876ff8fa 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;
+ }
+
+ Logger.Log("LoadCustomMap: Map " + customMapFile.FullName + " added succesfully.");
- resultMessage = $"Map {customMapFile.FullName} loaded succesfully.";
+ AddMapToGameModes(map, true);
+ var gameModes = GameModes.Where(gm => gm.Maps.Contains(map));
+ GameModeMaps.AddRange(gameModes.Select(gm => new GameModeMap(gm, map, false)));
- return map;
- }
+ // 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.";
- 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