diff --git a/DXMainClient/DXGUI/Generic/Campaign/VariableCheckbox.cs b/DXMainClient/DXGUI/Generic/Campaign/VariableCheckbox.cs new file mode 100644 index 000000000..963085158 --- /dev/null +++ b/DXMainClient/DXGUI/Generic/Campaign/VariableCheckbox.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using DTAClient.Domain.Singleplayer; + +using Rampastring.XNAUI; +using Rampastring.XNAUI.XNAControls; + +namespace DTAClient.DXGUI.Generic.Campaign +{ + + public class VariableCheckbox : XNACheckBox + { + private string _variable; + public string Variable + { + get + { + return _variable; + } + set + { + Checked = CampaignHandler.Instance.Variables[value] > 0; + _variable = value; + } + } + public VariableCheckbox(WindowManager windowManager) : base(windowManager) + { + AllowChecking = true; + } + public override void OnLeftClick() + { + base.OnLeftClick(); + + if (CampaignHandler.Instance.Variables.ContainsKey(Variable)) + CampaignHandler.Instance.Variables[Variable] = Checked ? 1 : 0; + } + } +} diff --git a/DXMainClient/DXGUI/Generic/CampaignSelector.cs b/DXMainClient/DXGUI/Generic/CampaignSelector.cs index a1fe6840e..ec4114e75 100644 --- a/DXMainClient/DXGUI/Generic/CampaignSelector.cs +++ b/DXMainClient/DXGUI/Generic/CampaignSelector.cs @@ -10,6 +10,8 @@ using Rampastring.Tools; using ClientUpdater; using ClientCore.Extensions; +using DTAClient.Domain.Singleplayer; +using DTAClient.DXGUI.Generic.Campaign; namespace DTAClient.DXGUI.Generic { @@ -19,14 +21,6 @@ public class CampaignSelector : XNAWindow private const int DEFAULT_HEIGHT = 600; private static string[] DifficultyNames = new string[] { "Easy", "Medium", "Hard" }; - - private static string[] DifficultyIniPaths = new string[] - { - "INI/Map Code/Difficulty Easy.ini", - "INI/Map Code/Difficulty Medium.ini", - "INI/Map Code/Difficulty Hard.ini" - }; - public CampaignSelector(WindowManager windowManager, DiscordHandler discordHandler) : base(windowManager) { this.discordHandler = discordHandler; @@ -34,12 +28,18 @@ public CampaignSelector(WindowManager windowManager, DiscordHandler discordHandl private DiscordHandler discordHandler; - private List Missions = new List(); private XNAListBox lbCampaignList; private XNAClientButton btnLaunch; private XNATextBlock tbMissionDescription; private XNATrackbar trbDifficultySelector; + private const int VAR_MAX = 10; + + private XNALabel lblVariablesHeader; + private XNALabel[] variableNames = new XNALabel[VAR_MAX]; + private ToolTip[] variableToolTips = new ToolTip[VAR_MAX]; + private VariableCheckbox[] variableValues = new VariableCheckbox[VAR_MAX]; + private CheaterWindow cheaterWindow; private string[] filesToCheck = new string[] @@ -158,6 +158,40 @@ public override void Initialize() btnCancel.Text = "Cancel".L10N("Client:Main:ButtonCancel"); btnCancel.LeftClick += BtnCancel_LeftClick; + int y = (lblDifficultyLevel.Y - (UIDesignConstants.CONTROL_VERTICAL_MARGIN * 2) - UIDesignConstants.BUTTON_HEIGHT + 1) - UIDesignConstants.EMPTY_SPACE_BOTTOM; + + for (int i = 0; i < VAR_MAX; i++) + { + variableValues[i] = new VariableCheckbox(WindowManager); + variableValues[i].Name = "variableValue" + i; + AddChild(variableValues[i]); + variableValues[i].X = trbDifficultySelector.ClientRectangle.Center.X + tbMissionDescription.Width / 4; + variableValues[i].Y = y - (UIDesignConstants.EMPTY_SPACE_BOTTOM * 2) - variableValues[i].Height; + variableValues[i].Disable(); + + variableNames[i] = new XNALabel(WindowManager); + variableNames[i].Name = "variableName" + i; + variableNames[i].Text = "Variable #" + i; + variableNames[i].TextAnchor = LabelTextAnchorInfo.RIGHT; + variableNames[i].AnchorPoint = new Vector2(trbDifficultySelector.ClientRectangle.Center.X - tbMissionDescription.Width / 4, variableValues[i].Y - 1); + AddChild(variableNames[i]); + variableNames[i].Disable(); + + variableToolTips[i] = new ToolTip(WindowManager, variableNames[i]); + y = variableNames[i].Y; + + } + + lblVariablesHeader = new XNALabel(WindowManager); + lblVariablesHeader.Name = nameof(lblVariablesHeader); + lblVariablesHeader.FontIndex = 1; + lblVariablesHeader.TextAnchor = LabelTextAnchorInfo.HORIZONTAL_CENTER; + lblVariablesHeader.AnchorPoint = new Vector2(trbDifficultySelector.ClientRectangle.Center.X, + variableNames[0].Y - UIDesignConstants.CONTROL_VERTICAL_MARGIN * 2); + lblVariablesHeader.Text = "GLOBAL VARIABLES"; + AddChild(lblVariablesHeader); + lblVariablesHeader.Disable(); + AddChild(lblSelectCampaign); AddChild(lblMissionDescriptionHeader); AddChild(lbCampaignList); @@ -178,7 +212,7 @@ public override void Initialize() trbDifficultySelector.Value = UserINISettings.Instance.Difficulty; - ReadMissionList(); + ListMissions(); cheaterWindow = new CheaterWindow(WindowManager); var dp = new DarkeningPanel(WindowManager); @@ -199,7 +233,7 @@ private void LbCampaignList_SelectedIndexChanged(object sender, EventArgs e) return; } - Mission mission = Missions[lbCampaignList.SelectedIndex]; + Mission mission = CampaignHandler.Instance.Missions[lbCampaignList.SelectedIndex]; if (string.IsNullOrEmpty(mission.Scenario)) { @@ -216,9 +250,55 @@ private void LbCampaignList_SelectedIndexChanged(object sender, EventArgs e) return; } + ConfigureVariableUI(mission); btnLaunch.AllowClick = true; } + private void ConfigureVariableUI(Mission mission) + { + lblVariablesHeader.Disable(); + for(int i = 0; i < VAR_MAX; i++) + { + variableNames[i].Disable(); + variableValues[i].Disable(); + } + + tbMissionDescription.Height = variableValues[0].Bottom - tbMissionDescription.Y; + + if (mission != null && mission.ConfigurableVariables.Count > 0) + { + lblVariablesHeader.Enable(); + + for (int i = 0; i < mission.ConfigurableVariables.Count && i < VAR_MAX; i++) + { + string[] components = mission.ConfigurableVariables[i].Split(','); + + if (components.Length != 3) + { + Logger.Log("Syntax Error For Configurable Mission Variable: " + mission.ConfigurableVariables[i]); + return; + } + + variableNames[i].Text = components[1]; + variableNames[i].TextColor = UISettings.ActiveSettings.TextColor; + variableNames[i].Enable(); + variableToolTips[i].Text = components[2]; + + variableValues[i].Variable = components[0]; + variableValues[i].Enable(); + } + + int y = mission.ConfigurableVariables.Count > VAR_MAX ? variableNames[0].Y : + variableNames[mission.ConfigurableVariables.Count - 1].Y; + y -= UIDesignConstants.CONTROL_VERTICAL_MARGIN * 4; + lblVariablesHeader.Y = y; + lblVariablesHeader.Enable(); + + tbMissionDescription.Height = lblVariablesHeader.Y - (UIDesignConstants.CONTROL_VERTICAL_MARGIN * 2) - tbMissionDescription.Y; + + } + } + private void BtnCancel_LeftClick(object sender, EventArgs e) { Enabled = false; @@ -228,7 +308,7 @@ private void BtnLaunch_LeftClick(object sender, EventArgs e) { int selectedMissionId = lbCampaignList.SelectedIndex; - Mission mission = Missions[selectedMissionId]; + Mission mission = CampaignHandler.Instance.Missions[selectedMissionId]; if (!ClientConfiguration.Instance.ModMode && (!Updater.IsFileNonexistantOrOriginal(mission.Scenario) || AreFilesModified())) @@ -242,6 +322,33 @@ private void BtnLaunch_LeftClick(object sender, EventArgs e) LaunchMission(mission); } + private void ListMissions() + { + foreach (var mission in CampaignHandler.Instance.Missions) + { + var item = new XNAListBoxItem(); + item.Text = mission.GUIName; + if (!mission.Enabled) + { + item.TextColor = UISettings.ActiveSettings.DisabledItemColor; + } + else if (string.IsNullOrEmpty(mission.Scenario)) + { + item.TextColor = AssetLoader.GetColorFromString(ClientConfiguration.Instance.ListBoxHeaderColor); + item.IsHeader = true; + item.Selectable = false; + } + else + { + item.TextColor = lbCampaignList.DefaultItemColor; + } + if (!string.IsNullOrEmpty(mission.IconPath)) + item.Texture = AssetLoader.LoadTexture(mission.IconPath + "icon.png"); + + lbCampaignList.AddItem(item); + } + } + private bool AreFilesModified() { foreach (string filePath in filesToCheck) @@ -267,60 +374,11 @@ private void CheaterWindow_YesClicked(object sender, EventArgs e) /// private void LaunchMission(Mission mission) { - bool copyMapsToSpawnmapINI = ClientConfiguration.Instance.CopyMissionsToSpawnmapINI; - - Logger.Log("About to write spawn.ini."); - using (var spawnStreamWriter = new StreamWriter(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawn.ini"))) - { - spawnStreamWriter.WriteLine("; Generated by DTA Client"); - spawnStreamWriter.WriteLine("[Settings]"); - if (copyMapsToSpawnmapINI) - spawnStreamWriter.WriteLine("Scenario=spawnmap.ini"); - else - spawnStreamWriter.WriteLine("Scenario=" + mission.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); -#if YR || ARES - spawnStreamWriter.WriteLine("Ra2Mode=" + !mission.RequiredAddon); -#else - spawnStreamWriter.WriteLine("Firestorm=" + mission.RequiredAddon); -#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; - - spawnStreamWriter.WriteLine("DifficultyModeHuman=" + (mission.PlayerAlwaysOnNormalDifficulty ? "1" : trbDifficultySelector.Value.ToString())); - spawnStreamWriter.WriteLine("DifficultyModeComputer=" + GetComputerDifficulty()); - - spawnStreamWriter.WriteLine(); - spawnStreamWriter.WriteLine(); - spawnStreamWriter.WriteLine(); - } - - var difficultyIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, DifficultyIniPaths[trbDifficultySelector.Value])); - string difficultyName = DifficultyNames[trbDifficultySelector.Value]; - - if (copyMapsToSpawnmapINI) - { - var mapIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, mission.Scenario)); - IniFile.ConsolidateIniFiles(mapIni, difficultyIni); - mapIni.WriteIniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawnmap.ini")); - } - - UserINISettings.Instance.Difficulty.Value = trbDifficultySelector.Value; - UserINISettings.Instance.SaveSettings(); + CampaignHandler.Instance.WriteFilesForMission(mission, trbDifficultySelector.Value); ((MainMenuDarkeningPanel)Parent).Hide(); + string difficultyName = DifficultyNames[trbDifficultySelector.Value]; discordHandler.UpdatePresence(mission.UntranslatedGUIName, difficultyName, mission.IconPath, true); GameProcessLogic.GameProcessExited += GameProcessExited_Callback; @@ -340,82 +398,7 @@ protected virtual void GameProcessExited() GameProcessLogic.GameProcessExited -= GameProcessExited_Callback; // Logger.Log("GameProcessExited: Updating Discord Presence."); discordHandler.UpdatePresence(); - } - - private void ReadMissionList() - { - ParseBattleIni("INI/Battle.ini"); - - if (Missions.Count == 0) - ParseBattleIni("INI/" + ClientConfiguration.Instance.BattleFSFileName); - } - - /// - /// Parses a Battle(E).ini file. Returns true if succesful (file found), otherwise false. - /// - /// The path of the file, relative to the game directory. - /// True if succesful, otherwise false. - private bool ParseBattleIni(string path) - { - Logger.Log("Attempting to parse " + path + " to populate mission list."); - - FileInfo battleIniFileInfo = SafePath.GetFile(ProgramConstants.GamePath, path); - if (!battleIniFileInfo.Exists) - { - Logger.Log("File " + path + " not found. Ignoring."); - return false; - } - - if (Missions.Count > 0) - { - throw new InvalidOperationException("Loading multiple Battle*.ini files is not supported anymore."); - } - - var battleIni = new IniFile(battleIniFileInfo.FullName); - - List battleKeys = battleIni.GetSectionKeys("Battles"); - - if (battleKeys == null) - return false; // File exists but [Battles] doesn't - - for (int i = 0; i < battleKeys.Count; i++) - { - string battleEntry = battleKeys[i]; - string battleSection = battleIni.GetStringValue("Battles", battleEntry, "NOT FOUND"); - - if (!battleIni.SectionExists(battleSection)) - continue; - - var mission = new Mission(battleIni, battleSection, i); - - Missions.Add(mission); - - var item = new XNAListBoxItem(); - item.Text = mission.GUIName; - if (!mission.Enabled) - { - item.TextColor = UISettings.ActiveSettings.DisabledItemColor; - } - else if (string.IsNullOrEmpty(mission.Scenario)) - { - item.TextColor = AssetLoader.GetColorFromString( - ClientConfiguration.Instance.ListBoxHeaderColor); - item.IsHeader = true; - item.Selectable = false; - } - else - { - item.TextColor = lbCampaignList.DefaultItemColor; - } - - if (!string.IsNullOrEmpty(mission.IconPath)) - item.Texture = AssetLoader.LoadTexture(mission.IconPath + "icon.png"); - - lbCampaignList.AddItem(item); - } - - Logger.Log("Finished parsing " + path + "."); - return true; + CampaignHandler.Instance.CampaignPostGame(missionToLaunch); } public override void Draw(GameTime gameTime) diff --git a/DXMainClient/Domain/Mission.cs b/DXMainClient/Domain/Mission.cs deleted file mode 100644 index d2c2fc1c9..000000000 --- a/DXMainClient/Domain/Mission.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using ClientCore; -using ClientCore.Extensions; -using Rampastring.Tools; - - -namespace DTAClient.Domain -{ - /// - /// A Tiberian Sun mission listed in Battle(E).ini. - /// - public class Mission - { - public Mission(IniFile iniFile, string sectionName, int index) - { - 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"); - GUIName = UntranslatedGUIName - .L10N($"INI:Missions:{sectionName}:Description"); - - IconPath = iniFile.GetStringValue(sectionName, "SideName", string.Empty); - GUIDescription = iniFile.GetStringValue(sectionName, "LongDescription", string.Empty) - .FromIniString() - .L10N($"INI:Missions:{sectionName}:LongDescription"); - FinalMovie = iniFile.GetStringValue(sectionName, nameof(FinalMovie), "none"); - RequiredAddon = iniFile.GetBooleanValue(sectionName, nameof(RequiredAddon), -#if YR || ARES - 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); - } - - public int Index { get; } - public int CD { get; } - 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; } - } -} diff --git a/DXMainClient/Domain/Singleplayer/CampaignBindSet.cs b/DXMainClient/Domain/Singleplayer/CampaignBindSet.cs new file mode 100644 index 000000000..b5c4da1c2 --- /dev/null +++ b/DXMainClient/Domain/Singleplayer/CampaignBindSet.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using System.Text.RegularExpressions; + +using Rampastring.Tools; +using ClientCore; + +namespace DTAClient.Domain.Singleplayer +{ + public class CampaignBindSet + { + public string Prefix; + public Dictionary Bindings; + + public CampaignBindSet(string prefix) + { + Prefix = prefix; + Bindings = new Dictionary(); + } + + public void InitFromIniSection(IniSection section) + { + foreach (KeyValuePair kvp in section.Keys.Where(k => k.Key.StartsWith(Prefix))) + { + string[] parts = kvp.Key.Split('.'); + if (parts.Length > 2) + Logger.Log("Campaign binding key containing more than one /'./' will be skipped: " + kvp.Key); + else + Bindings.Add(parts[1],kvp.Value); + } + } + } +} diff --git a/DXMainClient/Domain/Singleplayer/CampaignHandler.cs b/DXMainClient/Domain/Singleplayer/CampaignHandler.cs new file mode 100644 index 000000000..777fd3e3b --- /dev/null +++ b/DXMainClient/Domain/Singleplayer/CampaignHandler.cs @@ -0,0 +1,306 @@ +using ClientCore; +using ClientCore.Statistics; + +using Rampastring.Tools; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace DTAClient.Domain.Singleplayer +{ + public class CampaignConfigException : Exception + { + public CampaignConfigException(string message) : base(message) { } + } + + /// + /// Primary container and controller for campaigns and campaign career variables + /// + public class CampaignHandler + { + + private CampaignHandler() + { + ReadBattleIni("INI/Battle.ini"); + ReadBattleIni("INI/" + ClientConfiguration.Instance.BattleFSFileName); + + CareerHandler.ReadCareerData(Missions, Variables); + + ValidateConfiguration(); + } + + public List Missions = new List(); + public Dictionary Variables = new Dictionary(); + + private static Regex GameVariableFormat = new Regex(@"^[lg]\d+"); + + private static string[] DifficultyIniPaths = new string[] + { + "INI/Map Code/Difficulty Easy.ini", + "INI/Map Code/Difficulty Medium.ini", + "INI/Map Code/Difficulty Hard.ini" + }; + + + private static CampaignHandler _instance; + public static CampaignHandler Instance + { + get + { + if (_instance == null) + _instance = new CampaignHandler(); + return _instance; + } + } + + /// + /// Reads all the missions defined in the specified battle ini file. + /// Missions must have only one section, no overriding or piecemeal entries. + /// + private bool ReadBattleIni(string path) + { + Logger.Log("Attempting to parse " + path + " to populate mission list."); + + FileInfo iniFileInfo = SafePath.GetFile(ProgramConstants.GamePath, path); + + if (!iniFileInfo.Exists) + { + Logger.Log("File " + path + " not found. Ignoring."); + return false; + } + + var battleIni = new IniFile(iniFileInfo.FullName); + List battleKeys = battleIni.GetSectionKeys("Battles"); + + if (battleKeys == null) + return false; // File exists but [Battles] doesn't + + foreach (string battleEntry in battleKeys) + { + string battleSection = battleIni.GetStringValue("Battles", battleEntry, "NOT FOUND"); + + if (!battleIni.SectionExists(battleSection)) + continue; + + // Mission mission = Missions.Find(m => m.InternalName == battleSection); + // TODO Update duplicate + + Mission mission = new Mission(battleIni.GetSection(battleSection)); + Missions.Add(mission); + + } + Logger.Log("Finished parsing " + path + "."); + return true; + } + + /// + /// Checks all the mission definitions to make sure that the associated + /// map files exist and all mission unlocks point to defined missions. + /// + private void ValidateConfiguration() + { + foreach (var mission in Missions.ToList()) + { + string root = ProgramConstants.GamePath; + + // If defined, check to make sure mission file exists + if(mission.Scenario != string.Empty && + !File.Exists(root + mission.Scenario)) + { + Logger.Log("Map file for mission " + mission.InternalName + " not found. Entry will be discarded."); + Missions.Remove(mission); + continue; + } + + // Make sure every variable mentioned is defined in the variables dictionary + // Could probably get mod makers to define a list instead of this mess? + foreach(var kvp in mission.LocalBindings) + { + if (!Variables.ContainsKey(kvp.Key)) + Variables.Add(kvp.Key, 0); + } + foreach (var kvp in mission.GlobalBindings) + { + if (!Variables.ContainsKey(kvp.Key)) + Variables.Add(kvp.Key, 0); + } + foreach (var kvp in mission.LocalUpdates) + { + if (!Variables.ContainsKey(kvp.Key)) + Variables.Add(kvp.Key, 0); + } + foreach (var kvp in mission.GlobalUpdates) + { + if (!Variables.ContainsKey(kvp.Key)) + Variables.Add(kvp.Key, 0); + } + } + } + + public void CampaignPostGame(Mission mission) + { + Dictionary gameVars = new Dictionary(); + + void VariablesFromIni(string ini) + { + FileInfo iniInfo = SafePath.GetFile(ProgramConstants.GamePath, ini); + if (!iniInfo.Exists) + return; + + IniSection section = new IniFile(iniInfo.FullName).GetSection("spawnmap.ini"); + if (section.Keys.Count == 0) + return; + + for (int i = 0; i < section.Keys.Count; i++) + { + gameVars.Add(ini[0] + i.ToString(), int.Parse(section.Keys[i].Value)); + } + } + + VariablesFromIni("globals.ini"); + VariablesFromIni("locals.ini"); + + // TODO Updates + } + + public void WriteFilesForMission(Mission mission, int difficulty) + { + bool copyMapsToSpawnmapINI = ClientConfiguration.Instance.CopyMissionsToSpawnmapINI; + + Logger.Log("About to write spawn.ini."); + using (var spawnStreamWriter = new StreamWriter(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawn.ini"))) + { + spawnStreamWriter.WriteLine("; Generated by DTA Client"); + spawnStreamWriter.WriteLine("[Settings]"); + if (copyMapsToSpawnmapINI) + spawnStreamWriter.WriteLine("Scenario=spawnmap.ini"); + else + spawnStreamWriter.WriteLine("Scenario=" + mission.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); +#if YR || ARES + spawnStreamWriter.WriteLine("Ra2Mode=" + !mission.RequiredAddon); +#else + spawnStreamWriter.WriteLine("Firestorm=" + mission.RequiredAddon); +#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 = difficulty; + + spawnStreamWriter.WriteLine("DifficultyModeHuman=" + (mission.PlayerAlwaysOnNormalDifficulty ? "1" : difficulty.ToString())); + spawnStreamWriter.WriteLine("DifficultyModeComputer=" + GetComputerDifficulty(difficulty)); + + spawnStreamWriter.WriteLine(); + spawnStreamWriter.WriteLine(); + spawnStreamWriter.WriteLine(); + } + + var difficultyIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, DifficultyIniPaths[difficulty])); + + if (copyMapsToSpawnmapINI) + { + var mapIni = new IniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, mission.Scenario)); + IniFile.ConsolidateIniFiles(mapIni, difficultyIni); + mapIni = AppendVariableBinding(mapIni, mission); + mapIni.WriteIniFile(SafePath.CombineFilePath(ProgramConstants.GamePath, "spawnmap.ini")); + } + + UserINISettings.Instance.Difficulty.Value = difficulty; + UserINISettings.Instance.SaveSettings(); + } + private int GetComputerDifficulty(int selected) => + Math.Abs(selected - 2); + + private IniFile AppendVariableBinding(IniFile src, Mission mission) + { + IniFile mapIni = src; + + // For locals, we just put the variable in the [VariableNames] section and set the default value + foreach (var local in mission.LocalBindings) + mapIni.SetStringValue("VariableNames", local.Value.ToString(), local.Key + "," + Variables[local.Key]); + + if (mission.HomeCell != string.Empty) + { + // Syntax for homecell change should be either "Variable:Waypoint" or "Variable|Value:Waypoint,Value:Waypoint,..." + + int index = mission.HomeCell.IndexOf('|'); + + if (index != -1) + { + string variable = mission.HomeCell.Substring(0, index); + string[] values = mission.HomeCell.Substring(index + 1).Split(','); + + foreach (var v in values) + { + string[] parts = v.Split(':'); + + if (parts[0] == Variables[variable].ToString()) + { + if (parts.Length == 2) + mapIni.SetStringValue("Basic", "HomeCell", parts[1]); + else + Logger.Log("Incorrect syntax for HomeCell flag on mission " + mission.InternalName + ": " + v); + } + } + } + else + { + string[] parts = mission.HomeCell.Split(':'); + + if (Variables[parts[0]] > 0) + { + if (parts.Length == 2) + mapIni.SetStringValue("Basic", "HomeCell", parts[1]); + else + Logger.Log("Incorrect syntax for HomeCell flag on mission " + mission.InternalName + ": " + mission.HomeCell); + } + } + } + + // For globals, we need to make a trigger to set all the globals properly + if (mission.GlobalBindings.Count > 0) + { + string action = ""; + int count = 0; + + foreach (var global in mission.GlobalBindings) + { + if (Variables[global.Key] > 0) + { + action = action + ",28,0," + global.Value.ToString() + ",0,0,0,0,A"; + count++; + } + } + + // If none of the variables need to be set, skip to return the map ini before we write a nonsense trigger + if (count < 1) + return mapIni; + + + IniFile bindings = new IniFile(); + + bindings.SetStringValue("Triggers", "XNACLIENT", "Neutral,,Bind Client Variables,0,1,1,1,0"); + bindings.SetStringValue("Events", "XNACLIENT", "1,13,0,0"); + bindings.SetStringValue("Tags", "XNACLIENTTag", "0,Used by XNA Client,XNACLIENT"); + bindings.SetStringValue("Actions", "XNACLIENT", count + action); + + // Consolidating in this order should place our trigger as first in the list + IniFile.ConsolidateIniFiles(bindings, mapIni); + return bindings; + } + + return mapIni; + } + } +} diff --git a/DXMainClient/Domain/Singleplayer/CareerHandler.cs b/DXMainClient/Domain/Singleplayer/CareerHandler.cs new file mode 100644 index 000000000..0cc1ea174 --- /dev/null +++ b/DXMainClient/Domain/Singleplayer/CareerHandler.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using ClientCore; + +using Rampastring.Tools; + +namespace DTAClient.Domain.Singleplayer +{ + /// + /// Reads and writes campaign career data for tracking unlocked missions and variable states + /// + public static class CareerHandler + { + private const string SP_CAREER_FILE = "Client/spcareer.dat"; + private const string SP_CAREER_FILE_BACKUP = "Client/spcareer_backup.dat"; + private const string MISSIONS_SECTION = "Missions"; + private const string VARIABLES_SECTION = "Variables"; + private const int RANK_MIN = 1; + private const int RANK_MAX = 3; + + // Data format: + // [Missions] + // GDI1A=0,0 ; isunlocked, completion_state + // + // [Variables] + // Credits=0 ; int value + + // Slight modification from how DTA stores this info, writing to an INI file + // that then gets base64-encoded to make it difficult to tamper with. + + public static void ReadCareerData(List missions, Dictionary variables) + { + + Logger.Log("Loading single-player career data."); + string filePath = ProgramConstants.GamePath + SP_CAREER_FILE; + if (!File.Exists(filePath)) + { + Logger.Log("Data file for single-player career not found."); + return; + } + + string b64data = File.ReadAllText(filePath, Encoding.Unicode); + //byte[] decoded = Convert.FromBase64String(b64data); + // IniFile iniFile; + // + // using (var memoryStream = new MemoryStream(decoded)) + // { + // iniFile = new IniFile(memoryStream, Encoding.UTF8); + // } + + IniFile iniFile = new IniFile(filePath); + var missionsSection = iniFile.GetSection(MISSIONS_SECTION); + if (missionsSection != null) + { + foreach (var kvp in missionsSection.Keys) + { + string missionName = kvp.Key; + string[] unlockAndRank = kvp.Value.Split(','); + if (unlockAndRank.Length != 2) + { + Logger.Log("Invalid mission clear data for mission " + missionName + ": " + kvp.Value); + continue; + } + bool isUnlocked = unlockAndRank[0] == "1"; + int rank = Conversions.IntFromString(unlockAndRank[1], 0); + Mission mission = missions.Find(m => m.InternalName == missionName); + if (mission != null) + { + if (mission.RequiresUnlocking) + mission.IsUnlocked = isUnlocked; + if (rank >= RANK_MIN && rank <= RANK_MAX) + mission.Rank = (CompletionState)rank; + } + } + } + + var variablesSection = iniFile.GetSection(VARIABLES_SECTION); + if (variablesSection != null) + { + foreach (var kvp in variablesSection.Keys) + { + string name = kvp.Key; + int value = -1; + if (int.TryParse(kvp.Value, out value)) + { + Logger.Log("Invalid career data for career variable " + name + ": " + kvp.Value); + continue; + } + + if (variables.ContainsKey(name)) + variables[name] = value; + } + } + } + + public static void WriteCareerData(List missions, Dictionary variables) + { + Logger.Log("Writing single-player career data."); + try + { + if (File.Exists(ProgramConstants.GamePath + SP_CAREER_FILE)) + { + File.Copy(ProgramConstants.GamePath + SP_CAREER_FILE, + ProgramConstants.GamePath + SP_CAREER_FILE_BACKUP, true); + } + } + catch (IOException ex) + { + Logger.Log("FAILED to refresh back-up of SP career data due to IOException: " + ex.Message); + return; + } + IniFile careerIni = new IniFile(); + foreach (var mission in missions) + { + bool isUnlocked = mission.IsUnlocked; + int rank = (int)mission.Rank; + if ((isUnlocked && mission.RequiresUnlocking) || rank > 0) + { + careerIni.SetStringValue( + MISSIONS_SECTION, + mission.InternalName, + $"{(isUnlocked ? "1" : "0")},{rank.ToString(CultureInfo.InvariantCulture)} "); + } + } + foreach (var variable in variables) + { + careerIni.SetStringValue( + VARIABLES_SECTION, + variable.Key, + $"{variable.Value}"); + } + careerIni.WriteIniFile(ProgramConstants.GamePath + SP_CAREER_FILE); + Logger.Log("Completed writing single-player career data."); + } + } +} diff --git a/DXMainClient/Domain/Singleplayer/CompletionState.cs b/DXMainClient/Domain/Singleplayer/CompletionState.cs new file mode 100644 index 000000000..05a34be95 --- /dev/null +++ b/DXMainClient/Domain/Singleplayer/CompletionState.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DTAClient.Domain.Singleplayer +{ + public enum CompletionState + { + NONE = 0, + INCOMPLETE = 1, + EASY = 2, + NORMAL = 3, + HARD = 4, + EASY_PAR = 5, + NORMAL_PAR = 6, + HARD_PAR = 7 + } +} diff --git a/DXMainClient/Domain/Singleplayer/Mission.cs b/DXMainClient/Domain/Singleplayer/Mission.cs new file mode 100644 index 000000000..625dd10ea --- /dev/null +++ b/DXMainClient/Domain/Singleplayer/Mission.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using ClientCore; +using ClientCore.Extensions; + +using Rampastring.Tools; + + +namespace DTAClient.Domain.Singleplayer +{ + /// + /// A Tiberian Sun mission listed in Battle(E).ini. + /// + public class Mission + { + private const int CompletionStateCount = 3; + + public Mission(IniSection section) + { + InternalName = section.SectionName; + CD = section.GetIntValue(nameof(CD), 0); + Side = section.GetIntValue(nameof(Side), 0); + Scenario = section.GetStringValue(nameof(Scenario), string.Empty); + UntranslatedGUIName = section.GetStringValue("Description", "Undefined mission"); + GUIName = UntranslatedGUIName + .L10N($"INI:Missions:{section.SectionName}:Description"); + + IconPath = section.GetStringValue("SideName", string.Empty); + GUIDescription = section.GetStringValue("LongDescription", string.Empty) + .FromIniString() + .L10N($"INI:Missions:{section.SectionName}:LongDescription"); + FinalMovie = section.GetStringValue(nameof(FinalMovie), "none"); + RequiredAddon = section.GetBooleanValue(nameof(RequiredAddon), +#if YR || ARES + true // In case of YR this toggles Ra2Mode instead which should not be default +#else + false +#endif + ); + Enabled = section.GetBooleanValue(nameof(Enabled), true); + BuildOffAlly = section.GetBooleanValue(nameof(BuildOffAlly), false); + PlayerAlwaysOnNormalDifficulty = section.GetBooleanValue(nameof(PlayerAlwaysOnNormalDifficulty), false); + + HomeCell = section.GetStringValue("HomeCell", string.Empty); + + ConfigurableVariables = new List(); + for(int i = 0; i < 10; i++) + { + string str = section.GetStringValue("ConfigureVariable" + i, string.Empty); + if (str == string.Empty) + break; + ConfigurableVariables.Add(str); + } + + LocalBindings = ParseVariables(section.GetStringValue("BindLocals", string.Empty)); + GlobalBindings = ParseVariables(section.GetStringValue("BindGlobals", string.Empty)); + LocalUpdates = ParseVariables(section.GetStringValue("LocalUpdates", string.Empty)); + GlobalUpdates = ParseVariables(section.GetStringValue("GlobalUpdates", string.Empty)); + } + + public string InternalName { get; } + public int CD { get; private set; } + public int CampaignID { get; } = -1; + public int Side { get; private set; } + 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; private set; } + public bool BuildOffAlly { get; private set; } + public bool PlayerAlwaysOnNormalDifficulty { get; } + public bool RequiresUnlocking { get; private set; } + public bool IsUnlocked { get; set; } + public CompletionState Rank { get; set; } + public string HomeCell { get; } + public List ConfigurableVariables { get; } + public Dictionary LocalBindings { get; } + public Dictionary GlobalBindings { get; } + public Dictionary LocalUpdates { get; } + public Dictionary GlobalUpdates { get; } + + private Dictionary ParseVariables(string str) + { + if (str == string.Empty) + return new Dictionary(); + + Dictionary binds = new Dictionary(); + + string[] vars = str.Split(','); + + foreach (string var in vars) + { + string[] parts = var.Split(':'); + if(parts.Length == 2) + { + binds.Add(parts[0], int.Parse(parts[1])); + } + else + { + Logger.Log(InternalName + " failed trying to parse client variable: " + var); + } + } + + return binds; + } + } +}