From 1485f2af535c4bc074d6fc1ceeb0797711eb8cfd Mon Sep 17 00:00:00 2001 From: SadPencil Date: Tue, 17 Sep 2024 16:05:32 +0800 Subject: [PATCH] Implement custom mission support with CampaignTagSelector --- ClientCore/ClientConfiguration.cs | 7 + ClientGUI/INItializableWindow.cs | 44 ++-- DXMainClient/DXGUI/GameClass.cs | 2 + .../DXGUI/Generic/CampaignSelector.cs | 202 ++++++++++++++---- .../DXGUI/Generic/CampaignTagSelector.cs | 88 ++++++++ .../DXGUI/Generic/GameLoadingWindow.cs | 22 +- DXMainClient/DXGUI/Generic/MainMenu.cs | 18 +- .../DXGUI/Generic/MainMenuDarkeningPanel.cs | 8 - DXMainClient/Domain/CustomMissionHelper.cs | 52 +++++ DXMainClient/Domain/Mission.cs | 114 +++++++--- DXMainClient/Domain/SavedGame.cs | 28 ++- DXMainClient/PreStartup.cs | 4 + 12 files changed, 471 insertions(+), 118 deletions(-) create mode 100644 DXMainClient/DXGUI/Generic/CampaignTagSelector.cs create mode 100644 DXMainClient/Domain/CustomMissionHelper.cs diff --git a/ClientCore/ClientConfiguration.cs b/ClientCore/ClientConfiguration.cs index d80fb6a05..72f0be937 100644 --- a/ClientCore/ClientConfiguration.cs +++ b/ClientCore/ClientConfiguration.cs @@ -328,6 +328,8 @@ private List ParseTranslationGameFiles() public string AllowedCustomGameModes => clientDefinitionsIni.GetStringValue(SETTINGS, "AllowedCustomGameModes", "Standard,Custom Map"); + public bool CampaignTagSelectorEnabled => clientDefinitionsIni.GetBooleanValue(SETTINGS, "CampaignTagSelectorEnabled", false); + public string GetGameExecutableName() { string[] exeNames = clientDefinitionsIni.GetStringValue(SETTINGS, "GameExecutableNames", "Game.exe").Split(','); @@ -420,6 +422,11 @@ public List GetIRCServers() public bool DiscordIntegrationGloballyDisabled => string.IsNullOrWhiteSpace(DiscordAppId) || DisableDiscordIntegration; + public string CustomMissionPath => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionPath", "Maps/CustomMissions"); + public string CustomMissionCsfName => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionCsfName", "stringtable99.csf"); + public string CustomMissionPalName => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionPalName", "custommission.pal"); + public string CustomMissionShpName => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionShpName", "custommission.shp"); + public OSVersion GetOperatingSystemVersion() { #if NETFRAMEWORK diff --git a/ClientGUI/INItializableWindow.cs b/ClientGUI/INItializableWindow.cs index 84ebfed40..58b6ef9eb 100644 --- a/ClientGUI/INItializableWindow.cs +++ b/ClientGUI/INItializableWindow.cs @@ -29,29 +29,43 @@ public INItializableWindow(WindowManager windowManager) : base(windowManager) /// instead of the window's name. /// protected string IniNameOverride { get; set; } + private bool VisitChild(IEnumerable list, Func judge) + { + foreach (XNAControl child in list) + { + bool stop = judge(child); + if (stop) return true; + stop = VisitChild(child.Children, judge); + if (stop) return true; + } + return false; + } public T FindChild(string childName, bool optional = false) where T : XNAControl { - T child = FindChild(Children, childName); - if (child == null && !optional) + XNAControl result = null; + VisitChild(new List() { this }, control => + { + if (control.Name != childName) return false; + result = control; + return true; + }); + if (result == null && !optional) throw new KeyNotFoundException("Could not find required child control: " + childName); - - return child; + return (T)result; } - private T FindChild(IEnumerable list, string controlName) where T : XNAControl + public List FindChildrenStartWith(string prefix) where T : XNAControl { - foreach (XNAControl child in list) + List result = new List(); + VisitChild(new List() { this }, (control) => { - if (child.Name == controlName) - return (T)child; - - T childOfChild = FindChild(child.Children, controlName); - if (childOfChild != null) - return childOfChild; - } - - return null; + if (string.IsNullOrEmpty(prefix) || + !string.IsNullOrEmpty(control.Name) && control.Name.StartsWith(prefix)) + result.Add((T)control); + return false; + }); + return result; } /// diff --git a/DXMainClient/DXGUI/GameClass.cs b/DXMainClient/DXGUI/GameClass.cs index ee269228a..a7f5b2480 100644 --- a/DXMainClient/DXGUI/GameClass.cs +++ b/DXMainClient/DXGUI/GameClass.cs @@ -235,6 +235,8 @@ private IServiceProvider BuildServiceProvider(WindowManager windowManager) .AddSingletonXnaControl() .AddSingletonXnaControl() .AddSingletonXnaControl() + .AddSingletonXnaControl() + .AddSingletonXnaControl() .AddSingletonXnaControl() .AddSingletonXnaControl() .AddSingletonXnaControl() diff --git a/DXMainClient/DXGUI/Generic/CampaignSelector.cs b/DXMainClient/DXGUI/Generic/CampaignSelector.cs index a1fe6840e..b617acb50 100644 --- a/DXMainClient/DXGUI/Generic/CampaignSelector.cs +++ b/DXMainClient/DXGUI/Generic/CampaignSelector.cs @@ -1,15 +1,18 @@ -using ClientCore; -using Microsoft.Xna.Framework; -using System; +using System; using System.Collections.Generic; -using DTAClient.Domain; using System.IO; +using System.Linq; +using ClientCore; using ClientGUI; -using Rampastring.XNAUI.XNAControls; -using Rampastring.XNAUI; -using Rampastring.Tools; using ClientUpdater; using ClientCore.Extensions; +using DTAClient.Domain; +using Microsoft.Xna.Framework; +using Rampastring.Tools; +using Rampastring.XNAUI; +using Rampastring.XNAUI.XNAControls; +using System.Diagnostics; +using System.Globalization; namespace DTAClient.DXGUI.Generic { @@ -34,7 +37,7 @@ public CampaignSelector(WindowManager windowManager, DiscordHandler discordHandl private DiscordHandler discordHandler; - private List Missions = new List(); + private List lbCampaignListMissions = new List(); private XNAListBox lbCampaignList; private XNAClientButton btnLaunch; private XNATextBlock tbMissionDescription; @@ -57,6 +60,30 @@ public CampaignSelector(WindowManager windowManager, DiscordHandler discordHandl private Mission missionToLaunch; + private List _allMissions = []; + public IReadOnlyCollection AllMissions { get => _allMissions; } + + private Dictionary _uniqueIDToMissions = new(); + public IReadOnlyDictionary UniqueIDToMissions => _uniqueIDToMissions; + + private void AddMission(Mission mission) + { + // no matter whether the key is duplicated, the mission is always added to AllMissions + _allMissions.Add(mission); + + // but only the first mission is recorded in UniqueIDToMissions + if (_uniqueIDToMissions.ContainsKey(mission.CustomMissionID)) + { + Logger.Log($"CampaignSelector: duplicated mission. CodeName: {mission.CodeName}. ID: {mission.CustomMissionID}. Description: {mission.UntranslatedGUIName}."); + if (!string.IsNullOrEmpty(mission.Scenario)) + mission.Enabled = false; + } + else + { + _uniqueIDToMissions.Add(mission.CustomMissionID, mission); + } + } + public override void Initialize() { BackgroundTexture = AssetLoader.LoadTexture("missionselectorbg.png"); @@ -199,7 +226,7 @@ private void LbCampaignList_SelectedIndexChanged(object sender, EventArgs e) return; } - Mission mission = Missions[lbCampaignList.SelectedIndex]; + Mission mission = lbCampaignListMissions[lbCampaignList.SelectedIndex]; if (string.IsNullOrEmpty(mission.Scenario)) { @@ -221,14 +248,14 @@ private void LbCampaignList_SelectedIndexChanged(object sender, EventArgs e) private void BtnCancel_LeftClick(object sender, EventArgs e) { - Enabled = false; + Disable(); } private void BtnLaunch_LeftClick(object sender, EventArgs e) { int selectedMissionId = lbCampaignList.SelectedIndex; - Mission mission = Missions[selectedMissionId]; + Mission mission = lbCampaignListMissions[selectedMissionId]; if (!ClientConfiguration.Instance.ModMode && (!Updater.IsFileNonexistantOrOriginal(mission.Scenario) || AreFilesModified())) @@ -267,45 +294,71 @@ private void CheaterWindow_YesClicked(object sender, EventArgs e) /// private void LaunchMission(Mission mission) { + CustomMissionHelper.DeleteSupplementalMissionFiles(); + CustomMissionHelper.CopySupplementalMissionFiles(mission); + + string scenario = mission.Scenario; + bool copyMapsToSpawnmapINI = ClientConfiguration.Instance.CopyMissionsToSpawnmapINI; Logger.Log("About to write spawn.ini."); - using (var spawnStreamWriter = new StreamWriter(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawn.ini"))) + IniFile spawnIni = new() { - spawnStreamWriter.WriteLine("; Generated by DTA Client"); - spawnStreamWriter.WriteLine("[Settings]"); - if (copyMapsToSpawnmapINI) - spawnStreamWriter.WriteLine("Scenario=spawnmap.ini"); - else - spawnStreamWriter.WriteLine("Scenario=" + mission.Scenario); + Comment = "Generated by CnCNet Client" + }; + IniSection spawnIniSettings = new("Settings"); - // No one wants to play missions on Fastest, so we'll change it to Faster - if (UserINISettings.Instance.GameSpeed == 0) - UserINISettings.Instance.GameSpeed.Value = 1; + if (copyMapsToSpawnmapINI) + spawnIniSettings.AddKey("Scenario", "spawnmap.ini"); + else + spawnIniSettings.AddKey("Scenario", scenario); + + // No one wants to play missions on Fastest, so we'll change it to Faster + if (UserINISettings.Instance.GameSpeed == 0) + UserINISettings.Instance.GameSpeed.Value = 1; - spawnStreamWriter.WriteLine("CampaignID=" + mission.CampaignID); - spawnStreamWriter.WriteLine("GameSpeed=" + UserINISettings.Instance.GameSpeed); + spawnIniSettings.AddKey("CampaignID", mission.CampaignID.ToString(CultureInfo.InvariantCulture)); + spawnIniSettings.AddKey("GameSpeed", UserINISettings.Instance.GameSpeed.ToString()); #if YR || ARES - spawnStreamWriter.WriteLine("Ra2Mode=" + !mission.RequiredAddon); + spawnIniSettings.AddKey("Ra2Mode", (!mission.RequiredAddon).ToString(CultureInfo.InvariantCulture)); #else - spawnStreamWriter.WriteLine("Firestorm=" + mission.RequiredAddon); + spawnIniSettings.AddKey("Firestorm", mission.RequiredAddon.ToString(CultureInfo.InvariantCulture)); #endif - spawnStreamWriter.WriteLine("CustomLoadScreen=" + LoadingScreenController.GetLoadScreenName(mission.Side.ToString())); - spawnStreamWriter.WriteLine("IsSinglePlayer=Yes"); - spawnStreamWriter.WriteLine("SidebarHack=" + ClientConfiguration.Instance.SidebarHack); - spawnStreamWriter.WriteLine("Side=" + mission.Side); - spawnStreamWriter.WriteLine("BuildOffAlly=" + mission.BuildOffAlly); - UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; + spawnIniSettings.AddKey("CustomLoadScreen", LoadingScreenController.GetLoadScreenName(mission.Side.ToString())); - spawnStreamWriter.WriteLine("DifficultyModeHuman=" + (mission.PlayerAlwaysOnNormalDifficulty ? "1" : trbDifficultySelector.Value.ToString())); - spawnStreamWriter.WriteLine("DifficultyModeComputer=" + GetComputerDifficulty()); + spawnIniSettings.AddKey("IsSinglePlayer", "Yes"); + spawnIniSettings.AddKey("SidebarHack", ClientConfiguration.Instance.SidebarHack.ToString(CultureInfo.InvariantCulture)); + spawnIniSettings.AddKey("Side", mission.Side.ToString(CultureInfo.InvariantCulture)); + spawnIniSettings.AddKey("BuildOffAlly", mission.BuildOffAlly.ToString(CultureInfo.InvariantCulture)); + + UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; - spawnStreamWriter.WriteLine(); - spawnStreamWriter.WriteLine(); - spawnStreamWriter.WriteLine(); + spawnIniSettings.AddKey("DifficultyModeHuman", mission.PlayerAlwaysOnNormalDifficulty ? "1" : trbDifficultySelector.Value.ToString(CultureInfo.InvariantCulture)); + spawnIniSettings.AddKey("DifficultyModeComputer", GetComputerDifficulty().ToString(CultureInfo.InvariantCulture)); + + if (mission.IsCustomMission) + { + spawnIniSettings.AddKey("CustomMissionID", mission.CustomMissionID.ToString(CultureInfo.InvariantCulture)); } + spawnIni.AddSection(spawnIniSettings); + + if (mission.IsCustomMission && mission.CustomMission_MissionMdIniSection is not null) + { + // copy an IniSection + IniSection spawnIniMissionIniSection = new(scenario); + foreach (var kvp in mission.CustomMission_MissionMdIniSection.Keys) + { + spawnIniMissionIniSection.AddKey(kvp.Key, kvp.Value); + } + + // append the new IniSection + spawnIni.AddSection(spawnIniMissionIniSection); + } + + spawnIni.WriteIniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawn.ini")); + var difficultyIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, DifficultyIniPaths[trbDifficultySelector.Value])); string difficultyName = DifficultyNames[trbDifficultySelector.Value]; @@ -319,7 +372,7 @@ private void LaunchMission(Mission mission) UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; UserINISettings.Instance.SaveSettings(); - ((MainMenuDarkeningPanel)Parent).Hide(); + Disable(); discordHandler.UpdatePresence(mission.UntranslatedGUIName, difficultyName, mission.IconPath, true); GameProcessLogic.GameProcessExited += GameProcessExited_Callback; @@ -338,6 +391,9 @@ private void GameProcessExited_Callback() protected virtual void GameProcessExited() { GameProcessLogic.GameProcessExited -= GameProcessExited_Callback; + + CustomMissionHelper.DeleteSupplementalMissionFiles(); + // Logger.Log("GameProcessExited: Updating Discord Presence."); discordHandler.UpdatePresence(); } @@ -346,8 +402,36 @@ private void ReadMissionList() { ParseBattleIni("INI/Battle.ini"); - if (Missions.Count == 0) + if (AllMissions.Count == 0) ParseBattleIni("INI/" + ClientConfiguration.Instance.BattleFSFileName); + + LoadCustomMissions(); + + LoadMissionsWithFilter(null); + } + + private void LoadCustomMissions() + { + string customMissionsDirectory = SafePath.CombineDirectoryPath(ProgramConstants.GamePath, ClientConfiguration.Instance.CustomMissionPath); + if (!Directory.Exists(customMissionsDirectory)) + return; + + string[] mapFiles = Directory.GetFiles(customMissionsDirectory, "*.map"); + foreach (string mapFilePath in mapFiles) + { + var mapFile = new IniFile(mapFilePath); + + IniSection missionSection = mapFile.GetSection("CNCNET:MISSION:BATTLE.INI"); + if (missionSection is null) + continue; + + IniSection? missionMdIniSection = mapFile.GetSection("CNCNET:MISSION:MISSION.INI"); + + string filename = new FileInfo(mapFilePath).Name; + string scenario = SafePath.CombineFilePath(ClientConfiguration.Instance.CustomMissionPath, filename); + Mission mission = Mission.NewCustomMission(missionSection, missionCodeName: filename.ToUpperInvariant(), scenario, missionMdIniSection); + AddMission(mission); + } } /// @@ -366,7 +450,7 @@ private bool ParseBattleIni(string path) return false; } - if (Missions.Count > 0) + if (lbCampaignListMissions.Count > 0) { throw new InvalidOperationException("Loading multiple Battle*.ini files is not supported anymore."); } @@ -386,10 +470,40 @@ private bool ParseBattleIni(string path) if (!battleIni.SectionExists(battleSection)) continue; - var mission = new Mission(battleIni, battleSection, i); + var mission = new Mission(battleIni.GetSection(battleSection), missionCodeName: battleEntry); + AddMission(mission); + } + + Logger.Log("Finished parsing " + path + "."); + return true; + } + + /// + /// Load or re-load missons with selected tags. + /// + /// Missions with at lease one of which tags to be shown. As an exception, null means show all missions. + public void LoadMissionsWithFilter(ISet selectedTags = null) + { + lbCampaignListMissions.Clear(); - Missions.Add(mission); + lbCampaignList.IsChangingSize = true; + lbCampaignList.Clear(); + lbCampaignList.SelectedIndex = -1; + + // The following two lines are handled by LbCampaignList_SelectedIndexChanged + // tbMissionDescription.Text = string.Empty; + // btnLaunch.AllowClick = false; + + // Select missions with the filter + IEnumerable missions = AllMissions; + if (selectedTags != null) + missions = missions.Where(mission => mission.Tags.Intersect(selectedTags).Any()).ToList(); + lbCampaignListMissions = missions.ToList(); + + // Update lbCampaignList with selected missions + foreach (Mission mission in lbCampaignListMissions) + { var item = new XNAListBoxItem(); item.Text = mission.GUIName; if (!mission.Enabled) @@ -414,10 +528,10 @@ private bool ParseBattleIni(string path) lbCampaignList.AddItem(item); } - Logger.Log("Finished parsing " + path + "."); - return true; - } + lbCampaignList.IsChangingSize = false; + lbCampaignList.TopIndex = 0; + } public override void Draw(GameTime gameTime) { base.Draw(gameTime); diff --git a/DXMainClient/DXGUI/Generic/CampaignTagSelector.cs b/DXMainClient/DXGUI/Generic/CampaignTagSelector.cs new file mode 100644 index 000000000..56e7dae64 --- /dev/null +++ b/DXMainClient/DXGUI/Generic/CampaignTagSelector.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using ClientCore; +using ClientGUI; +using DTAClient.Domain; +using Microsoft.Xna.Framework; +using Rampastring.XNAUI; + +namespace DTAClient.DXGUI.Generic +{ + public class CampaignTagSelector : INItializableWindow + { + private const int DEFAULT_WIDTH = 576; + private const int DEFAULT_HEIGHT = 475; + private string _iniSectionName = nameof(CampaignTagSelector); + private DiscordHandler discordHandler; + + public CampaignTagSelector(WindowManager windowManager, DiscordHandler discordHandler) + : base(windowManager) + { + this.discordHandler = discordHandler; + } + + public IReadOnlyDictionary UniqueIDToMissions => this.CampaignSelector.UniqueIDToMissions; + public IReadOnlyCollection AllMissions => this.CampaignSelector.AllMissions; + + protected XNAClientButton btnCancel; + protected XNAClientButton btnShowAllMission; + protected XNAClientButton btnShowCustomMission; + public override void Initialize() + { + CampaignSelector = new CampaignSelector(WindowManager, discordHandler); + DarkeningPanel.AddAndInitializeWithControl(WindowManager, CampaignSelector); + CampaignSelector.Disable(); + + Name = _iniSectionName; + + if (!ClientConfiguration.Instance.CampaignTagSelectorEnabled) + return; + + ClientRectangle = new Rectangle(0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT); + BorderColor = UISettings.ActiveSettings.PanelBorderColor; + + base.Initialize(); + + WindowManager.CenterControlOnScreen(this); + + btnCancel = FindChild(nameof(btnCancel)); + btnCancel.LeftClick += BtnCancel_LeftClick; + + btnShowAllMission = FindChild(nameof(btnShowAllMission)); + btnShowAllMission.LeftClick += (sender, e) => + { + CampaignSelector.LoadMissionsWithFilter(null); + CampaignSelector.Enable(); + Disable(); + }; + + const string TagButtonsPrefix = "ButtonTag_"; + var tagButtons = FindChildrenStartWith(TagButtonsPrefix); + foreach (var tagButton in tagButtons) + { + string tagName = tagButton.Name.Substring(TagButtonsPrefix.Length); + tagButton.LeftClick += (sender, e) => + { + CampaignSelector.LoadMissionsWithFilter(new HashSet() { tagName }); + CampaignSelector.Enable(); + Disable(); + }; + } + } + + private void BtnCancel_LeftClick(object sender, EventArgs e) + { + Disable(); + } + + public void Open() + { + if (ClientConfiguration.Instance.CampaignTagSelectorEnabled) + Enable(); + else + CampaignSelector.Enable(); + } + + private CampaignSelector CampaignSelector; + } +} \ No newline at end of file diff --git a/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs b/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs index 0fdc36718..34274da38 100644 --- a/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs +++ b/DXMainClient/DXGUI/Generic/GameLoadingWindow.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Diagnostics; namespace DTAClient.DXGUI.Generic { @@ -20,12 +21,14 @@ public class GameLoadingWindow : XNAWindow { private const string SAVED_GAMES_DIRECTORY = "Saved Games"; - public GameLoadingWindow(WindowManager windowManager, DiscordHandler discordHandler) : base(windowManager) + public GameLoadingWindow(WindowManager windowManager, DiscordHandler discordHandler, CampaignTagSelector campaignTagSelector) : base(windowManager) { this.discordHandler = discordHandler; + this.campaignTagSelector = campaignTagSelector; } private DiscordHandler discordHandler; + private CampaignTagSelector campaignTagSelector; private XNAMultiColumnListBox lbSaveGameList; private XNAClientButton btnLaunch; @@ -96,9 +99,14 @@ private void ListBox_SelectedIndexChanged(object sender, EventArgs e) } } + public void Open() + { + Enable(); + } + private void BtnCancel_LeftClick(object sender, EventArgs e) { - Enabled = false; + Disable(); } private void BtnLaunch_LeftClick(object sender, EventArgs e) @@ -106,6 +114,12 @@ private void BtnLaunch_LeftClick(object sender, EventArgs e) SavedGame sg = savedGames[lbSaveGameList.SelectedIndex]; Logger.Log("Loading saved game " + sg.FileName); + Mission mission = campaignTagSelector.UniqueIDToMissions.GetValueOrDefault(sg.CustomMissionID, null); + + CustomMissionHelper.DeleteSupplementalMissionFiles(); + if (mission != null) + CustomMissionHelper.CopySupplementalMissionFiles(mission); + FileInfo spawnerSettingsFile = SafePath.GetFile(ProgramConstants.GamePath, ProgramConstants.SPAWNER_SETTINGS); if (spawnerSettingsFile.Exists) @@ -178,6 +192,9 @@ private void GameProcessExited_Callback() protected virtual void GameProcessExited() { GameProcessLogic.GameProcessExited -= GameProcessExited_Callback; + + CustomMissionHelper.DeleteSupplementalMissionFiles(); + discordHandler.UpdatePresence(); } @@ -199,6 +216,7 @@ public void ListSaves() foreach (FileInfo file in files) { + // note: ParseSaveGame modify savedGames ParseSaveGame(file.FullName); } diff --git a/DXMainClient/DXGUI/Generic/MainMenu.cs b/DXMainClient/DXGUI/Generic/MainMenu.cs index 8c0749a38..3070ed732 100644 --- a/DXMainClient/DXGUI/Generic/MainMenu.cs +++ b/DXMainClient/DXGUI/Generic/MainMenu.cs @@ -44,6 +44,8 @@ public MainMenu( TopBar topBar, OptionsWindow optionsWindow, CnCNetLobby cncnetLobby, + CampaignTagSelector campaignTagSelector, + GameLoadingWindow gameLoadingWindow, CnCNetManager connectionManager, DiscordHandler discordHandler, CnCNetGameLoadingLobby cnCNetGameLoadingLobby, @@ -59,6 +61,8 @@ MapLoader mapLoader this.connectionManager = connectionManager; this.optionsWindow = optionsWindow; this.cncnetLobby = cncnetLobby; + this.campaignTagSelector = campaignTagSelector; + this.gameLoadingWindow = gameLoadingWindow; this.discordHandler = discordHandler; this.skirmishLobby = skirmishLobby; this.cnCNetGameLoadingLobby = cnCNetGameLoadingLobby; @@ -81,6 +85,9 @@ MapLoader mapLoader private SkirmishLobby skirmishLobby; + private CampaignTagSelector campaignTagSelector; + private GameLoadingWindow gameLoadingWindow; + private LANLobby lanLobby; private CnCNetManager connectionManager; @@ -553,6 +560,8 @@ private void Clean() /// public void PostInit() { + DarkeningPanel.AddAndInitializeWithControl(WindowManager, campaignTagSelector); + DarkeningPanel.AddAndInitializeWithControl(WindowManager, gameLoadingWindow); DarkeningPanel.AddAndInitializeWithControl(WindowManager, skirmishLobby); DarkeningPanel.AddAndInitializeWithControl(WindowManager, cnCNetGameLoadingLobby); DarkeningPanel.AddAndInitializeWithControl(WindowManager, cnCNetGameLobby); @@ -566,6 +575,8 @@ public void PostInit() topBar.SetOptionsWindow(optionsWindow); WindowManager.AddAndInitializeControl(gameInProgressWindow); + campaignTagSelector.Disable(); + gameLoadingWindow.Disable(); skirmishLobby.Disable(); cncnetLobby.Disable(); cnCNetGameLobby.Disable(); @@ -829,10 +840,10 @@ private void BtnOptions_LeftClick(object sender, EventArgs e) => optionsWindow.Open(); private void BtnNewCampaign_LeftClick(object sender, EventArgs e) - => innerPanel.Show(innerPanel.CampaignSelector); + => campaignTagSelector.Open(); private void BtnLoadGame_LeftClick(object sender, EventArgs e) - => innerPanel.Show(innerPanel.GameLoadingWindow); + => gameLoadingWindow.Open(); private void BtnLan_LeftClick(object sender, EventArgs e) { @@ -883,7 +894,8 @@ private void SharedUILogic_GameProcessExited() => private void HandleGameProcessExited() { - innerPanel.GameLoadingWindow.ListSaves(); + gameLoadingWindow.ListSaves(); + gameLoadingWindow.Disable(); innerPanel.Hide(); // If music is disabled on menus, check if the main menu is the top-most diff --git a/DXMainClient/DXGUI/Generic/MainMenuDarkeningPanel.cs b/DXMainClient/DXGUI/Generic/MainMenuDarkeningPanel.cs index 6b65f0a3a..3d33d640b 100644 --- a/DXMainClient/DXGUI/Generic/MainMenuDarkeningPanel.cs +++ b/DXMainClient/DXGUI/Generic/MainMenuDarkeningPanel.cs @@ -26,8 +26,6 @@ public MainMenuDarkeningPanel(WindowManager windowManager, DiscordHandler discor private DiscordHandler discordHandler; private MapLoader mapLoader; - public CampaignSelector CampaignSelector; - public GameLoadingWindow GameLoadingWindow; public StatisticsWindow StatisticsWindow; public UpdateQueryWindow UpdateQueryWindow; public ManualUpdateQueryWindow ManualUpdateQueryWindow; @@ -44,12 +42,6 @@ public override void Initialize() PanelBackgroundDrawMode = PanelBackgroundImageDrawMode.STRETCHED; Alpha = 1.0f; - CampaignSelector = new CampaignSelector(WindowManager, discordHandler); - AddChild(CampaignSelector); - - GameLoadingWindow = new GameLoadingWindow(WindowManager, discordHandler); - AddChild(GameLoadingWindow); - StatisticsWindow = new StatisticsWindow(WindowManager, mapLoader); AddChild(StatisticsWindow); diff --git a/DXMainClient/Domain/CustomMissionHelper.cs b/DXMainClient/Domain/CustomMissionHelper.cs new file mode 100644 index 000000000..269578335 --- /dev/null +++ b/DXMainClient/Domain/CustomMissionHelper.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using ClientCore; +using Rampastring.Tools; + +namespace DTAClient.Domain; +internal static class CustomMissionHelper +{ + + public static void DeleteSupplementalMissionFiles() + { + DirectoryInfo gameDirectory = SafePath.GetDirectory(ProgramConstants.GamePath); + foreach (string filename in new string[] + { + ClientConfiguration.Instance.CustomMissionCsfName, + ClientConfiguration.Instance.CustomMissionPalName, + ClientConfiguration.Instance.CustomMissionShpName, + }) + { + gameDirectory.EnumerateFiles(filename).SingleOrDefault()?.Delete(); + } + } + + public static void CopySupplementalMissionFiles(Mission mission) + { + DeleteSupplementalMissionFiles(); + + if (mission.IsCustomMission) + { + string missionFileName = mission.Scenario; + Debug.Assert(missionFileName.EndsWith(".map", StringComparison.InvariantCultureIgnoreCase), "Mission file should have the extension \".map\"."); + + // copy the CSF file if exists + foreach ((string ext, string filename) in new (string, string)[] + { + ("csf", ClientConfiguration.Instance.CustomMissionCsfName), + ("pal", ClientConfiguration.Instance.CustomMissionPalName), + ("shp", ClientConfiguration.Instance.CustomMissionShpName), + }) + { + string sourceFileName = missionFileName[..^".map".Length] + "." + ext; + if (SafePath.GetFile(SafePath.CombineFilePath(ProgramConstants.GamePath, sourceFileName)).Exists) + File.Copy(SafePath.CombineFilePath(ProgramConstants.GamePath, sourceFileName), SafePath.CombineFilePath(ProgramConstants.GamePath, filename)); + } + } + } +} \ No newline at end of file diff --git a/DXMainClient/Domain/Mission.cs b/DXMainClient/Domain/Mission.cs index d2c2fc1c9..0c221eb8f 100644 --- a/DXMainClient/Domain/Mission.cs +++ b/DXMainClient/Domain/Mission.cs @@ -1,9 +1,15 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; using ClientCore; using ClientCore.Extensions; using Rampastring.Tools; - namespace DTAClient.Domain { /// @@ -11,46 +17,92 @@ namespace DTAClient.Domain /// public class Mission { - public Mission(IniFile iniFile, string sectionName, int index) + public Mission(IniSection missionSection, string missionCodeName) { - Index = index; - CD = iniFile.GetIntValue(sectionName, nameof(CD), 0); - Side = iniFile.GetIntValue(sectionName, nameof(Side), 0); - Scenario = iniFile.GetStringValue(sectionName, nameof(Scenario), string.Empty); - UntranslatedGUIName = iniFile.GetStringValue(sectionName, "Description", "Undefined mission"); + if (missionSection == null) + throw new ArgumentNullException(nameof(missionSection)); + + CD = missionSection.GetIntValue(nameof(CD), 0); + Side = missionSection.GetIntValue(nameof(Side), 0); + Scenario = missionSection.GetStringValue(nameof(Scenario), string.Empty); + UntranslatedGUIName = missionSection.GetStringValue("Description", "Undefined mission"); GUIName = UntranslatedGUIName - .L10N($"INI:Missions:{sectionName}:Description"); + .L10N($"INI:Missions:{missionCodeName}:Description"); - IconPath = iniFile.GetStringValue(sectionName, "SideName", string.Empty); - GUIDescription = iniFile.GetStringValue(sectionName, "LongDescription", string.Empty) + IconPath = missionSection.GetStringValue("SideName", string.Empty); + GUIDescription = missionSection.GetStringValue("LongDescription", string.Empty) .FromIniString() - .L10N($"INI:Missions:{sectionName}:LongDescription"); - FinalMovie = iniFile.GetStringValue(sectionName, nameof(FinalMovie), "none"); - RequiredAddon = iniFile.GetBooleanValue(sectionName, nameof(RequiredAddon), + .L10N($"INI:Missions:{missionCodeName}:LongDescription"); + FinalMovie = missionSection.GetStringValue(nameof(FinalMovie), "none"); + RequiredAddon = missionSection.GetBooleanValue(nameof(RequiredAddon), #if YR || ARES - true // In case of YR this toggles Ra2Mode instead which should not be default + true // In case of YR this toggles Ra2Mode instead which should not be default #else false #endif ); - Enabled = iniFile.GetBooleanValue(sectionName, nameof(Enabled), true); - BuildOffAlly = iniFile.GetBooleanValue(sectionName, nameof(BuildOffAlly), false); - PlayerAlwaysOnNormalDifficulty = iniFile.GetBooleanValue(sectionName, nameof(PlayerAlwaysOnNormalDifficulty), false); + Enabled = missionSection.GetBooleanValue(nameof(Enabled), true); + BuildOffAlly = missionSection.GetBooleanValue(nameof(BuildOffAlly), false); + PlayerAlwaysOnNormalDifficulty = missionSection.GetBooleanValue(nameof(PlayerAlwaysOnNormalDifficulty), false); + Tags = missionSection.GetStringValue(nameof(Tags), string.Empty).Split(','); + + CodeName = missionCodeName; + CustomMissionID = ComputeCustomMissionID(missionCodeName); } - public int Index { get; } - public int CD { get; } + public static Mission NewCustomMission(IniSection missionSection, string missionCodeName, string scenario, IniSection? missionMdIniSection) + { + var mission = new Mission(missionSection, missionCodeName) + { + IsCustomMission = true, + Scenario = scenario, + CustomMission_MissionMdIniSection = missionMdIniSection, + Tags = ["CUSTOM"], + }; + return mission; + } + + private static int ComputeCustomMissionID(string missionCodeName) + { +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms +#pragma warning disable CA1850 // Prefer static 'HashData' method over 'ComputeHash' + using var sha1 = SHA1.Create(); + byte[] digest = sha1.ComputeHash(Encoding.UTF8.GetBytes(missionCodeName)); + return BitConverter.ToInt32(digest, 0); +#pragma warning restore CA1850 // Prefer static 'HashData' method over 'ComputeHash' +#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms + } + + public string CodeName { get; private set; } public int CampaignID { get; } = -1; - public int Side { get; } - public string Scenario { get; } - public string GUIName { get; } - public string UntranslatedGUIName { get; } - public string IconPath { get; } - public string GUIDescription { get; } - public string FinalMovie { get; } - public bool RequiredAddon { get; } - public bool Enabled { get; } - public bool BuildOffAlly { get; } - public bool PlayerAlwaysOnNormalDifficulty { get; } + public int CustomMissionID { get; private set; } + + public int CD { get; private set; } + public int Side { get; private set; } + + /// + /// Refers to the map file. Must be a relative path to the game folder. + /// + public string Scenario { get; private set; } + public string GUIName { get; private set; } + public string UntranslatedGUIName { get; private set; } + public string IconPath { get; private set; } + public string GUIDescription { get; private set; } + public string FinalMovie { get; private set; } + public bool RequiredAddon { get; private set; } + public bool Enabled { get; set; } + public bool BuildOffAlly { get; private set; } + public bool PlayerAlwaysOnNormalDifficulty { get; private set; } + public IReadOnlyCollection Tags { get; private set; } + + /// + /// This property is not set through the ini file. + /// For a user custom mission, "scenario" will be assumed as the filename of a map file, with the suffix ".map" (case-insensitive). + /// The map file is assumed to be placed at ClientConfiguration.CustomMissionPath. + /// When launching a user custom mission, all supplemental files, i.e., files with the same filename (excepts for the suffix), will be temporarily copied into game folder. + /// + public bool IsCustomMission { get; private set; } + + public IniSection? CustomMission_MissionMdIniSection { get; private set; } } -} +} \ No newline at end of file diff --git a/DXMainClient/Domain/SavedGame.cs b/DXMainClient/Domain/SavedGame.cs index f1e596d06..c18a1dd8b 100644 --- a/DXMainClient/Domain/SavedGame.cs +++ b/DXMainClient/Domain/SavedGame.cs @@ -3,6 +3,7 @@ using System; using System.IO; using OpenMcdf; +using System.Diagnostics; namespace DTAClient.Domain { @@ -21,20 +22,7 @@ public SavedGame(string fileName) public string FileName { get; private set; } public string GUIName { get; private set; } public DateTime LastModified { get; private set; } - - /// - /// Get the saved game's name from a .sav file. - /// - /// - /// - private static string GetArchiveName(Stream file) - { - var cf = new CompoundFile(file); - var archiveNameBytes = cf.RootStorage.GetStream("Scenario Description").GetData(); - var archiveName = System.Text.Encoding.Unicode.GetString(archiveNameBytes); - archiveName = archiveName.TrimEnd(new char[] { '\0' }); - return archiveName; - } + public int CustomMissionID { get; private set; } /// /// Reads and sets the saved game's name and last modified date, and returns true if succesful. @@ -48,7 +36,17 @@ public bool ParseInfo() using (Stream file = savedGameFileInfo.Open(FileMode.Open, FileAccess.Read)) { - GUIName = GetArchiveName(file); + var cf = new CompoundFile(file); + + GUIName = System.Text.Encoding.Unicode.GetString(cf.RootStorage.GetStream("Scenario Description").GetData()).TrimEnd(['\0']); + try + { + CustomMissionID = BitConverter.ToInt32(cf.RootStorage.GetStream("CustomMissionID").GetData(), 0); + } + catch (CFItemNotFound) + { + CustomMissionID = 0; + } } LastModified = savedGameFileInfo.LastWriteTime; diff --git a/DXMainClient/PreStartup.cs b/DXMainClient/PreStartup.cs index 2c5664cc0..0b1c5bd13 100644 --- a/DXMainClient/PreStartup.cs +++ b/DXMainClient/PreStartup.cs @@ -18,6 +18,7 @@ using ClientCore.I18N; using System.Globalization; using System.Transactions; +using DTAClient.DXGUI.Multiplayer.GameLobby; namespace DTAClient { @@ -173,6 +174,9 @@ public static void Initialize(StartupParams parameters) Logger.Log("Failed to generate the translation stub: " + ex.ToString()); } + // Delete custom mission files + CustomMissionHelper.DeleteSupplementalMissionFiles(); + // Delete obsolete files from old target project versions gameDirectory.EnumerateFiles("mainclient.log").SingleOrDefault()?.Delete();