diff --git a/DXMainClient/DXGUI/Generic/LoadingScreen.cs b/DXMainClient/DXGUI/Generic/LoadingScreen.cs
index 8753ac524..7666c41ec 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 f44c8171b..6aedf0603 100644
--- a/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs
+++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/CnCNetGameLobby.cs
@@ -1594,14 +1594,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);
}
}
@@ -1803,7 +1803,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 b47a68617..46a9230be 100644
--- a/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs
+++ b/DXMainClient/DXGUI/Multiplayer/GameLobby/GameLobbyBase.cs
@@ -59,7 +59,10 @@ DiscordHandler discordHandler
) : base(windowManager)
{
_iniSectionName = iniName;
+
MapLoader = mapLoader;
+ MapLoader.GameModeMapsUpdated += MapLoader_GameModeMapsUpdated;
+
this.isMultiplayer = isMultiplayer;
this.discordHandler = discordHandler;
}
@@ -2315,5 +2318,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 54c67a3b5..4f95870f8 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/DXMainClient.csproj b/DXMainClient/DXMainClient.csproj
index 6971636eb..57d072953 100644
--- a/DXMainClient/DXMainClient.csproj
+++ b/DXMainClient/DXMainClient.csproj
@@ -432,6 +432,7 @@
+
diff --git a/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs b/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs
index 48bb79b70..67b3e7964 100644
--- a/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs
+++ b/DXMainClient/Domain/Multiplayer/CnCNet/MapSharer.cs
@@ -121,7 +121,7 @@ private static string MapUpload(string _URL, Map map, string gameName, out bool
{
ServicePointManager.Expect100Continue = false;
- string zipFile = ProgramConstants.GamePath + "Maps/Custom/" + map.SHA1 + ".zip";
+ string zipFile = ProgramConstants.GamePath + MapLoader.CustomMapsDirectory + map.SHA1 + ".zip";
if (File.Exists(zipFile)) File.Delete(zipFile);
@@ -380,7 +380,7 @@ public static string GetMapFileName(string sha1, string mapName)
private static string DownloadMain(string sha1, string myGame, string mapName, out bool success)
{
- string customMapsDirectory = ProgramConstants.GamePath + "Maps/Custom/";
+ string customMapsDirectory = ProgramConstants.GamePath + MapLoader.CustomMapsDirectory;
string mapFileName = GetMapFileName(sha1, mapName);
diff --git a/DXMainClient/Domain/Multiplayer/MapLoader.cs b/DXMainClient/Domain/Multiplayer/MapLoader.cs
index 4ca76a55d..93aeda0de 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.
///
@@ -69,12 +217,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);
@@ -233,13 +389,18 @@ 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)
{
- if (!File.Exists(ProgramConstants.GamePath + mapPath + MAP_FILE_EXTENSION))
+ // Create the full path to the map file.
+ string fullPath = $"{ProgramConstants.GamePath}{mapPath}{MAP_FILE_EXTENSION}";
+
+ if (!File.Exists(fullPath))
{
Logger.Log("LoadCustomMap: Map " + mapPath + " not found!");
resultMessage = $"Map file {mapPath}{MAP_FILE_EXTENSION} doesn't exist!";
@@ -248,37 +409,40 @@ public Map LoadCustomMap(string mapPath, out string resultMessage)
}
Logger.Log("LoadCustomMap: Loading custom map " + mapPath);
- var iniPath = ProgramConstants.GamePath + mapPath + MAP_FILE_EXTENSION;
+ var iniPath = fullPath;
Map map = new Map(mapPath, iniPath);
- 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 " + mapPath + " is already loaded!");
- resultMessage = $"Map {mapPath} is already loaded.";
+ Logger.Log("LoadCustomMap: Loading map " + mapPath + " failed!");
+ resultMessage = $"Loading map {mapPath} failed!";
- return null;
- }
- }
+ return null;
+ }
- Logger.Log("LoadCustomMap: Map " + mapPath + " 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 " + mapPath + " is already loaded!");
+ resultMessage = $"Map {mapPath} 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 {mapPath} loaded succesfully.";
+ Logger.Log("LoadCustomMap: Map " + mapPath + " added 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 " + mapPath + " failed!");
- resultMessage = $"Loading map {mapPath} failed!";
+ resultMessage = $"Map {mapPath} 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