From 2f41814f3fc97f235cdfe34e0a7b81d7f804b7db Mon Sep 17 00:00:00 2001 From: Arodoid Date: Fri, 6 Feb 2026 23:20:15 -0800 Subject: [PATCH 01/12] Refactor engine configuration UI for improved readability and functionality - Updated localization string for current engine configuration to be more concise. - Refactored DrawConfigSelectors method to utilize BuildConfigRows for better structure and clarity in the engine configuration UI. - Enhanced the configuration row definition structure for better data handling and display. - Implemented dynamic column width calculation for the configuration table to improve layout adaptability. - Adjusted GUI styles for the editor panel to enhance visual consistency and usability. --- RealFuels/Localization/en-us.cfg | 2 +- Source/Engines/ModuleBimodalEngineConfigs.cs | 33 +- Source/Engines/ModuleEngineConfigs.cs | 926 ++++++++++++++++++- Source/Utilities/Styles.cs | 2 +- 4 files changed, 903 insertions(+), 60 deletions(-) diff --git a/RealFuels/Localization/en-us.cfg b/RealFuels/Localization/en-us.cfg index c2a88e07..081e8b0c 100644 --- a/RealFuels/Localization/en-us.cfg +++ b/RealFuels/Localization/en-us.cfg @@ -44,7 +44,7 @@ Localization #RF_Engine_GUIHide = Hide GUI #RF_Engine_GUIShow = Show GUI #RF_Engine_WindowTitle = Configure <<1>> - #RF_Engine_CurrentConfig = Current config + #RF_Engine_CurrentConfig = Current #RF_Engine_ConfigSwitch = Switch to #RF_Engine_LacksTech = Lacks tech for <<1>> #RF_Engine_Purchase = Purchase diff --git a/Source/Engines/ModuleBimodalEngineConfigs.cs b/Source/Engines/ModuleBimodalEngineConfigs.cs index 5ce2d2f8..be2884b8 100644 --- a/Source/Engines/ModuleBimodalEngineConfigs.cs +++ b/Source/Engines/ModuleBimodalEngineConfigs.cs @@ -121,34 +121,35 @@ public override string GetConfigInfo(ConfigNode config, bool addDescription = tr return info; } - protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) + protected override IEnumerable BuildConfigRows() { - if (GUILayout.Button(new GUIContent(ToggleText, toggleButtonHoverInfo))) - ToggleMode(); - foreach (var node in availableConfigNodes) + foreach (var node in FilteredDisplayConfigs(false)) { bool hasSecondary = ConfigHasSecondary(node); var nodeApplied = IsSecondaryMode && hasSecondary ? SecondaryConfig(node) : node; - DrawSelectButton( - nodeApplied, - node.GetValue("name") == configuration, - (configName) => + string configName = node.GetValue("name"); + yield return new ConfigRowDefinition + { + Node = nodeApplied, + DisplayName = GetConfigDisplayName(nodeApplied), + IsSelected = configName == configuration, + Indent = false, + Apply = () => { activePatchName = IsSecondaryMode && hasSecondary ? SecondaryPatchName(node) : ""; GUIApplyConfig(configName); - }); + } + }; } } - protected override void DrawPartInfo() + protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) { - using (new GUILayout.HorizontalScope()) - { - GUILayout.Label($"{Localizer.GetStringByTag("#RF_BimodalEngine_Currentmode")}: {ActiveModeDescription}"); // Current mode - } - base.DrawPartInfo(); - } + if (GUILayout.Button(new GUIContent(ToggleText, toggleButtonHoverInfo))) + ToggleMode(); + DrawConfigTable(BuildConfigRows()); + } [KSPAction("#RF_BimodalEngine_ToggleEngineMode")] // Toggle Engine Mode public void ToggleAction(KSPActionParam _) => ToggleMode(); diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index d5b06956..867a64b8 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -158,37 +158,49 @@ public override string GetConfigDisplayName(ConfigNode node) return $"{name} [Subconfig {node.GetValue(PatchNameKey)}]"; } - protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) + protected override IEnumerable BuildConfigRows() { - foreach (var node in availableConfigNodes) + foreach (var node in FilteredDisplayConfigs(false)) { - DrawSelectButton( - node, - node.GetValue("name") == configuration && activePatchName == "", - (configName) => + string configName = node.GetValue("name"); + yield return new ConfigRowDefinition + { + Node = node, + DisplayName = GetConfigDisplayName(node), + IsSelected = configName == configuration && activePatchName == "", + Indent = false, + Apply = () => { activePatchName = ""; GUIApplyConfig(configName); - }); + } + }; + foreach (var patch in GetPatchesOfConfig(node)) { var patchedNode = PatchConfig(node, patch, false); string patchName = patch.GetValue("name"); - using (new GUILayout.HorizontalScope()) + string patchedConfigName = configName; + yield return new ConfigRowDefinition { - GUILayout.Space(30); - DrawSelectButton( - patchedNode, - node.GetValue("name") == configuration && patchName == activePatchName, - (configName) => - { - activePatchName = patchName; - GUIApplyConfig(configName); - }); - } + Node = patchedNode, + DisplayName = GetConfigDisplayName(patchedNode), + IsSelected = patchedConfigName == configuration && patchName == activePatchName, + Indent = true, + Apply = () => + { + activePatchName = patchName; + GUIApplyConfig(patchedConfigName); + } + }; } } } + + protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) + { + DrawConfigTable(BuildConfigRows()); + } } public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassModifier @@ -474,7 +486,7 @@ public static void RelocateRCSPawItems(ModuleRCS module) field.group = new BasePAWGroup(groupName, groupDisplayName, false); } - private List FilteredDisplayConfigs(bool update) + protected List FilteredDisplayConfigs(bool update) { if (update || filteredDisplayConfigs == null) { @@ -540,7 +552,10 @@ public override void OnStart(StartState state) Fields[nameof(showRFGUI)].guiActiveEditor = isMaster; if (HighLogic.LoadedSceneIsEditor) + { GameEvents.onPartActionUIDismiss.Add(OnPartActionGuiDismiss); + GameEvents.onPartActionUIShown.Add(OnPartActionUIShown); + } ConfigSaveLoad(); @@ -669,6 +684,24 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c info.Append($" {Localizer.GetStringByTag("#RF_Engine_Isp")}: {ispSL:N0} - {ispV:N0}s\n"); // Isp } + if (config.HasNode("PROPELLANT")) + { + var propellants = config.GetNodes("PROPELLANT") + .Select(node => + { + string name = node.GetValue("name"); + string ratioStr = null; + if (node.TryGetValue("ratio", ref ratioStr) && float.TryParse(ratioStr, out float ratio)) + return $"{name} ({ratio:N3})"; + return name; + }) + .Where(name => !string.IsNullOrWhiteSpace(name)); + + string propellantList = string.Join(", ", propellants); + if (!string.IsNullOrWhiteSpace(propellantList)) + info.Append($" {Localizer.GetStringByTag("#RF_EngineRF_Propellant")}: {propellantList}\n"); + } + if (config.HasValue("ratedBurnTime")) { if (config.HasValue("ratedContinuousBurnTime")) @@ -1370,17 +1403,27 @@ private void OnPartActionGuiDismiss(Part p) showRFGUI = false; } + private void OnPartActionUIShown(UIPartActionWindow window, Part p) + { + if (p == part) + showRFGUI = isMaster; + } + public override void OnInactive() { if (!compatible) return; if (HighLogic.LoadedSceneIsEditor) + { GameEvents.onPartActionUIDismiss.Remove(OnPartActionGuiDismiss); + GameEvents.onPartActionUIShown.Remove(OnPartActionUIShown); + } } public void OnDestroy() { GameEvents.onPartActionUIDismiss.Remove(OnPartActionGuiDismiss); + GameEvents.onPartActionUIShown.Remove(OnPartActionUIShown); } private static Vector3 mousePos = Vector3.zero; @@ -1389,6 +1432,19 @@ public void OnDestroy() private int counterTT; private bool editorLocked = false; + private Vector2 configScrollPos = Vector2.zero; + private GUIContent configGuiContent; + private bool compactView = false; + + private const int ConfigRowHeight = 22; + private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) + // Dynamic column widths - calculated based on content + private float[] ConfigColumnWidths = new float[17]; + + private static Texture2D rowHoverTex; + private static Texture2D rowCurrentTex; + private static Texture2D rowLockedTex; + private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); @@ -1411,7 +1467,8 @@ public void OnGUI() { int posAdd = inPartsEditor ? 256 : 0; int posMult = (offsetGUIPos == -1) ? (part.Modules.Contains("ModuleFuelTanks") ? 1 : 0) : offsetGUIPos; - guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 430, (Screen.height - 365)); + // Set position, width and height will auto-size based on content + guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 100, 0); // Start small, will grow } mousePos = Input.mousePosition; //Mouse location; based on Kerbal Engineer Redux code @@ -1571,36 +1628,814 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action } } - virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) + protected struct ConfigRowDefinition { - foreach (ConfigNode node in availableConfigNodes) - DrawSelectButton(node, node.GetValue("name") == configuration, GUIApplyConfig); + public ConfigNode Node; + public string DisplayName; + public bool IsSelected; + public bool Indent; + public Action Apply; } - virtual protected void DrawPartInfo() + protected virtual IEnumerable BuildConfigRows() { - // show current info, cost - if (pModule != null && part.partInfo != null) + foreach (ConfigNode node in FilteredDisplayConfigs(false)) { - GUILayout.BeginHorizontal(); - string ratedBurnTime = string.Empty; - if (config.HasValue("ratedBurnTime")) + string configName = node.GetValue("name"); + yield return new ConfigRowDefinition { - if (config.HasValue("ratedContinuousBurnTime")) - ratedBurnTime = $"{Localizer.GetStringByTag("#RF_Engine_RatedBurnTime")}: {config.GetValue("ratedContinuousBurnTime")}/{config.GetValue("ratedBurnTime")}s\n"; // Rated burn time - else - ratedBurnTime = $"{Localizer.GetStringByTag("#RF_Engine_RatedBurnTime")}: {config.GetValue("ratedBurnTime")}s\n"; // Rated burn time + Node = node, + DisplayName = GetConfigDisplayName(node), + IsSelected = configName == configuration, + Indent = false, + Apply = () => GUIApplyConfig(configName) + }; + } + } + + protected void CalculateColumnWidths(List rows) + { + // Create style for measuring cell content + var cellStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + fontStyle = FontStyle.Bold, + padding = new RectOffset(5, 0, 0, 0) + }; + + // Initialize with minimum widths + for (int i = 0; i < ConfigColumnWidths.Length; i++) + { + ConfigColumnWidths[i] = 30f; // Start with minimum + } + + // Measure all row content (ignore headers - they're rotated and centered) + foreach (var row in rows) + { + string nameText = row.DisplayName; + if (row.Indent) nameText = "↳ " + nameText; + + string[] cellValues = new string[] + { + nameText, + GetThrustString(row.Node), + GetMinThrottleString(row.Node), + GetIspString(row.Node), + GetMassString(row.Node), + GetGimbalString(row.Node), + GetIgnitionsString(row.Node), + GetBoolSymbol(row.Node, "ullage"), + GetBoolSymbol(row.Node, "pressureFed"), + GetRatedBurnTimeString(row.Node), + GetIgnitionReliabilityStartString(row.Node), + GetIgnitionReliabilityEndString(row.Node), + GetCycleReliabilityStartString(row.Node), + GetCycleReliabilityEndString(row.Node), + GetTechString(row.Node), + GetCostDeltaString(row.Node), + "" // Action column - buttons + }; + + for (int i = 0; i < cellValues.Length; i++) + { + if (!string.IsNullOrEmpty(cellValues[i])) + { + float width = cellStyle.CalcSize(new GUIContent(cellValues[i])).x + 10f; // Add padding + if (width > ConfigColumnWidths[i]) + ConfigColumnWidths[i] = width; + } } - string label = $"{Localizer.GetStringByTag("#RF_Engine_Enginemass")}: {part.mass:N3}t\n" + // Engine mass - $"{ratedBurnTime}" + - $"{pModule.GetInfo()}\n" + - $"{TLTInfo()}\n" + - $"{Localizer.GetStringByTag("#RF_Engine_TotalCost")}: {part.partInfo.cost + part.GetModuleCosts(part.partInfo.cost):0}"; // Total cost - GUILayout.Label(label); - GUILayout.EndHorizontal(); } + + // Action column needs fixed width for two buttons + ConfigColumnWidths[16] = 160f; + + // Set minimum widths for specific columns + ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); // Ull + ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); // PFed + ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); // Rated burn + } + + protected void DrawConfigTable(IEnumerable rows) + { + EnsureTableTextures(); + + var rowList = rows.ToList(); + + // Calculate dynamic column widths + CalculateColumnWidths(rowList); + + // Sum only visible column widths + float totalWidth = 0f; + for (int i = 0; i < ConfigColumnWidths.Length; i++) + { + if (IsColumnVisible(i)) + totalWidth += ConfigColumnWidths[i]; + } + + // Update window width to fit table exactly (accounting for window padding: 5px left + 5px right = 10px) + float requiredWindowWidth = totalWidth + 10f; // Table width + padding + guiWindowRect.width = requiredWindowWidth; + + Rect headerRowRect = GUILayoutUtility.GetRect(GUIContent.none, GUI.skin.label, GUILayout.Height(45)); + float headerStartX = headerRowRect.x; // No left margin + DrawHeaderRow(new Rect(headerStartX, headerRowRect.y, totalWidth, headerRowRect.height)); + + // Dynamic height: grow up to max, then scroll + int actualRows = rowList.Count; + int visibleRows = Mathf.Min(actualRows, ConfigMaxVisibleRows); + int scrollViewHeight = visibleRows * ConfigRowHeight; + + // No spacing in scroll view + var scrollStyle = new GUIStyle(GUI.skin.scrollView) { padding = new RectOffset(0, 0, 0, 0) }; + configScrollPos = GUILayout.BeginScrollView(configScrollPos, false, false, GUIStyle.none, GUI.skin.verticalScrollbar, scrollStyle, GUILayout.Height(scrollViewHeight)); + + // Use a style with no margin/padding for tight row spacing + var noSpaceStyle = new GUIStyle { margin = new RectOffset(0, 0, 0, 0), padding = new RectOffset(0, 0, 0, 0) }; + + int rowIndex = 0; + foreach (var row in rowList) + { + Rect rowRect = GUILayoutUtility.GetRect(GUIContent.none, noSpaceStyle, GUILayout.Height(ConfigRowHeight)); + float rowStartX = rowRect.x; // No left margin + Rect tableRowRect = new Rect(rowStartX, rowRect.y, totalWidth, rowRect.height); + bool isHovered = tableRowRect.Contains(Event.current.mousePosition); + + bool isLocked = !CanConfig(row.Node); + if (Event.current.type == EventType.Repaint) + { + // Draw alternating row background first + if (!row.IsSelected && !isLocked && !isHovered && rowIndex % 2 == 1) + { + Color zebraColor = new Color(0.05f, 0.05f, 0.05f, 0.3f); + GUI.DrawTexture(tableRowRect, Styles.CreateColorPixel(zebraColor)); + } + + if (row.IsSelected) + GUI.DrawTexture(tableRowRect, rowCurrentTex); + else if (isLocked) + GUI.DrawTexture(tableRowRect, rowLockedTex); + else if (isHovered) + GUI.DrawTexture(tableRowRect, rowHoverTex); + } + + string tooltip = GetRowTooltip(row.Node); + if (configGuiContent == null) + configGuiContent = new GUIContent(); + configGuiContent.text = string.Empty; + configGuiContent.tooltip = tooltip; + GUI.Label(tableRowRect, configGuiContent, GUIStyle.none); + + DrawConfigRow(tableRowRect, row, isHovered, isLocked); + + // Draw column separators + if (Event.current.type == EventType.Repaint) + { + DrawColumnSeparators(tableRowRect); + } + + rowIndex++; + } + + GUILayout.EndScrollView(); + } + + private void DrawHeaderRow(Rect headerRect) + { + float currentX = headerRect.x; + if (IsColumnVisible(0)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[0], headerRect.height), + "Name", "Configuration name"); + currentX += ConfigColumnWidths[0]; + } + if (IsColumnVisible(1)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[1], headerRect.height), + Localizer.GetStringByTag("#RF_EngineRF_Thrust"), "Rated thrust"); + currentX += ConfigColumnWidths[1]; + } + if (IsColumnVisible(2)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[2], headerRect.height), + "Min%", "Minimum throttle"); + currentX += ConfigColumnWidths[2]; + } + if (IsColumnVisible(3)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[3], headerRect.height), + Localizer.GetStringByTag("#RF_Engine_Isp"), "Sea level and vacuum Isp"); + currentX += ConfigColumnWidths[3]; + } + if (IsColumnVisible(4)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[4], headerRect.height), + Localizer.GetStringByTag("#RF_Engine_Enginemass"), "Engine mass"); + currentX += ConfigColumnWidths[4]; + } + if (IsColumnVisible(5)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[5], headerRect.height), + Localizer.GetStringByTag("#RF_Engine_TLTInfo_Gimbal"), "Gimbal range"); + currentX += ConfigColumnWidths[5]; + } + if (IsColumnVisible(6)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[6], headerRect.height), + Localizer.GetStringByTag("#RF_EngineRF_Ignitions"), "Ignitions"); + currentX += ConfigColumnWidths[6]; + } + if (IsColumnVisible(7)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[7], headerRect.height), + Localizer.GetStringByTag("#RF_Engine_ullage"), "Ullage requirement"); + currentX += ConfigColumnWidths[7]; + } + if (IsColumnVisible(8)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[8], headerRect.height), + Localizer.GetStringByTag("#RF_Engine_pressureFed"), "Pressure-fed"); + currentX += ConfigColumnWidths[8]; + } + if (IsColumnVisible(9)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[9], headerRect.height), + "Rated (s)", "Rated burn time"); + currentX += ConfigColumnWidths[9]; + } + if (IsColumnVisible(10)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[10], headerRect.height), + "Ign No Data", "Ignition reliability at 0 data"); + currentX += ConfigColumnWidths[10]; + } + if (IsColumnVisible(11)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[11], headerRect.height), + "Ign Max Data", "Ignition reliability at max data"); + currentX += ConfigColumnWidths[11]; + } + if (IsColumnVisible(12)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[12], headerRect.height), + "Burn No Data", "Cycle reliability at 0 data"); + currentX += ConfigColumnWidths[12]; + } + if (IsColumnVisible(13)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[13], headerRect.height), + "Burn Max Data", "Cycle reliability at max data"); + currentX += ConfigColumnWidths[13]; + } + if (IsColumnVisible(14)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[14], headerRect.height), + Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); + currentX += ConfigColumnWidths[14]; + } + if (IsColumnVisible(15)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[15], headerRect.height), + "Extra Cost", "Extra cost for this config"); + currentX += ConfigColumnWidths[15]; + } + if (IsColumnVisible(16)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[16], headerRect.height), + "", "Switch and purchase actions"); // No label, just tooltip + } + } + + private void DrawColumnSeparators(Rect rowRect) + { + Color separatorColor = new Color(0.25f, 0.25f, 0.25f, 0.9f); // Darker and more opaque + Texture2D separatorTex = Styles.CreateColorPixel(separatorColor); + + float currentX = rowRect.x; + for (int i = 0; i < ConfigColumnWidths.Length - 1; i++) + { + if (IsColumnVisible(i)) + { + currentX += ConfigColumnWidths[i]; + Rect separatorRect = new Rect(currentX, rowRect.y, 1, rowRect.height); + GUI.DrawTexture(separatorRect, separatorTex); + } + } + } + + private void DrawHeaderCell(Rect rect, string text, string tooltip) + { + bool hover = rect.Contains(Event.current.mousePosition); + var headerStyle = new GUIStyle(GUI.skin.label) + { + fontSize = hover ? 15 : 14, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, + alignment = TextAnchor.LowerLeft, + richText = true + }; + if (configGuiContent == null) + configGuiContent = new GUIContent(); + configGuiContent.text = text; + configGuiContent.tooltip = tooltip; + Matrix4x4 matrixBackup = GUI.matrix; + // Start text at horizontal center of column + float offsetX = rect.width / 2f; + Vector2 pivot = new Vector2(rect.x + offsetX, rect.y + rect.height + 4f); + GUIUtility.RotateAroundPivot(-45f, pivot); + GUI.Label(new Rect(rect.x + offsetX, rect.y + rect.height - 22f, 140f, 24f), configGuiContent, headerStyle); + GUI.matrix = matrixBackup; + } + + private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered, bool isLocked) + { + var primaryStyle = new GUIStyle(GUI.skin.label) + { + fontSize = isHovered ? 15 : 14, + fontStyle = FontStyle.Bold, + normal = { textColor = isLocked ? new Color(1f, 0.65f, 0.3f) : new Color(0.85f, 0.85f, 0.85f) }, + alignment = TextAnchor.MiddleLeft, + richText = true, + padding = new RectOffset(5, 0, 0, 0) // Add left padding + }; + var secondaryStyle = new GUIStyle(primaryStyle) + { + normal = { textColor = new Color(0.7f, 0.7f, 0.7f) } + }; + + float currentX = rowRect.x; + string nameText = row.DisplayName; + if (row.Indent) nameText = "↳ " + nameText; + + if (IsColumnVisible(0)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[0], rowRect.height), nameText, primaryStyle); + currentX += ConfigColumnWidths[0]; + } + + if (IsColumnVisible(1)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[1], rowRect.height), GetThrustString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[1]; + } + + if (IsColumnVisible(2)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[2], rowRect.height), GetMinThrottleString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[2]; + } + + if (IsColumnVisible(3)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[3], rowRect.height), GetIspString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[3]; + } + + if (IsColumnVisible(4)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[4], rowRect.height), GetMassString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[4]; + } + + if (IsColumnVisible(5)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[5], rowRect.height), GetGimbalString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[5]; + } + + if (IsColumnVisible(6)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[6], rowRect.height), GetIgnitionsString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[6]; + } + + if (IsColumnVisible(7)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[7], rowRect.height), GetBoolSymbol(row.Node, "ullage"), secondaryStyle); + currentX += ConfigColumnWidths[7]; + } + + if (IsColumnVisible(8)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[8], rowRect.height), GetBoolSymbol(row.Node, "pressureFed"), secondaryStyle); + currentX += ConfigColumnWidths[8]; + } + + if (IsColumnVisible(9)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[9], rowRect.height), GetRatedBurnTimeString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[9]; + } + + if (IsColumnVisible(10)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[10], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[10]; + } + + if (IsColumnVisible(11)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[11]; + } + + if (IsColumnVisible(12)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[12]; + } + + if (IsColumnVisible(13)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[13]; + } + + if (IsColumnVisible(14)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetTechString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[14]; + } + + if (IsColumnVisible(15)) { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[15]; + } + + if (IsColumnVisible(16)) { + DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[16], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); + } + } + + private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action apply) + { + var buttonStyle = HighLogic.Skin.button; + var smallButtonStyle = new GUIStyle(buttonStyle) + { + fontSize = 11, + padding = new RectOffset(2, 2, 2, 2) + }; + + string configName = node.GetValue("name"); + bool canUse = CanConfig(node); + bool unlocked = UnlockedConfig(node, part); + double cost = EntryCostManager.Instance.ConfigEntryCost(configName); + + // Auto-purchase free configs + if (cost <= 0 && !unlocked && canUse) + EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); + + // Split the rect into two buttons side by side + float buttonWidth = rect.width / 2f - 2f; + Rect switchRect = new Rect(rect.x, rect.y, buttonWidth, rect.height); + Rect purchaseRect = new Rect(rect.x + buttonWidth + 4f, rect.y, buttonWidth, rect.height); + + // Switch button - always enabled except when already selected + GUI.enabled = !isSelected; + string switchLabel = isSelected ? "Active" : "Switch"; + if (GUI.Button(switchRect, switchLabel, smallButtonStyle)) + { + if (!unlocked && cost <= 0) + EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); + apply?.Invoke(); + } + + // Purchase button (shows cost) + GUI.enabled = canUse && !unlocked && cost > 0; + string purchaseLabel = cost > 0 ? $"Buy ({cost:N0}f)" : "Free"; + if (GUI.Button(purchaseRect, purchaseLabel, smallButtonStyle)) + { + if (EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired"))) + apply?.Invoke(); + } + + GUI.enabled = true; + } + + private string GetThrustString(ConfigNode node) + { + if (!node.HasValue(thrustRating)) + return "-"; + + return Utilities.FormatThrust(scale * ThrustTL(node.GetValue(thrustRating), node)); + } + + private string GetMinThrottleString(ConfigNode node) + { + float value = -1f; + if (node.HasValue("minThrust") && node.HasValue(thrustRating)) + { + float.TryParse(node.GetValue("minThrust"), out float minT); + float.TryParse(node.GetValue(thrustRating), out float maxT); + if (maxT > 0) + value = minT / maxT; + } + else if (node.HasValue("throttle")) + { + float.TryParse(node.GetValue("throttle"), out value); + } + + if (value < 0f) + return "-"; + return value.ToString("P0"); + } + + private string GetIspString(ConfigNode node) + { + if (node.HasNode("atmosphereCurve")) + { + FloatCurve isp = new FloatCurve(); + isp.Load(node.GetNode("atmosphereCurve")); + float ispVac = isp.Evaluate(isp.maxTime); + float ispSL = isp.Evaluate(isp.minTime); + return $"{ispVac:N0}-{ispSL:N0}"; + } + + if (node.HasValue("IspSL") && node.HasValue("IspV")) + { + float.TryParse(node.GetValue("IspSL"), out float ispSL); + float.TryParse(node.GetValue("IspV"), out float ispV); + if (techLevel != -1) + { + TechLevel cTL = new TechLevel(); + if (cTL.Load(node, techNodes, engineType, techLevel)) + { + ispSL *= ispSLMult * cTL.AtmosphereCurve.Evaluate(1); + ispV *= ispVMult * cTL.AtmosphereCurve.Evaluate(0); + } + } + return $"{ispV:N0}-{ispSL:N0}"; + } + + return "-"; + } + + private string GetMassString(ConfigNode node) + { + if (origMass <= 0f) + return "-"; + + float cMass = scale * origMass * RFSettings.Instance.EngineMassMultiplier; + if (node.HasValue("massMult") && float.TryParse(node.GetValue("massMult"), out float ftmp)) + cMass *= ftmp; + + return $"{cMass:N3}t"; + } + + private string GetGimbalString(ConfigNode node) + { + if (!part.HasModuleImplementing()) + return "✗"; + + var gimbals = ExtractGimbals(node); + + // If no explicit gimbal in config, check if we should use tech level gimbal + if (gimbals.Count == 0 && techLevel != -1 && (!gimbalTransform.Equals(string.Empty) || useGimbalAnyway)) + { + TechLevel cTL = new TechLevel(); + if (cTL.Load(node, techNodes, engineType, techLevel)) + { + float gimbalRange = cTL.GimbalRange; + if (node.HasValue("gimbalMult")) + gimbalRange *= float.Parse(node.GetValue("gimbalMult")); + + if (gimbalRange >= 0) + return $"{gimbalRange * gimbalMult:N1}d"; + } + } + + // Fallback: if config has no gimbal data, use the part's ModuleGimbal + if (gimbals.Count == 0) + { + foreach (var gimbalMod in part.Modules.OfType()) + { + if (gimbalMod != null) + { + var gimbal = new Gimbal(gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange); + gimbals[gimbalMod.gimbalTransformName] = gimbal; + } + } + } + + if (gimbals.Count == 0) + return "✗"; + + var first = gimbals.Values.First(); + bool allSame = gimbals.Values.All(g => g.gimbalRange == first.gimbalRange + && g.gimbalRangeXP == first.gimbalRangeXP + && g.gimbalRangeXN == first.gimbalRangeXN + && g.gimbalRangeYP == first.gimbalRangeYP + && g.gimbalRangeYN == first.gimbalRangeYN); + + if (allSame) + return first.Info(); + + // Multiple different gimbal ranges - list them all + var uniqueInfos = gimbals.Values.Select(g => g.Info()).Distinct().OrderBy(s => s); + return string.Join(", ", uniqueInfos); } + private string GetIgnitionsString(ConfigNode node) + { + if (!node.HasValue("ignitions")) + return "-"; + + if (!int.TryParse(node.GetValue("ignitions"), out int ignitions)) + return "∞"; + + int resolved = ConfigIgnitions(ignitions); + if (resolved == -1) + return "∞"; + if (resolved == 0 && literalZeroIgnitions) + return "Gnd"; // Yellow G for ground-only ignitions + return resolved.ToString(); + } + + private string GetBoolSymbol(ConfigNode node, string key) + { + if (!node.HasValue(key)) + return "✗"; // Treat missing as false - gray (no restriction) + bool isTrue = node.GetValue(key).ToLower() == "true"; + return isTrue ? "✓" : "✗"; // Orange for restriction, gray for no restriction + } + + private bool IsColumnVisible(int columnIndex) + { + if (!compactView) + return true; // All columns visible in full view + + // Compact view: show only essential columns + // 0: Name, 1: Thrust, 3: ISP, 4: Mass, 6: Ignitions, 9: Burn Time, 14: Tech, 15: Cost, 16: Actions + return columnIndex == 0 || columnIndex == 1 || columnIndex == 3 || columnIndex == 4 || + columnIndex == 6 || columnIndex == 9 || columnIndex == 14 || columnIndex == 15 || columnIndex == 16; + } + + private string GetRatedBurnTimeString(ConfigNode node) + { + bool hasRatedBurnTime = node.HasValue("ratedBurnTime"); + bool hasRatedContinuousBurnTime = node.HasValue("ratedContinuousBurnTime"); + + if (!hasRatedBurnTime && !hasRatedContinuousBurnTime) + return "∞"; + + // If both values exist, show as "continuous/cumulative" + if (hasRatedBurnTime && hasRatedContinuousBurnTime) + { + string continuous = node.GetValue("ratedBurnTime"); + string cumulative = node.GetValue("ratedContinuousBurnTime"); + return $"{continuous}/{cumulative}"; + } + + // Otherwise show whichever one exists + return hasRatedBurnTime ? node.GetValue("ratedBurnTime") : node.GetValue("ratedContinuousBurnTime"); + } + + private string GetIgnitionReliabilityStartString(ConfigNode node) + { + if (!node.HasValue("ignitionReliabilityStart")) + return "∞"; + if (float.TryParse(node.GetValue("ignitionReliabilityStart"), out float val)) + return $"{val:P1}"; + return "∞"; + } + + private string GetIgnitionReliabilityEndString(ConfigNode node) + { + if (!node.HasValue("ignitionReliabilityEnd")) + return "∞"; + if (float.TryParse(node.GetValue("ignitionReliabilityEnd"), out float val)) + return $"{val:P1}"; + return "∞"; + } + + private string GetCycleReliabilityStartString(ConfigNode node) + { + if (!node.HasValue("cycleReliabilityStart")) + return "∞"; + if (float.TryParse(node.GetValue("cycleReliabilityStart"), out float val)) + return $"{val:P1}"; + return "∞"; + } + + private string GetCycleReliabilityEndString(ConfigNode node) + { + if (!node.HasValue("cycleReliabilityEnd")) + return "∞"; + if (float.TryParse(node.GetValue("cycleReliabilityEnd"), out float val)) + return $"{val:P1}"; + return "∞"; + } + + private string GetTechString(ConfigNode node) + { + if (!node.HasValue("techRequired")) + return "-"; + + string tech = node.GetValue("techRequired"); + if (techNameToTitle.TryGetValue(tech, out string title)) + return title; + return tech; + } + + private string GetCostDeltaString(ConfigNode node) + { + if (!node.HasValue("cost")) + return "-"; + + float curCost = scale * float.Parse(node.GetValue("cost")); + if (techLevel != -1) + curCost = CostTL(curCost, node) - CostTL(0f, node); + + if (Mathf.Approximately(curCost, 0f)) + return "-"; + + string sign = curCost < 0 ? string.Empty : "+"; + return $"{sign}{curCost:N0}f"; + } + + #region Removed TestFlight UI Integration + // All TestFlight column display code removed due to: + // 1. Data spread across multiple modules (TestFlightCore, TestFlightFailure_IgnitionFail) + // 2. Reliability/MTBF values require complex calculations, not simple field access + // 3. Reflection-based approach was error-prone and caused GUI crashes + // + // TestFlight integration still works via UpdateTFInterops() to notify TestFlight + // of active configuration changes. TestFlight UI displays its own data. + // + // Removed methods: + // - GetFlightDataString, GetIgnitionChanceString, GetIgnitionChanceAtMaxDataString + // - GetReliabilityString, GetReliabilityAtMaxDataString + // - TryGetTestFlightStats, GetAllTestFlightDataSources, GetTestFlightDataSource + // - TryGetConfigDataSource, TryGetNumber, TryGetMemberValue, TryGetStringMember + // - TryConvertToDouble, FormatPercent + // - TestFlightStats struct + #endregion + + private string GetRowTooltip(ConfigNode node) + { + List tooltipParts = new List(); + + // Add description if present + if (node.HasValue("description")) + tooltipParts.Add(node.GetValue("description")); + + // Add propellants with flow rates if present + if (node.HasNode("PROPELLANT")) + { + // Get thrust and ISP for flow calculations + float thrust = 0f; + float isp = 0f; + + if (node.HasValue(thrustRating) && float.TryParse(node.GetValue(thrustRating), out float maxThrust)) + thrust = ThrustTL(node.GetValue(thrustRating), node) * scale; + + if (node.HasNode("atmosphereCurve")) + { + var atmCurve = new FloatCurve(); + atmCurve.Load(node.GetNode("atmosphereCurve")); + isp = atmCurve.Evaluate(0f); // Vacuum ISP + } + + // Calculate total mass flow: F = mdot * Isp * g0 + const float g0 = 9.80665f; + float totalMassFlow = (thrust > 0f && isp > 0f) ? thrust / (isp * g0) : 0f; + + // Get propellant ratios + var propNodes = node.GetNodes("PROPELLANT"); + float totalRatio = 0f; + foreach (var propNode in propNodes) + { + string ratioStr = null; + if (propNode.TryGetValue("ratio", ref ratioStr) && float.TryParse(ratioStr, out float ratio)) + totalRatio += ratio; + } + + var propellantLines = new List(); + foreach (var propNode in propNodes) + { + string name = propNode.GetValue("name"); + if (string.IsNullOrWhiteSpace(name)) continue; + + string line = $" • {name}"; + + string ratioStr2 = null; + if (propNode.TryGetValue("ratio", ref ratioStr2) && float.TryParse(ratioStr2, out float ratio) && totalMassFlow > 0f && totalRatio > 0f) + { + float propMassFlow = totalMassFlow * (ratio / totalRatio); + + // Get density from resource library + var resource = PartResourceLibrary.Instance?.GetDefinition(name); + + // Format mass flow: use grams if < 1 kg/s for better precision + string massFlowStr = propMassFlow >= 1f + ? $"{propMassFlow:F2} kg/s" + : $"{propMassFlow * 1000f:F1} g/s"; + + if (resource != null) + { + float volumeFlow = propMassFlow / (float)resource.density; + line += $": {volumeFlow:F2} L/s ({massFlowStr})"; + } + else + { + line += $": {massFlowStr}"; + } + } + + propellantLines.Add(line); + } + + if (propellantLines.Count > 0) + tooltipParts.Add($"Propellant Consumption:\n{string.Join("\n", propellantLines)}"); + } + + return tooltipParts.Count > 0 ? string.Join("\n\n", tooltipParts) : string.Empty; + } + + private void EnsureTableTextures() + { + if (rowHoverTex == null) + rowHoverTex = Styles.CreateColorPixel(new Color(1f, 1f, 1f, 0.05f)); + if (rowCurrentTex == null) + rowCurrentTex = Styles.CreateColorPixel(new Color(0.3f, 0.6f, 1.0f, 0.20f)); // Subtle blue tint + if (rowLockedTex == null) + rowLockedTex = Styles.CreateColorPixel(new Color(1f, 0.5f, 0.3f, 0.15f)); // Subtle orange tint + } + + virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) + { + DrawConfigTable(BuildConfigRows()); + } + + protected void DrawTechLevelSelector() { // NK Tech Level @@ -1682,16 +2517,23 @@ protected void DrawTechLevelSelector() private void EngineManagerGUI(int WindowID) { - GUILayout.Space(20); + GUILayout.Space(6); // Breathing room at top GUILayout.BeginHorizontal(); GUILayout.Label(EditorDescription); + GUILayout.FlexibleSpace(); + if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) + { + compactView = !compactView; + } GUILayout.EndHorizontal(); + GUILayout.Space(6); // Space before table DrawConfigSelectors(FilteredDisplayConfigs(false)); DrawTechLevelSelector(); - DrawPartInfo(); + + GUILayout.Space(-80); // Remove all bottom padding - window ends right at table if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty)) { diff --git a/Source/Utilities/Styles.cs b/Source/Utilities/Styles.cs index ecff70a6..86c437d8 100644 --- a/Source/Utilities/Styles.cs +++ b/Source/Utilities/Styles.cs @@ -37,7 +37,7 @@ internal static void InitStyles() styleEditorPanel = new GUIStyle(); styleEditorPanel.normal.background = CreateColorPixel(new Color32(7,54,66,200)); styleEditorPanel.border = new RectOffset(27, 27, 27, 27); - styleEditorPanel.padding = new RectOffset(10, 10, 10, 10); + styleEditorPanel.padding = new RectOffset(5, 5, 6, 0); styleEditorPanel.normal.textColor = new Color32(147,161,161,255); styleEditorPanel.fontSize = 12; From ce01f3985a031c2e59de4ad29afa539fee2c2287 Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sat, 7 Feb 2026 22:25:39 -0800 Subject: [PATCH 02/12] Add TestFlight data retrieval methods to TestFlightWrapper - Implemented GetCurrentFlightData method to retrieve the current flight data for a part. - Implemented GetMaximumData method to retrieve the maximum data value for a part. - Added GetDataPercentage method to calculate the percentage of current data relative to maximum data. - Enhanced reflection logic to safely access TestFlightCore methods. --- RealFuels/RF_TestFlight_UISupport.cfg | 22 + Source/Engines/ModuleEngineConfigs.cs | 1444 +++++++++++++++++++++++-- Source/Utilities/TestFlightWrapper.cs | 91 +- 3 files changed, 1487 insertions(+), 70 deletions(-) create mode 100644 RealFuels/RF_TestFlight_UISupport.cfg diff --git a/RealFuels/RF_TestFlight_UISupport.cfg b/RealFuels/RF_TestFlight_UISupport.cfg new file mode 100644 index 00000000..540a4814 --- /dev/null +++ b/RealFuels/RF_TestFlight_UISupport.cfg @@ -0,0 +1,22 @@ +// RealFuels TestFlight UI Support +// This patch copies TestFlight reliability values from TESTFLIGHT nodes into CONFIG nodes +// so the RealFuels engine configuration UI can display them. +// This runs after RealismOverhaul but before TESTFLIGHT nodes are deleted. + +@PART[*]:HAS[@MODULE[Module*EngineConfigs]]:BEFORE[zTestFlight]:AFTER[RealismOverhaul] +{ + @MODULE[Module*EngineConfigs] + { + // Copy reliability values for RealFuels UI display + @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityStart]] { &ignitionReliabilityStart = #$TESTFLIGHT/ignitionReliabilityStart$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityEnd]] { &ignitionReliabilityEnd = #$TESTFLIGHT/ignitionReliabilityEnd$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityStart]] { &cycleReliabilityStart = #$TESTFLIGHT/cycleReliabilityStart$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityEnd]] { &cycleReliabilityEnd = #$TESTFLIGHT/cycleReliabilityEnd$ } + + // Copy burn time values for failure probability chart + @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedBurnTime]] { &ratedBurnTime = #$TESTFLIGHT/ratedBurnTime$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedContinuousBurnTime]] { &ratedContinuousBurnTime = #$TESTFLIGHT/ratedContinuousBurnTime$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#testedBurnTime]] { &testedBurnTime = #$TESTFLIGHT/testedBurnTime$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#overburnPenalty]] { &overburnPenalty = #$TESTFLIGHT/overburnPenalty$ } + } +} diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index 867a64b8..ec923484 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Reflection; using System.Linq; using UnityEngine; @@ -32,18 +33,18 @@ public Gimbal(float gimbalRange, float gimbalRangeXP, float gimbalRangeXN, float public string Info() { if (new[] { gimbalRange, gimbalRangeXP, gimbalRangeXN, gimbalRangeYP, gimbalRangeYN }.Distinct().Count() == 1) - return $"{gimbalRange:N1}d"; // + return $"{gimbalRange:0.#}°"; if (new[] { gimbalRangeXP, gimbalRangeXN, gimbalRangeYP, gimbalRangeYN }.Distinct().Count() == 1) - return $"{gimbalRangeXP:N1}d"; // + return $"{gimbalRangeXP:0.#}°"; var ret = string.Empty; if (gimbalRangeXP == gimbalRangeXN) - ret += $"{gimbalRangeXP:N1}d pitch, "; // + ret += $"{gimbalRangeXP:0.#}° pitch, "; else - ret += $"+{gimbalRangeXP:N1}d/-{gimbalRangeXN:N1}d pitch, "; // + ret += $"+{gimbalRangeXP:0.#}°/-{gimbalRangeXN:0.#}° pitch, "; if (gimbalRangeYP == gimbalRangeYN) - ret += $"{gimbalRangeYP:N1}d yaw"; // + ret += $"{gimbalRangeYP:0.#}° yaw"; else - ret += $"+{gimbalRangeYP:N1}d/-{gimbalRangeYN:N1}d yaw"; // + ret += $"+{gimbalRangeYP:0.#}°/-{gimbalRangeYN:0.#}° yaw"; return ret; } } @@ -155,7 +156,7 @@ public override string GetConfigDisplayName(ConfigNode node) var name = node.GetValue("name"); if (!node.HasValue(PatchNameKey)) return name; - return $"{name} [Subconfig {node.GetValue(PatchNameKey)}]"; + return node.GetValue(PatchNameKey); // Just show subconfig name without parent prefix } protected override IEnumerable BuildConfigRows() @@ -603,7 +604,7 @@ private string TLTInfo() float gimbalR = -1f; if (config.HasValue("gimbalRange")) - gimbalR = float.Parse(config.GetValue("gimbalRange")); + gimbalR = float.Parse(config.GetValue("gimbalRange"), CultureInfo.InvariantCulture); else if (!gimbalTransform.Equals(string.Empty) || useGimbalAnyway) { if (cTL != null) @@ -652,9 +653,9 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c info.Append($" {Utilities.FormatThrust(scale * ThrustTL(config.GetValue(thrustRating), config))}"); // add throttling info if present if (config.HasValue("minThrust")) - info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("minThrust")) / float.Parse(config.GetValue(thrustRating)):P0}"); //min + info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("minThrust"), CultureInfo.InvariantCulture) / float.Parse(config.GetValue(thrustRating), CultureInfo.InvariantCulture):P0}"); //min else if (config.HasValue("throttle")) - info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("throttle")):P0}"); // min + info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("throttle"), CultureInfo.InvariantCulture):P0}"); // min } else info.Append($" {Localizer.GetStringByTag("#RF_Engine_UnknownThrust")}"); // Unknown Thrust @@ -763,7 +764,7 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c info.Append("\n"); } if (config.HasValue("cost") && float.TryParse(config.GetValue("cost"), out float cst)) - info.Append($" ({scale * cst:N0} {Localizer.GetStringByTag("#RF_Engine_extraCost")} )\n"); // extra cost// FIXME should get cost from TL, but this should be safe + info.Append($" ({scale * cst:N0}√ {Localizer.GetStringByTag("#RF_Engine_extraCost")} )\n"); // extra cost// FIXME should get cost from TL, but this should be safe if (addDescription && config.HasValue("description")) info.Append($"\n {config.GetValue("description")}\n"); @@ -1154,11 +1155,11 @@ virtual public void DoConfig(ConfigNode cfg) float gimbal = -1f; if (cfg.HasValue("gimbalRange")) - gimbal = float.Parse(cfg.GetValue("gimbalRange")); + gimbal = float.Parse(cfg.GetValue("gimbalRange"), CultureInfo.InvariantCulture); float cost = 0f; if (cfg.HasValue("cost")) - cost = scale * float.Parse(cfg.GetValue("cost")); + cost = scale * float.Parse(cfg.GetValue("cost"), CultureInfo.InvariantCulture); if (techLevel != -1) { @@ -1222,7 +1223,7 @@ virtual public void DoConfig(ConfigNode cfg) { // allow local override of gimbal mult if (cfg.HasValue("gimbalMult")) - gimbal *= float.Parse(cfg.GetValue("gimbalMult")); + gimbal *= float.Parse(cfg.GetValue("gimbalMult"), CultureInfo.InvariantCulture); } // Cost (multiplier will be 1.0 if unspecified) @@ -1435,15 +1436,49 @@ public void OnDestroy() private Vector2 configScrollPos = Vector2.zero; private GUIContent configGuiContent; private bool compactView = false; + private bool useLogScaleX = false; // Toggle for logarithmic x-axis on failure chart + private bool useLogScaleY = false; // Toggle for logarithmic y-axis on failure chart + + // Simulation controls for data percentage and cluster size + private bool useSimulatedData = false; // Whether to override real TestFlight data + private float simulatedDataPercentage = 0f; // Simulated data percentage (0-1) + private int clusterSize = 1; // Number of engines in cluster (default 1) + private string clusterSizeInput = "1"; // Text input for cluster size + private string dataPercentageInput = "0"; // Text input for data percentage private const int ConfigRowHeight = 22; private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) // Dynamic column widths - calculated based on content - private float[] ConfigColumnWidths = new float[17]; + private float[] ConfigColumnWidths = new float[18]; private static Texture2D rowHoverTex; private static Texture2D rowCurrentTex; private static Texture2D rowLockedTex; + private static Texture2D zebraStripeTex; + private static Texture2D columnSeparatorTex; + + // Chart textures - cached to prevent loss on focus change + private static Texture2D chartBgTex; + private static Texture2D chartGridMajorTex; + private static Texture2D chartGridMinorTex; + private static Texture2D chartGreenZoneTex; + private static Texture2D chartYellowZoneTex; + private static Texture2D chartRedZoneTex; + private static Texture2D chartDarkRedZoneTex; + private static Texture2D chartStartupZoneTex; + private static Texture2D chartLineTex; + private static Texture2D chartMarkerBlueTex; + private static Texture2D chartMarkerGreenTex; + private static Texture2D chartMarkerYellowTex; + private static Texture2D chartMarkerOrangeTex; + private static Texture2D chartMarkerDarkRedTex; + private static Texture2D chartSeparatorTex; + private static Texture2D chartHoverLineTex; + private static Texture2D chartOrangeLineTex; + private static Texture2D chartGreenLineTex; + private static Texture2D chartBlueLineTex; + private static Texture2D chartTooltipBgTex; + private static Texture2D infoPanelBgTex; private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); @@ -1512,13 +1547,13 @@ protected string GetCostString(ConfigNode node) string costString = string.Empty; if (node.HasValue("cost")) { - float curCost = scale * float.Parse(node.GetValue("cost")); + float curCost = scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture); if (techLevel != -1) { curCost = CostTL(curCost, node) - CostTL(0f, node); // get purely the config cost difference } - costString = $" ({((curCost < 0) ? string.Empty : "+")}{curCost:N0}f)"; + costString = $" ({((curCost < 0) ? string.Empty : "+")}{curCost:N0}√)"; } return costString; } @@ -1571,7 +1606,7 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action } GUI.enabled = isConfigAvailable; - if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} ({upgradeCost:N0}f)", tooltip), GUILayout.Width(145))) // Purchase + if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} ({upgradeCost:N0}√)", tooltip), GUILayout.Width(145))) // Purchase { if (EntryCostManager.Instance.PurchaseConfig(nName, node.GetValue("techRequired"))) apply(nName); @@ -1610,7 +1645,7 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action string techRequired = node.GetValue("techRequired"); if (upgradeCost > 0d) { - costString = $" ({upgradeCost:N0}f)"; + costString = $" ({upgradeCost:N0}√)"; if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} {dispName}{costString}", configInfo))) // Purchase { if (EntryCostManager.Instance.PurchaseConfig(nName, techRequired)) @@ -1673,7 +1708,7 @@ protected void CalculateColumnWidths(List rows) foreach (var row in rows) { string nameText = row.DisplayName; - if (row.Indent) nameText = "↳ " + nameText; + if (row.Indent) nameText = " ↳ " + nameText; string[] cellValues = new string[] { @@ -1687,6 +1722,7 @@ protected void CalculateColumnWidths(List rows) GetBoolSymbol(row.Node, "ullage"), GetBoolSymbol(row.Node, "pressureFed"), GetRatedBurnTimeString(row.Node), + GetTestedBurnTimeString(row.Node), // NEW: Tested burn time column GetIgnitionReliabilityStartString(row.Node), GetIgnitionReliabilityEndString(row.Node), GetCycleReliabilityStartString(row.Node), @@ -1708,12 +1744,13 @@ protected void CalculateColumnWidths(List rows) } // Action column needs fixed width for two buttons - ConfigColumnWidths[16] = 160f; + ConfigColumnWidths[17] = 160f; // Set minimum widths for specific columns ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); // Ull ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); // PFed ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); // Rated burn + ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f); // Tested burn } protected void DrawConfigTable(IEnumerable rows) @@ -1767,8 +1804,7 @@ protected void DrawConfigTable(IEnumerable rows) // Draw alternating row background first if (!row.IsSelected && !isLocked && !isHovered && rowIndex % 2 == 1) { - Color zebraColor = new Color(0.05f, 0.05f, 0.05f, 0.3f); - GUI.DrawTexture(tableRowRect, Styles.CreateColorPixel(zebraColor)); + GUI.DrawTexture(tableRowRect, zebraStripeTex); } if (row.IsSelected) @@ -1855,45 +1891,47 @@ private void DrawHeaderRow(Rect headerRect) } if (IsColumnVisible(10)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[10], headerRect.height), - "Ign No Data", "Ignition reliability at 0 data"); + "Tested (s)", "Tested burn time (real-world test duration)"); currentX += ConfigColumnWidths[10]; } if (IsColumnVisible(11)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[11], headerRect.height), - "Ign Max Data", "Ignition reliability at max data"); + "Ign No Data", "Ignition reliability at 0 data"); currentX += ConfigColumnWidths[11]; } if (IsColumnVisible(12)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[12], headerRect.height), - "Burn No Data", "Cycle reliability at 0 data"); + "Ign Max Data", "Ignition reliability at max data"); currentX += ConfigColumnWidths[12]; } if (IsColumnVisible(13)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[13], headerRect.height), - "Burn Max Data", "Cycle reliability at max data"); + "Burn No Data", "Cycle reliability at 0 data"); currentX += ConfigColumnWidths[13]; } if (IsColumnVisible(14)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[14], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); + "Burn Max Data", "Cycle reliability at max data"); currentX += ConfigColumnWidths[14]; } if (IsColumnVisible(15)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[15], headerRect.height), - "Extra Cost", "Extra cost for this config"); + Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); currentX += ConfigColumnWidths[15]; } if (IsColumnVisible(16)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[16], headerRect.height), + "Extra Cost", "Extra cost for this config"); + currentX += ConfigColumnWidths[16]; + } + if (IsColumnVisible(17)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[17], headerRect.height), "", "Switch and purchase actions"); // No label, just tooltip } } private void DrawColumnSeparators(Rect rowRect) { - Color separatorColor = new Color(0.25f, 0.25f, 0.25f, 0.9f); // Darker and more opaque - Texture2D separatorTex = Styles.CreateColorPixel(separatorColor); - float currentX = rowRect.x; for (int i = 0; i < ConfigColumnWidths.Length - 1; i++) { @@ -1901,7 +1939,7 @@ private void DrawColumnSeparators(Rect rowRect) { currentX += ConfigColumnWidths[i]; Rect separatorRect = new Rect(currentX, rowRect.y, 1, rowRect.height); - GUI.DrawTexture(separatorRect, separatorTex); + GUI.DrawTexture(separatorRect, columnSeparatorTex); } } } @@ -1948,7 +1986,7 @@ private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered float currentX = rowRect.x; string nameText = row.DisplayName; - if (row.Indent) nameText = "↳ " + nameText; + if (row.Indent) nameText = " ↳ " + nameText; if (IsColumnVisible(0)) { GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[0], rowRect.height), nameText, primaryStyle); @@ -2001,37 +2039,42 @@ private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered } if (IsColumnVisible(10)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[10], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[10], rowRect.height), GetTestedBurnTimeString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[10]; } if (IsColumnVisible(11)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[11]; } if (IsColumnVisible(12)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[12]; } if (IsColumnVisible(13)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[13]; } if (IsColumnVisible(14)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetTechString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[14]; } if (IsColumnVisible(15)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetTechString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[15]; } if (IsColumnVisible(16)) { - DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[16], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[16], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[16]; + } + + if (IsColumnVisible(17)) { + DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[17], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); } } @@ -2070,7 +2113,12 @@ private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action // Purchase button (shows cost) GUI.enabled = canUse && !unlocked && cost > 0; - string purchaseLabel = cost > 0 ? $"Buy ({cost:N0}f)" : "Free"; + string purchaseLabel; + if (cost > 0) + purchaseLabel = unlocked ? "Owned" : $"Buy ({cost:N0}√)"; + else + purchaseLabel = "Free"; + if (GUI.Button(purchaseRect, purchaseLabel, smallButtonStyle)) { if (EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired"))) @@ -2085,7 +2133,11 @@ private string GetThrustString(ConfigNode node) if (!node.HasValue(thrustRating)) return "-"; - return Utilities.FormatThrust(scale * ThrustTL(node.GetValue(thrustRating), node)); + float thrust = scale * ThrustTL(node.GetValue(thrustRating), node); + // Remove decimals for large thrust values + if (thrust >= 100f) + return $"{thrust:N0} kN"; + return $"{thrust:N2} kN"; } private string GetMinThrottleString(ConfigNode node) @@ -2165,10 +2217,10 @@ private string GetGimbalString(ConfigNode node) { float gimbalRange = cTL.GimbalRange; if (node.HasValue("gimbalMult")) - gimbalRange *= float.Parse(node.GetValue("gimbalMult")); + gimbalRange *= float.Parse(node.GetValue("gimbalMult"), CultureInfo.InvariantCulture); if (gimbalRange >= 0) - return $"{gimbalRange * gimbalMult:N1}d"; + return $"{gimbalRange * gimbalMult:0.#}°"; } } @@ -2233,9 +2285,9 @@ private bool IsColumnVisible(int columnIndex) return true; // All columns visible in full view // Compact view: show only essential columns - // 0: Name, 1: Thrust, 3: ISP, 4: Mass, 6: Ignitions, 9: Burn Time, 14: Tech, 15: Cost, 16: Actions + // 0: Name, 1: Thrust, 3: ISP, 4: Mass, 6: Ignitions, 9: Rated Burn, 10: Tested Burn, 15: Tech, 16: Cost, 17: Actions return columnIndex == 0 || columnIndex == 1 || columnIndex == 3 || columnIndex == 4 || - columnIndex == 6 || columnIndex == 9 || columnIndex == 14 || columnIndex == 15 || columnIndex == 16; + columnIndex == 6 || columnIndex == 9 || columnIndex == 10 || columnIndex == 15 || columnIndex == 16 || columnIndex == 17; } private string GetRatedBurnTimeString(ConfigNode node) @@ -2249,8 +2301,8 @@ private string GetRatedBurnTimeString(ConfigNode node) // If both values exist, show as "continuous/cumulative" if (hasRatedBurnTime && hasRatedContinuousBurnTime) { - string continuous = node.GetValue("ratedBurnTime"); - string cumulative = node.GetValue("ratedContinuousBurnTime"); + string continuous = node.GetValue("ratedContinuousBurnTime"); + string cumulative = node.GetValue("ratedBurnTime"); return $"{continuous}/{cumulative}"; } @@ -2258,40 +2310,57 @@ private string GetRatedBurnTimeString(ConfigNode node) return hasRatedBurnTime ? node.GetValue("ratedBurnTime") : node.GetValue("ratedContinuousBurnTime"); } + private string GetTestedBurnTimeString(ConfigNode node) + { + // Values are copied to CONFIG level by ModuleManager patch + if (!node.HasValue("testedBurnTime")) + return "-"; + + float testedBurnTime = 0f; + if (node.TryGetValue("testedBurnTime", ref testedBurnTime)) + return testedBurnTime.ToString("F0"); + + return "-"; + } + private string GetIgnitionReliabilityStartString(ConfigNode node) { + // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("ignitionReliabilityStart")) - return "∞"; + return "-"; if (float.TryParse(node.GetValue("ignitionReliabilityStart"), out float val)) return $"{val:P1}"; - return "∞"; + return "-"; } private string GetIgnitionReliabilityEndString(ConfigNode node) { + // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("ignitionReliabilityEnd")) - return "∞"; + return "-"; if (float.TryParse(node.GetValue("ignitionReliabilityEnd"), out float val)) return $"{val:P1}"; - return "∞"; + return "-"; } private string GetCycleReliabilityStartString(ConfigNode node) { + // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("cycleReliabilityStart")) - return "∞"; + return "-"; if (float.TryParse(node.GetValue("cycleReliabilityStart"), out float val)) return $"{val:P1}"; - return "∞"; + return "-"; } private string GetCycleReliabilityEndString(ConfigNode node) { + // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("cycleReliabilityEnd")) - return "∞"; + return "-"; if (float.TryParse(node.GetValue("cycleReliabilityEnd"), out float val)) return $"{val:P1}"; - return "∞"; + return "-"; } private string GetTechString(ConfigNode node) @@ -2301,8 +2370,22 @@ private string GetTechString(ConfigNode node) string tech = node.GetValue("techRequired"); if (techNameToTitle.TryGetValue(tech, out string title)) - return title; - return tech; + tech = title; + + // Abbreviate: keep first word, then first 4 letters of other words with "-" + var words = tech.Split(' '); + if (words.Length <= 1) + return tech; + + var abbreviated = words[0]; + for (int i = 1; i < words.Length; i++) + { + if (words[i].Length > 4) + abbreviated += "-" + words[i].Substring(0, 4); + else + abbreviated += "-" + words[i]; + } + return abbreviated; } private string GetCostDeltaString(ConfigNode node) @@ -2310,7 +2393,7 @@ private string GetCostDeltaString(ConfigNode node) if (!node.HasValue("cost")) return "-"; - float curCost = scale * float.Parse(node.GetValue("cost")); + float curCost = scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture); if (techLevel != -1) curCost = CostTL(curCost, node) - CostTL(0f, node); @@ -2318,7 +2401,7 @@ private string GetCostDeltaString(ConfigNode node) return "-"; string sign = curCost < 0 ? string.Empty : "+"; - return $"{sign}{curCost:N0}f"; + return $"{sign}{curCost:N0}√"; } #region Removed TestFlight UI Integration @@ -2365,8 +2448,10 @@ private string GetRowTooltip(ConfigNode node) } // Calculate total mass flow: F = mdot * Isp * g0 + // Thrust is in kN (kilonewtons), convert to N (newtons) for the equation const float g0 = 9.80665f; - float totalMassFlow = (thrust > 0f && isp > 0f) ? thrust / (isp * g0) : 0f; + float thrustN = thrust * 1000f; + float totalMassFlow = (thrustN > 0f && isp > 0f) ? thrustN / (isp * g0) : 0f; // Get propellant ratios var propNodes = node.GetNodes("PROPELLANT"); @@ -2422,12 +2507,1226 @@ private string GetRowTooltip(ConfigNode node) private void EnsureTableTextures() { - if (rowHoverTex == null) + // Use Unity's implicit bool conversion to properly detect destroyed textures + if (!rowHoverTex) rowHoverTex = Styles.CreateColorPixel(new Color(1f, 1f, 1f, 0.05f)); - if (rowCurrentTex == null) + if (!rowCurrentTex) rowCurrentTex = Styles.CreateColorPixel(new Color(0.3f, 0.6f, 1.0f, 0.20f)); // Subtle blue tint - if (rowLockedTex == null) + if (!rowLockedTex) rowLockedTex = Styles.CreateColorPixel(new Color(1f, 0.5f, 0.3f, 0.15f)); // Subtle orange tint + if (!zebraStripeTex) + zebraStripeTex = Styles.CreateColorPixel(new Color(0.05f, 0.05f, 0.05f, 0.3f)); + if (!columnSeparatorTex) + columnSeparatorTex = Styles.CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.9f)); + } + + private void EnsureChartTextures() + { + // Use Unity's implicit bool conversion to properly detect destroyed textures + if (!chartBgTex) + chartBgTex = Styles.CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.8f)); + if (!chartGridMajorTex) + chartGridMajorTex = Styles.CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.4f)); // Major gridlines at 20% + if (!chartGridMinorTex) + chartGridMinorTex = Styles.CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.2f)); // Minor gridlines at 10%, barely visible + if (!chartGreenZoneTex) + chartGreenZoneTex = Styles.CreateColorPixel(new Color(0.2f, 0.5f, 0.2f, 0.15f)); + if (!chartYellowZoneTex) + chartYellowZoneTex = Styles.CreateColorPixel(new Color(0.5f, 0.5f, 0.2f, 0.15f)); + if (!chartRedZoneTex) + chartRedZoneTex = Styles.CreateColorPixel(new Color(0.5f, 0.2f, 0.2f, 0.15f)); + if (!chartDarkRedZoneTex) + chartDarkRedZoneTex = Styles.CreateColorPixel(new Color(0.4f, 0.1f, 0.1f, 0.25f)); // Darker red for 100× zone + if (!chartStartupZoneTex) + chartStartupZoneTex = Styles.CreateColorPixel(new Color(0.15f, 0.3f, 0.5f, 0.3f)); + if (!chartLineTex) + chartLineTex = Styles.CreateColorPixel(new Color(0.8f, 0.4f, 0.4f, 1f)); + if (!chartMarkerBlueTex) + chartMarkerBlueTex = Styles.CreateColorPixel(new Color(0.4f, 0.6f, 0.9f, 0.5f)); // Blue for startup zone end + if (!chartMarkerGreenTex) + chartMarkerGreenTex = Styles.CreateColorPixel(new Color(0.3f, 0.8f, 0.3f, 0.5f)); // Less prominent + if (!chartMarkerYellowTex) + chartMarkerYellowTex = Styles.CreateColorPixel(new Color(0.9f, 0.9f, 0.3f, 0.5f)); // Less prominent + if (!chartMarkerOrangeTex) + chartMarkerOrangeTex = Styles.CreateColorPixel(new Color(1f, 0.65f, 0f, 0.5f)); // Less prominent + if (!chartMarkerDarkRedTex) + chartMarkerDarkRedTex = Styles.CreateColorPixel(new Color(0.8f, 0.1f, 0.1f, 0.5f)); // Less prominent + if (!chartSeparatorTex) + chartSeparatorTex = Styles.CreateColorPixel(new Color(0.6f, 0.6f, 0.6f, 0.5f)); // Less prominent + if (!chartHoverLineTex) + chartHoverLineTex = Styles.CreateColorPixel(new Color(1f, 1f, 1f, 0.4f)); + if (!chartOrangeLineTex) + chartOrangeLineTex = Styles.CreateColorPixel(new Color(1f, 0.5f, 0.2f, 1f)); + if (!chartGreenLineTex) + chartGreenLineTex = Styles.CreateColorPixel(new Color(0.3f, 0.9f, 0.3f, 1f)); + if (!chartBlueLineTex) + chartBlueLineTex = Styles.CreateColorPixel(new Color(0.5f, 0.85f, 1.0f, 1f)); // Lighter blue for current data + if (!chartTooltipBgTex) + chartTooltipBgTex = Styles.CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.95f)); + if (!infoPanelBgTex) + infoPanelBgTex = Styles.CreateColorPixel(new Color(0.12f, 0.12f, 0.12f, 0.9f)); + } + + /// + /// Format MTBF (mean time between failures) in human-readable units. + /// + private string FormatMTBF(float mtbfSeconds) + { + if (float.IsInfinity(mtbfSeconds) || float.IsNaN(mtbfSeconds)) + return "∞"; + + if (mtbfSeconds < 60f) + return $"{mtbfSeconds:F1}s"; + if (mtbfSeconds < 3600f) + return $"{mtbfSeconds / 60f:F1}m"; + if (mtbfSeconds < 86400f) + return $"{mtbfSeconds / 3600f:F1}h"; + if (mtbfSeconds < 31536000f) + return $"{mtbfSeconds / 86400f:F1}d"; + return $"{mtbfSeconds / 31536000f:F1}y"; + } + + /// + /// Numerically integrate the cycle curve from t1 to t2 using trapezoidal rule. + /// Returns the integral of the cycle modifier over the time interval. + /// + private float IntegrateCycleCurve(FloatCurve curve, float t1, float t2, int steps) + { + if (t2 <= t1) return 0f; + + float dt = (t2 - t1) / steps; + float sum = 0f; + + // Trapezoidal rule: integrate by averaging adjacent points + for (int i = 0; i < steps; i++) + { + float tStart = t1 + i * dt; + float tEnd = tStart + dt; + float valueStart = curve.Evaluate(tStart); + float valueEnd = curve.Evaluate(tEnd); + sum += (valueStart + valueEnd) * 0.5f * dt; + } + + return sum; + } + + /// + /// Build the TestFlight cycle curve exactly as TestFlight_Generic_Engines.cfg does. + /// This matches the ModuleManager patch logic from RealismOverhaul. + /// + private FloatCurve BuildTestFlightCycleCurve(float ratedBurnTime, float testedBurnTime, float overburnPenalty, bool hasTestedBurnTime) + { + FloatCurve curve = new FloatCurve(); + + // Key 1: Early burn high penalty + curve.Add(0.00f, 10.00f); + + // Key 2: Stabilize at 5 seconds + curve.Add(5.00f, 1.00f, -0.8f, 0f); + + // Key 3: Maintain 1.0 until rated burn time (+ 5 second cushion) + float rbtCushioned = ratedBurnTime + 5f; + curve.Add(rbtCushioned, 1f, 0f, 0f); + + if (hasTestedBurnTime) + { + // Key 4: Tested burn time with smooth transition + float ratedToTestedInterval = testedBurnTime - rbtCushioned; + float tbtTransitionSlope = 3.135f / ratedToTestedInterval; + float tbtTransitionSlopeMult = overburnPenalty - 1.0f; + tbtTransitionSlope *= tbtTransitionSlopeMult; + curve.Add(testedBurnTime, overburnPenalty, tbtTransitionSlope, tbtTransitionSlope); + + // Key 5: Complete failure at 2.5x tested burn time + float failTime = testedBurnTime * 2.5f; + float tbtToFailInterval = failTime - testedBurnTime; + float failInSlope = 1.989f / tbtToFailInterval; + float failInSlopeMult = 100f - overburnPenalty; + failInSlope *= failInSlopeMult; + curve.Add(failTime, 100f, failInSlope, 0f); + } + else + { + // Key 4: Complete failure at 2.5x rated burn time (standard overburn) + float failTime = ratedBurnTime * 2.5f; + float rbtToFailInterval = failTime - rbtCushioned; + float failInSlope = 292.8f / rbtToFailInterval; + curve.Add(failTime, 100f, failInSlope, 0f); + } + + return curve; + } + + /// + /// Convert time to x-position on the chart, using either linear or logarithmic scale. + /// + private float TimeToXPosition(float time, float maxTime, float plotX, float plotWidth, bool useLogScale) + { + if (useLogScale) + { + // Logarithmic scale: use log10(time + 1) to handle t=0 + // Map from log10(1) to log10(maxTime + 1) + float logTime = Mathf.Log10(time + 1f); + float logMax = Mathf.Log10(maxTime + 1f); + return plotX + (logTime / logMax) * plotWidth; + } + else + { + // Linear scale + return plotX + (time / maxTime) * plotWidth; + } + } + + /// + /// Convert x-position back to time, using either linear or logarithmic scale. + /// + private float XPositionToTime(float xPos, float maxTime, float plotX, float plotWidth, bool useLogScale) + { + float normalizedX = (xPos - plotX) / plotWidth; + normalizedX = Mathf.Clamp01(normalizedX); + + if (useLogScale) + { + // Inverse of log scale: 10^(normalizedX * log10(maxTime + 1)) - 1 + float logMax = Mathf.Log10(maxTime + 1f); + return Mathf.Pow(10f, normalizedX * logMax) - 1f; + } + else + { + // Linear scale + return normalizedX * maxTime; + } + } + + /// + /// Convert failure probability to y-position on the chart, using either linear or logarithmic scale. + /// + private float FailureProbToYPosition(float failureProb, float yAxisMax, float plotY, float plotHeight, bool useLogScale) + { + if (useLogScale) + { + // Logarithmic scale: use log10(prob + 0.0001) to handle near-zero values + // The +0.0001 offset prevents log(0) and provides a visible baseline + float logProb = Mathf.Log10(failureProb + 0.0001f); + float logMax = Mathf.Log10(yAxisMax + 0.0001f); + float logMin = Mathf.Log10(0.0001f); // Minimum visible value + float normalizedLog = (logProb - logMin) / (logMax - logMin); + return plotY + plotHeight - (normalizedLog * plotHeight); + } + else + { + // Linear scale + return plotY + plotHeight - ((failureProb / yAxisMax) * plotHeight); + } + } + + private void DrawFailureProbabilityChart(ConfigNode configNode, float width, float height) + { + // Ensure textures are cached to prevent loss on window focus change + EnsureChartTextures(); + + // Values are copied to CONFIG level by ModuleManager patch + // Get TestFlight data for both start and end (we plot both) + if (!configNode.HasValue("cycleReliabilityStart")) return; + if (!configNode.HasValue("cycleReliabilityEnd")) return; + if (!float.TryParse(configNode.GetValue("cycleReliabilityStart"), out float cycleReliabilityStart)) return; + if (!float.TryParse(configNode.GetValue("cycleReliabilityEnd"), out float cycleReliabilityEnd)) return; + + // Validate reliability is in valid range + if (cycleReliabilityStart <= 0f || cycleReliabilityStart > 1f) return; + if (cycleReliabilityEnd <= 0f || cycleReliabilityEnd > 1f) return; + + float ratedBurnTime = 0; + if (!configNode.TryGetValue("ratedBurnTime", ref ratedBurnTime) || ratedBurnTime <= 0) return; + + float ratedContinuousBurnTime = ratedBurnTime; + configNode.TryGetValue("ratedContinuousBurnTime", ref ratedContinuousBurnTime); + + // Skip chart if this is a cumulative-limited engine (continuous << total) + if (ratedContinuousBurnTime < ratedBurnTime * 0.9f) return; + + // Read testedBurnTime to match TestFlight's exact behavior + float testedBurnTime = 0f; + bool hasTestedBurnTime = configNode.TryGetValue("testedBurnTime", ref testedBurnTime) && testedBurnTime > ratedBurnTime; + + // Split the area: chart on left (60%), info on right (40%) + float chartWidth = width * 0.58f; + float infoWidth = width * 0.42f; + + float overburnPenalty = 2.0f; // Default from TestFlight_Generic_Engines.cfg + configNode.TryGetValue("overburnPenalty", ref overburnPenalty); + + // Build the actual TestFlight cycle curve + FloatCurve cycleCurve = BuildTestFlightCycleCurve(ratedBurnTime, testedBurnTime, overburnPenalty, hasTestedBurnTime); + + // Main container + Rect containerRect = GUILayoutUtility.GetRect(width, height); + + // Chart area (left side) + const float padding = 38f; + float plotWidth = chartWidth - padding * 2; + float plotHeight = height - padding * 2; + + // Extend max time to show the full cycle curve beyond where it reaches 100× modifier + // The cycle curve reaches maximum at 2.5× (rated or tested), extend to 3.5× to see asymptotic behavior + float maxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; + + Rect chartRect = new Rect(containerRect.x, containerRect.y, chartWidth, height); + Rect plotArea = new Rect(chartRect.x + padding, chartRect.y + padding, plotWidth, plotHeight); + + // Info panel area (right side) + Rect infoRect = new Rect(containerRect.x + chartWidth, containerRect.y, infoWidth, height); + + // Draw background + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(chartRect, chartBgTex); + } + + // Calculate failure probabilities for both curves to determine Y-axis scale + const int curvePoints = 100; + float[] failureProbsStart = new float[curvePoints]; + float[] failureProbsEnd = new float[curvePoints]; + float maxFailureProb = 0f; + + // Base failure rates (from reliability at rated burn time) + float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; + float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; + + for (int i = 0; i < curvePoints; i++) + { + float t = (i / (float)(curvePoints - 1)) * maxTime; + + // Calculate failure using TestFlight's cycle curve + // For t <= ratedBurnTime: standard exponential reliability + // For t > ratedBurnTime: integrate the cycle modifier to account for varying failure rate + + // Calculate for start (0 data) + float failureProbStart = 0f; + if (t <= ratedBurnTime) + { + failureProbStart = 1f - Mathf.Pow(cycleReliabilityStart, t / ratedBurnTime); + } + else + { + // Base failure up to rated time + float survivalToRated = cycleReliabilityStart; + + // Integrate cycle modifier from ratedBurnTime to t using numerical integration + float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); + + // Additional failure rate scaled by integrated modifier + float additionalFailRate = baseRateStart * integratedModifier; + + // Total survival = survive to rated * survive additional time + float survivalProb = survivalToRated * Mathf.Exp(-additionalFailRate); + failureProbStart = Mathf.Clamp01(1f - survivalProb); + } + failureProbsStart[i] = failureProbStart; + maxFailureProb = Mathf.Max(maxFailureProb, failureProbStart); + + // Calculate for end (max data) + float failureProbEnd = 0f; + if (t <= ratedBurnTime) + { + failureProbEnd = 1f - Mathf.Pow(cycleReliabilityEnd, t / ratedBurnTime); + } + else + { + // Base failure up to rated time + float survivalToRated = cycleReliabilityEnd; + + // Integrate cycle modifier from ratedBurnTime to t + float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); + + // Additional failure rate scaled by integrated modifier + float additionalFailRate = baseRateEnd * integratedModifier; + + // Total survival = survive to rated * survive additional time + float survivalProb = survivalToRated * Mathf.Exp(-additionalFailRate); + failureProbEnd = Mathf.Clamp01(1f - survivalProb); + } + failureProbsEnd[i] = failureProbEnd; + maxFailureProb = Mathf.Max(maxFailureProb, failureProbEnd); + } + + // Get current TestFlight data (or use simulated value) + float realDataPercentage = TestFlightWrapper.GetDataPercentage(part); + float dataPercentage = useSimulatedData ? simulatedDataPercentage : realDataPercentage; + bool hasCurrentData = (useSimulatedData && simulatedDataPercentage >= 0f) || (dataPercentage >= 0f && dataPercentage <= 1f); + float[] failureProbsCurrent = null; + float cycleReliabilityCurrent = 0f; + float baseRateCurrent = 0f; + + if (hasCurrentData) + { + // Interpolate current reliability between start and end based on data percentage + cycleReliabilityCurrent = Mathf.Lerp(cycleReliabilityStart, cycleReliabilityEnd, dataPercentage); + baseRateCurrent = -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime; + failureProbsCurrent = new float[curvePoints]; + + for (int i = 0; i < curvePoints; i++) + { + float t = (i / (float)(curvePoints - 1)) * maxTime; + float failureProbCurrent = 0f; + + if (t <= ratedBurnTime) + { + failureProbCurrent = 1f - Mathf.Pow(cycleReliabilityCurrent, t / ratedBurnTime); + } + else + { + float survivalToRated = cycleReliabilityCurrent; + float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); + float additionalFailRate = baseRateCurrent * integratedModifier; + float survivalProb = survivalToRated * Mathf.Exp(-additionalFailRate); + failureProbCurrent = Mathf.Clamp01(1f - survivalProb); + } + + failureProbsCurrent[i] = failureProbCurrent; + maxFailureProb = Mathf.Max(maxFailureProb, failureProbCurrent); + } + } + + // Apply cluster math: for N engines, probability at least one fails = 1 - (1 - singleFailProb)^N + if (clusterSize > 1) + { + for (int i = 0; i < curvePoints; i++) + { + // Transform each failure probability for cluster + float singleSurvival = 1f - failureProbsStart[i]; + failureProbsStart[i] = 1f - Mathf.Pow(singleSurvival, clusterSize); + + singleSurvival = 1f - failureProbsEnd[i]; + failureProbsEnd[i] = 1f - Mathf.Pow(singleSurvival, clusterSize); + + if (hasCurrentData) + { + singleSurvival = 1f - failureProbsCurrent[i]; + failureProbsCurrent[i] = 1f - Mathf.Pow(singleSurvival, clusterSize); + } + + // Update max failure probability after cluster transformation + maxFailureProb = Mathf.Max(maxFailureProb, failureProbsStart[i]); + maxFailureProb = Mathf.Max(maxFailureProb, failureProbsEnd[i]); + if (hasCurrentData) + maxFailureProb = Mathf.Max(maxFailureProb, failureProbsCurrent[i]); + } + } + + // Set Y-axis max to 2% above the maximum failure probability + float yAxisMaxRaw = Mathf.Min(1f, maxFailureProb + 0.02f); + + // Round up to a "nice" number for clean axis labels + float yAxisMax = RoundToNiceNumber(yAxisMaxRaw, true); + // Ensure minimum range for readability + if (yAxisMax < 0.05f) yAxisMax = 0.05f; + + // Draw grid lines and labels with dynamic scale + var labelStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.grey } }; + + if (useLogScaleY) + { + // Logarithmic Y-axis labels: 0.01%, 0.1%, 1%, 10%, 100% + float[] logValues = { 0.0001f, 0.001f, 0.01f, 0.1f, 1f }; // As fractions + foreach (float failureProb in logValues) + { + if (failureProb > yAxisMax) break; // Don't show labels beyond max + + float y = FailureProbToYPosition(failureProb, yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + Rect lineRect = new Rect(plotArea.x, y, plotArea.width, 1); + if (Event.current.type == EventType.Repaint) + GUI.DrawTexture(lineRect, chartGridMajorTex); + + float labelValue = failureProb * 100f; + string label = labelValue < 0.1f ? $"{labelValue:F3}%" : (labelValue < 1f ? $"{labelValue:F2}%" : (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%")); + GUI.Label(new Rect(plotArea.x - 35, y - 10, 30, 20), label, labelStyle); + } + } + else + { + // Linear Y-axis: Major gridlines at 20% intervals, minor at 10% + // Draw all gridlines (major + minor) + for (int i = 0; i <= 10; i++) + { + bool isMajor = (i % 2 == 0); // Major gridlines at 0%, 20%, 40%, 60%, 80%, 100% + float y = plotArea.y + plotArea.height - (i * plotArea.height / 10f); + Rect lineRect = new Rect(plotArea.x, y, plotArea.width, 1); + + if (Event.current.type == EventType.Repaint) + { + // Use major or minor gridline texture + GUI.DrawTexture(lineRect, isMajor ? chartGridMajorTex : chartGridMinorTex); + } + + // Only show labels on major gridlines + if (isMajor) + { + float labelValue = (i / 10f) * yAxisMax * 100f; + string label = labelValue < 1f ? $"{labelValue:F2}%" : (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%"); + GUI.Label(new Rect(plotArea.x - 35, y - 10, 30, 20), label, labelStyle); + } + } + } + + // Draw zone backgrounds based on TestFlight cycle curve segments + // Zone boundaries match the cycle curve keys + float startupEndX = TimeToXPosition(5f, maxTime, plotArea.x, plotArea.width, useLogScaleX); // End of startup zone (0-5s) + float ratedCushionedX = TimeToXPosition(ratedBurnTime + 5f, maxTime, plotArea.x, plotArea.width, useLogScaleX); // Rated + 5s cushion + float testedX = hasTestedBurnTime ? TimeToXPosition(testedBurnTime, maxTime, plotArea.x, plotArea.width, useLogScaleX) : 0f; + + // Calculate 100× modifier point (at 2.5× the reference burn time) + float referenceBurnTime = hasTestedBurnTime ? testedBurnTime : ratedBurnTime; + float max100xTime = referenceBurnTime * 2.5f; + float max100xX = TimeToXPosition(max100xTime, maxTime, plotArea.x, plotArea.width, useLogScaleX); + + if (Event.current.type == EventType.Repaint) + { + // Zone 1: Startup (0-5s) - Dark blue (high initial risk) + GUI.DrawTexture(new Rect(plotArea.x, plotArea.y, startupEndX - plotArea.x, plotArea.height), chartStartupZoneTex); + + // Zone 2: Rated Operation (5s to ratedBurnTime+5) - Green (safe zone) + GUI.DrawTexture(new Rect(startupEndX, plotArea.y, ratedCushionedX - startupEndX, plotArea.height), + chartGreenZoneTex); + + if (hasTestedBurnTime) + { + // Zone 3: Tested Overburn (rated+5 to tested) - Yellow (reduced penalty overburn) + GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, testedX - ratedCushionedX, plotArea.height), + chartYellowZoneTex); + + // Zone 4: Severe Overburn (tested to 100×) - Red (danger zone) + GUI.DrawTexture(new Rect(testedX, plotArea.y, max100xX - testedX, plotArea.height), + chartRedZoneTex); + + // Zone 5: Maximum Overburn (100× to end) - Darker red (nearly linear failure increase) + GUI.DrawTexture(new Rect(max100xX, plotArea.y, plotArea.x + plotArea.width - max100xX, plotArea.height), + chartDarkRedZoneTex); + } + else + { + // Zone 3: Overburn (rated+5 to 100×) - Red (danger zone) + GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, max100xX - ratedCushionedX, plotArea.height), + chartRedZoneTex); + + // Zone 4: Maximum Overburn (100× to end) - Darker red (nearly linear failure increase) + GUI.DrawTexture(new Rect(max100xX, plotArea.y, plotArea.x + plotArea.width - max100xX, plotArea.height), + chartDarkRedZoneTex); + } + } + + // Draw vertical zone separators (thinner and less prominent) + if (Event.current.type == EventType.Repaint) + { + // Startup zone end (5s) - Blue + GUI.DrawTexture(new Rect(startupEndX, plotArea.y, 1, plotArea.height), chartMarkerBlueTex); + + // Rated burn time (+ 5s cushion) - Green + GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, 1, plotArea.height), chartMarkerGreenTex); + + // Tested burn time (if present) - Yellow + if (hasTestedBurnTime) + { + GUI.DrawTexture(new Rect(testedX, plotArea.y, 1, plotArea.height), chartMarkerYellowTex); + } + + // 100× modifier point (maximum cycle penalty) - Dark Red + GUI.DrawTexture(new Rect(max100xX, plotArea.y, 1, plotArea.height), chartMarkerDarkRedTex); + } + + // Now calculate point positions for all curves using the dynamic Y scale + Vector2[] pointsStart = new Vector2[curvePoints]; + Vector2[] pointsEnd = new Vector2[curvePoints]; + Vector2[] pointsCurrent = hasCurrentData ? new Vector2[curvePoints] : null; + + for (int i = 0; i < curvePoints; i++) + { + float t = (i / (float)(curvePoints - 1)) * maxTime; + float x = TimeToXPosition(t, maxTime, plotArea.x, plotArea.width, useLogScaleX); + + // Start curve (0 data) - orange + float yStart = FailureProbToYPosition(failureProbsStart[i], yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + if (float.IsNaN(x) || float.IsNaN(yStart) || float.IsInfinity(x) || float.IsInfinity(yStart)) + { + x = plotArea.x; + yStart = plotArea.y + plotArea.height; + } + pointsStart[i] = new Vector2(x, yStart); + + // End curve (max data) - green + float yEnd = FailureProbToYPosition(failureProbsEnd[i], yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + if (float.IsNaN(x) || float.IsNaN(yEnd) || float.IsInfinity(x) || float.IsInfinity(yEnd)) + { + x = plotArea.x; + yEnd = plotArea.y + plotArea.height; + } + pointsEnd[i] = new Vector2(x, yEnd); + + // Current data curve - light blue + if (hasCurrentData) + { + float yCurrent = FailureProbToYPosition(failureProbsCurrent[i], yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + if (float.IsNaN(x) || float.IsNaN(yCurrent) || float.IsInfinity(x) || float.IsInfinity(yCurrent)) + { + x = plotArea.x; + yCurrent = plotArea.y + plotArea.height; + } + pointsCurrent[i] = new Vector2(x, yCurrent); + } + } + + // Draw all curves + if (Event.current.type == EventType.Repaint) + { + // Draw start curve (0 data) in orange + for (int i = 0; i < pointsStart.Length - 1; i++) + { + DrawLine(pointsStart[i], pointsStart[i + 1], chartOrangeLineTex, 2.5f); + } + + // Draw end curve (max data) in green + for (int i = 0; i < pointsEnd.Length - 1; i++) + { + DrawLine(pointsEnd[i], pointsEnd[i + 1], chartGreenLineTex, 2.5f); + } + + // Draw current data curve in light blue + if (hasCurrentData && pointsCurrent != null) + { + for (int i = 0; i < pointsCurrent.Length - 1; i++) + { + DrawLine(pointsCurrent[i], pointsCurrent[i + 1], chartBlueLineTex, 2.5f); + } + } + } + + // X-axis labels (time in minutes) + var timeStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.grey }, alignment = TextAnchor.UpperCenter }; + + if (useLogScaleX) + { + // Logarithmic scale labels: show key time points + float[] logTimes = { 0.1f, 1f, 10f, 60f, 300f, 600f, 1800f, 3600f }; + foreach (float time in logTimes) + { + if (time > maxTime) break; + float x = TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, useLogScaleX); + string label = time < 60f ? $"{time:F0}s" : $"{time / 60:F0}m"; + GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), label, timeStyle); + } + } + else + { + // Linear scale labels + for (int i = 0; i <= 4; i++) + { + float time = (i / 4f) * maxTime; + float x = TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, useLogScaleX); + GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), $"{time / 60:F0}m", timeStyle); + } + } + + // Chart title + var titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, fontStyle = FontStyle.Bold, normal = { textColor = Color.white }, alignment = TextAnchor.MiddleCenter }; + GUI.Label(new Rect(chartRect.x, chartRect.y + 4, chartWidth, 24), "Failure Probability vs Burn Time", titleStyle); + + // Legend with colored circles + var legendStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.white }, alignment = TextAnchor.UpperLeft }; + float legendX = plotArea.x + 10; + float legendY = plotArea.y + 5; + + // Orange circle and line for 0 data + DrawCircle(new Rect(legendX, legendY + 5, 8, 8), chartOrangeLineTex); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 7, 15, 3), chartOrangeLineTex); + GUI.Label(new Rect(legendX + 28, legendY, 80, 18), "0 Data", legendStyle); + + // Blue circle and line for current data (if available) + if (hasCurrentData) + { + DrawCircle(new Rect(legendX, legendY + 23, 8, 8), chartBlueLineTex); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), chartBlueLineTex); + GUI.Label(new Rect(legendX + 28, legendY + 18, 100, 18), "Current Data", legendStyle); + + // Green circle and line for max data (shifted down) + DrawCircle(new Rect(legendX, legendY + 41, 8, 8), chartGreenLineTex); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 43, 15, 3), chartGreenLineTex); + GUI.Label(new Rect(legendX + 28, legendY + 36, 80, 18), "Max Data", legendStyle); + } + else + { + // Green circle and line for max data (no shift if no current data) + DrawCircle(new Rect(legendX, legendY + 23, 8, 8), chartGreenLineTex); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), chartGreenLineTex); + GUI.Label(new Rect(legendX + 28, legendY + 18, 80, 18), "Max Data", legendStyle); + } + + // Tooltip handling and hover line + Vector2 mousePos = Event.current.mousePosition; + if (plotArea.Contains(mousePos)) + { + // Draw vertical hover line + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(mousePos.x, plotArea.y, 1, plotArea.height), chartHoverLineTex); + } + + // Calculate the time at mouse position + float mouseT = XPositionToTime(mousePos.x, maxTime, plotArea.x, plotArea.width, useLogScaleX); + mouseT = Mathf.Clamp(mouseT, 0f, maxTime); + + // Determine which zone we're in + string zoneName = ""; + if (mouseT <= 5f) + { + zoneName = "Engine Startup"; + } + else if (mouseT <= ratedBurnTime + 5f) + { + zoneName = "Rated Operation"; + } + else if (hasTestedBurnTime && mouseT <= testedBurnTime) + { + zoneName = "Tested Overburn"; + } + else if (mouseT <= max100xTime) + { + zoneName = "Severe Overburn"; + } + else + { + zoneName = "Maximum Overburn"; + } + + // Calculate cycle modifier at this time + float cycleModifier = cycleCurve.Evaluate(mouseT); + + // Check if hovering near vertical markers for specific marker info + bool nearStartupMarker = Mathf.Abs(mousePos.x - startupEndX) < 8f; + bool nearRatedMarker = Mathf.Abs(mousePos.x - ratedCushionedX) < 8f; + bool nearTestedMarker = hasTestedBurnTime && Mathf.Abs(mousePos.x - testedX) < 8f; + bool near100xMarker = Mathf.Abs(mousePos.x - max100xX) < 8f; + + string tooltipText = ""; + string valueColor = "#88DDFF"; // Light cyan for values + + if (nearStartupMarker) + { + tooltipText = $"Startup Period End\n\nFailure risk drops from 10× to 1× during startup.\nAfter 5 seconds, the engine reaches stable operation."; + } + else if (nearRatedMarker) + { + float ratedMinutes = ratedBurnTime / 60f; + string ratedTimeStr = ratedMinutes >= 1f ? $"{ratedMinutes:F1}m" : $"{ratedBurnTime:F0}s"; + tooltipText = $"Rated Burn Time\n\nThis engine is designed to run for {ratedTimeStr}.\nBeyond this point, overburn penalties increase failure risk."; + } + else if (nearTestedMarker) + { + float testedMinutes = testedBurnTime / 60f; + string testedTimeStr = testedMinutes >= 1f ? $"{testedMinutes:F1}m" : $"{testedBurnTime:F0}s"; + tooltipText = $"Tested Overburn Limit\n\nThis engine was tested to {testedTimeStr} in real life.\nFailure risk reaches {overburnPenalty:F1}× at this point.\nBeyond here, risk increases rapidly toward certain failure."; + } + else if (near100xMarker) + { + float max100xMinutes = max100xTime / 60f; + string max100xTimeStr = max100xMinutes >= 1f ? $"{max100xMinutes:F1}m" : $"{max100xTime:F0}s"; + tooltipText = $"Maximum Cycle Penalty (100×)\n\nAt {max100xTimeStr}, the failure rate multiplier reaches its maximum of 100×.\n\nBeyond this point, it doesn't get much worse—failure probability increases nearly linearly with time."; + } + else + { + // Calculate failure probabilities at mouse position + float mouseFailStart = 0f; + float mouseFailEnd = 0f; + float mouseFailCurrent = 0f; + + if (mouseT <= ratedBurnTime) + { + mouseFailStart = 1f - Mathf.Pow(cycleReliabilityStart, mouseT / ratedBurnTime); + mouseFailEnd = 1f - Mathf.Pow(cycleReliabilityEnd, mouseT / ratedBurnTime); + if (hasCurrentData) + mouseFailCurrent = 1f - Mathf.Pow(cycleReliabilityCurrent, mouseT / ratedBurnTime); + } + else + { + float survivalToRatedStart = cycleReliabilityStart; + float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, mouseT, 20); + float additionalFailRate = baseRateStart * integratedModifier; + mouseFailStart = Mathf.Clamp01(1f - (survivalToRatedStart * Mathf.Exp(-additionalFailRate))); + + float survivalToRatedEnd = cycleReliabilityEnd; + additionalFailRate = baseRateEnd * integratedModifier; + mouseFailEnd = Mathf.Clamp01(1f - (survivalToRatedEnd * Mathf.Exp(-additionalFailRate))); + + if (hasCurrentData) + { + float survivalToRatedCurrent = cycleReliabilityCurrent; + additionalFailRate = baseRateCurrent * integratedModifier; + mouseFailCurrent = Mathf.Clamp01(1f - (survivalToRatedCurrent * Mathf.Exp(-additionalFailRate))); + } + } + + // Apply cluster math to tooltip values + if (clusterSize > 1) + { + mouseFailStart = 1f - Mathf.Pow(1f - mouseFailStart, clusterSize); + mouseFailEnd = 1f - Mathf.Pow(1f - mouseFailEnd, clusterSize); + if (hasCurrentData) + mouseFailCurrent = 1f - Mathf.Pow(1f - mouseFailCurrent, clusterSize); + } + + // Format time string + float minutes = Mathf.Floor(mouseT / 60f); + float seconds = mouseT % 60f; + string timeStr = minutes > 0 ? $"{minutes:F0}m {seconds:F0}s" : $"{seconds:F1}s"; + + // Color code the zone name based on zone type + string zoneColor = ""; + if (mouseT <= 5f) + zoneColor = "#6699CC"; // Blue for startup + else if (mouseT <= ratedBurnTime + 5f) + zoneColor = "#66DD66"; // Green for rated + else if (hasTestedBurnTime && mouseT <= testedBurnTime) + zoneColor = "#FFCC44"; // Yellow for tested overburn + else if (mouseT <= max100xTime) + zoneColor = "#FF6666"; // Red for severe overburn + else + zoneColor = "#CC2222"; // Dark red for maximum overburn + + // Build tooltip with color-coded values (valueColor already defined above) + string orangeColor = "#FF8033"; // Match orange line (0 data) + string blueColor = "#7DD9FF"; // Match lighter blue line (current data) + string greenColor = "#4DE64D"; // Match green line (max data) + + tooltipText = $"{zoneName}\n\n"; + tooltipText += $"At {timeStr}, this engine has a:\n\n"; + tooltipText += $" {mouseFailStart * 100f:F2}% chance to fail (0 data)\n"; + if (hasCurrentData) + tooltipText += $" {mouseFailCurrent * 100f:F2}% chance to fail (current data)\n"; + tooltipText += $" {mouseFailEnd * 100f:F2}% chance to fail (max data)\n\n"; + tooltipText += $"Cycle modifier: {cycleModifier:F2}×"; + } + + // Store tooltip to draw last (after info panel) so it appears on top + string finalTooltipText = tooltipText; + Vector2 finalMousePos = mousePos; + + // Draw info panel first + float ignitionReliabilityStart = 1f; + float ignitionReliabilityEnd = 1f; + configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); + configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); + + // Calculate current ignition reliability + float ignitionReliabilityCurrent = hasCurrentData ? Mathf.Lerp(ignitionReliabilityStart, ignitionReliabilityEnd, dataPercentage) : 0f; + + DrawFailureInfoPanel(infoRect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, + hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, realDataPercentage); + + // Draw tooltip last so it appears on top of everything + DrawChartTooltip(finalMousePos, finalTooltipText); + } + else + { + // No hover, just draw info panel + float ignitionReliabilityStart = 1f; + float ignitionReliabilityEnd = 1f; + configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); + configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); + + // Calculate current ignition reliability + float ignitionReliabilityCurrent = hasCurrentData ? Mathf.Lerp(ignitionReliabilityStart, ignitionReliabilityEnd, dataPercentage) : 0f; + + DrawFailureInfoPanel(infoRect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, + hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, realDataPercentage); + } + } + + private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, + float cycleReliabilityStart, float cycleReliabilityEnd, float ignitionReliabilityStart, float ignitionReliabilityEnd, + bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage, float realDataPercentage) + { + // Draw background + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(rect, infoPanelBgTex); + } + + // Calculate success probabilities (chance to complete the burn) + float ratedSuccessStart = cycleReliabilityStart * 100f; + float ratedSuccessEnd = cycleReliabilityEnd * 100f; + float ignitionSuccessStart = ignitionReliabilityStart * 100f; + float ignitionSuccessEnd = ignitionReliabilityEnd * 100f; + + // Calculate tested burn success if available + float testedSuccessStart = 0f; + float testedSuccessEnd = 0f; + if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) + { + // Use the cycle reliability for the full tested duration + float testedRatio = testedBurnTime / ratedBurnTime; + testedSuccessStart = Mathf.Pow(cycleReliabilityStart, testedRatio) * 100f; + testedSuccessEnd = Mathf.Pow(cycleReliabilityEnd, testedRatio) * 100f; + } + + // Calculate current data success probabilities + float ratedSuccessCurrent = 0f; + float testedSuccessCurrent = 0f; + float ignitionSuccessCurrent = 0f; + if (hasCurrentData) + { + ratedSuccessCurrent = cycleReliabilityCurrent * 100f; + ignitionSuccessCurrent = ignitionReliabilityCurrent * 100f; + if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) + { + float testedRatio = testedBurnTime / ratedBurnTime; + testedSuccessCurrent = Mathf.Pow(cycleReliabilityCurrent, testedRatio) * 100f; + } + } + + // Apply cluster math: for N engines all succeeding = (singleSuccess)^N + if (clusterSize > 1) + { + // Convert from percentage to decimal, apply power, convert back + ignitionSuccessStart = Mathf.Pow(ignitionSuccessStart / 100f, clusterSize) * 100f; + ignitionSuccessEnd = Mathf.Pow(ignitionSuccessEnd / 100f, clusterSize) * 100f; + ratedSuccessStart = Mathf.Pow(ratedSuccessStart / 100f, clusterSize) * 100f; + ratedSuccessEnd = Mathf.Pow(ratedSuccessEnd / 100f, clusterSize) * 100f; + testedSuccessStart = Mathf.Pow(testedSuccessStart / 100f, clusterSize) * 100f; + testedSuccessEnd = Mathf.Pow(testedSuccessEnd / 100f, clusterSize) * 100f; + + if (hasCurrentData) + { + ignitionSuccessCurrent = Mathf.Pow(ignitionSuccessCurrent / 100f, clusterSize) * 100f; + ratedSuccessCurrent = Mathf.Pow(ratedSuccessCurrent / 100f, clusterSize) * 100f; + testedSuccessCurrent = Mathf.Pow(testedSuccessCurrent / 100f, clusterSize) * 100f; + } + } + + // Style for rich text labels + var textStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + normal = { textColor = Color.white }, + wordWrap = true, + richText = true, + padding = new RectOffset(8, 8, 2, 2) + }; + + var headerStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 15, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, + wordWrap = true, + richText = true, + padding = new RectOffset(8, 8, 0, 4) // No top padding to align with chart title + }; + + var sectionStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(1f, 0.5f, 0.2f) }, // Orange for 0 data + wordWrap = true, + richText = true, + padding = new RectOffset(8, 8, 4, 2) + }; + + // Color codes matching the chart lines + string orangeColor = "#FF8033"; // 0 data + string blueColor = "#7DD9FF"; // Current data (lighter blue) + string greenColor = "#4DE64D"; // Max data + string valueColor = "#88DDFF"; // Time values + + // Start at same vertical position as chart title for alignment + float yPos = rect.y + 4; + + // Title + GUI.Label(new Rect(rect.x, yPos, rect.width, 20), "Engine Reliability:", headerStyle); + yPos += 24; + + // === 0 DATA SECTION (Orange) === + sectionStyle.normal.textColor = new Color(1f, 0.5f, 0.2f); + GUI.Label(new Rect(rect.x, yPos, rect.width, 18), "At 0 Data:", sectionStyle); + yPos += 20; + + // Build narrative text for 0 data + string engineText = clusterSize > 1 ? $"A cluster of {clusterSize} engines" : "This engine"; + string text0Data = $"{engineText} has a {ignitionSuccessStart:F1}% chance for all to ignite, "; + text0Data += $"then a {ratedSuccessStart:F1}% chance for all to burn for {ratedBurnTime:F0}s (rated)"; + if (hasTestedBurnTime) + text0Data += $", and a {testedSuccessStart:F1}% chance for all to burn to {testedBurnTime:F0}s (tested)"; + text0Data += "."; + + float height0 = textStyle.CalcHeight(new GUIContent(text0Data), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, height0), text0Data, textStyle); + yPos += height0 + 8; + + // === CURRENT DATA SECTION (Blue) - only if available === + if (hasCurrentData) + { + sectionStyle.normal.textColor = new Color(0.49f, 0.85f, 1.0f); // Lighter blue to match line + GUI.Label(new Rect(rect.x, yPos, rect.width, 18), $"At Current Data ({dataPercentage * 100f:F0}%):", sectionStyle); + yPos += 20; + + // Build narrative text for current data + string textCurrentData = $"{engineText} has a {ignitionSuccessCurrent:F1}% chance for all to ignite, "; + textCurrentData += $"then a {ratedSuccessCurrent:F1}% chance for all to burn for {ratedBurnTime:F0}s (rated)"; + if (hasTestedBurnTime) + textCurrentData += $", and a {testedSuccessCurrent:F1}% chance for all to burn to {testedBurnTime:F0}s (tested)"; + textCurrentData += "."; + + float heightCurrent = textStyle.CalcHeight(new GUIContent(textCurrentData), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, heightCurrent), textCurrentData, textStyle); + yPos += heightCurrent + 8; + } + + // === MAX DATA SECTION (Green) === + sectionStyle.normal.textColor = new Color(0.3f, 0.9f, 0.3f); + GUI.Label(new Rect(rect.x, yPos, rect.width, 18), "At Max Data:", sectionStyle); + yPos += 20; + + // Build narrative text for max data + string textMaxData = $"{engineText} has a {ignitionSuccessEnd:F1}% chance for all to ignite, "; + textMaxData += $"then a {ratedSuccessEnd:F1}% chance for all to burn for {ratedBurnTime:F0}s (rated)"; + if (hasTestedBurnTime) + textMaxData += $", and a {testedSuccessEnd:F1}% chance for all to burn to {testedBurnTime:F0}s (tested)"; + textMaxData += "."; + + float heightMax = textStyle.CalcHeight(new GUIContent(textMaxData), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, heightMax), textMaxData, textStyle); + yPos += heightMax + 16; + + // === SIMULATION CONTROLS === + bool hasRealData = realDataPercentage >= 0f && realDataPercentage <= 1f; + + var controlStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 12, + normal = { textColor = new Color(0.8f, 0.8f, 0.8f) }, + padding = new RectOffset(8, 8, 2, 2) + }; + + var sliderStyle = GUI.skin.horizontalSlider; + var thumbStyle = GUI.skin.horizontalSliderThumb; + var buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 11 }; + var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 11, alignment = TextAnchor.MiddleCenter }; + + // Separator line + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), chartSeparatorTex); + } + yPos += 6; + + // === BUTTONS ROW: Log X, Log Y, Reset (3 side by side) === + float btnWidth = (rect.width - 24) / 3f; + float btnSpacing = 4f; + + // X-axis log scale toggle + string toggleLabelX = useLogScaleX ? "X: Lin" : "X: Log"; + if (GUI.Button(new Rect(rect.x + 8, yPos, btnWidth, 20), toggleLabelX, buttonStyle)) + { + useLogScaleX = !useLogScaleX; + } + + // Y-axis log scale toggle + string toggleLabelY = useLogScaleY ? "Y: Lin" : "Y: Log"; + if (GUI.Button(new Rect(rect.x + 8 + btnWidth + btnSpacing, yPos, btnWidth, 20), toggleLabelY, buttonStyle)) + { + useLogScaleY = !useLogScaleY; + } + + // Reset button + string resetButtonText = hasRealData ? $"{realDataPercentage * 100f:F0}%" : "0%"; + if (GUI.Button(new Rect(rect.x + 8 + (btnWidth + btnSpacing) * 2, yPos, btnWidth, 20), resetButtonText, buttonStyle)) + { + if (hasRealData) + { + simulatedDataPercentage = realDataPercentage; + dataPercentageInput = $"{realDataPercentage * 100f:F0}"; + useSimulatedData = false; + } + else + { + simulatedDataPercentage = 0f; + dataPercentageInput = "0"; + useSimulatedData = true; + } + clusterSize = 1; + clusterSizeInput = "1"; + } + + yPos += 26; + + // === TWO SLIDERS SIDE BY SIDE === + float halfWidth = (rect.width - 24) / 2f; + float leftX = rect.x; + float rightX = rect.x + halfWidth + 8; + + // LEFT: DATA PERCENTAGE + GUI.Label(new Rect(leftX, yPos, halfWidth, 16), "Data %", controlStyle); + GUI.Label(new Rect(rightX, yPos, halfWidth, 16), "Cluster", controlStyle); + yPos += 16; + + // Initialize simulated value to real data if not yet simulating + if (!useSimulatedData) + { + simulatedDataPercentage = hasRealData ? realDataPercentage : 0f; + dataPercentageInput = $"{simulatedDataPercentage * 100f:F0}"; + } + + // Data % slider + simulatedDataPercentage = GUI.HorizontalSlider(new Rect(leftX + 8, yPos, halfWidth - 60, 16), + simulatedDataPercentage * 100f, 0f, 100f, sliderStyle, thumbStyle) / 100f; + + // Mark as simulated if user moved slider away from real data + if (hasRealData && Mathf.Abs(simulatedDataPercentage - realDataPercentage) > 0.001f) + { + useSimulatedData = true; + } + else if (!hasRealData && simulatedDataPercentage > 0.001f) + { + useSimulatedData = true; + } + + // Data % input field + dataPercentageInput = $"{simulatedDataPercentage * 100f:F0}"; + GUI.SetNextControlName("dataPercentInput"); + string newDataInput = GUI.TextField(new Rect(leftX + halfWidth - 45, yPos - 2, 40, 20), + dataPercentageInput, 5, inputStyle); + + if (newDataInput != dataPercentageInput) + { + dataPercentageInput = newDataInput; + if (GUI.GetNameOfFocusedControl() == "dataPercentInput" && float.TryParse(dataPercentageInput, out float inputDataPercent)) + { + inputDataPercent = Mathf.Clamp(inputDataPercent, 0f, 100f); + simulatedDataPercentage = inputDataPercent / 100f; + useSimulatedData = true; + } + } + + // RIGHT: CLUSTER SIZE + // Cluster slider + clusterSize = Mathf.RoundToInt(GUI.HorizontalSlider(new Rect(rightX + 8, yPos, halfWidth - 60, 16), + clusterSize, 1f, 20f, sliderStyle, thumbStyle)); + + // Cluster input field + clusterSizeInput = clusterSize.ToString(); + GUI.SetNextControlName("clusterSizeInput"); + string newClusterInput = GUI.TextField(new Rect(rightX + halfWidth - 45, yPos - 2, 40, 20), + clusterSizeInput, 3, inputStyle); + + if (newClusterInput != clusterSizeInput) + { + clusterSizeInput = newClusterInput; + if (GUI.GetNameOfFocusedControl() == "clusterSizeInput" && int.TryParse(clusterSizeInput, out int inputCluster)) + { + inputCluster = Mathf.Clamp(inputCluster, 1, 20); + clusterSize = inputCluster; + clusterSizeInput = clusterSize.ToString(); + } + } + } + + private void DrawLine(Vector2 start, Vector2 end, Texture2D texture, float width) + { + if (texture == null) + return; + + Vector2 diff = end - start; + float angle = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg; + float length = diff.magnitude; + + Matrix4x4 matrixBackup = GUI.matrix; + try + { + GUIUtility.RotateAroundPivot(angle, start); + GUI.DrawTexture(new Rect(start.x, start.y - width / 2, length, width), texture); + } + finally + { + GUI.matrix = matrixBackup; + } + } + + private void DrawCircle(Rect rect, Texture2D texture) + { + if (texture == null || Event.current.type != EventType.Repaint) + return; + + // Draw circle by drawing a filled square with rounded appearance + // For simplicity, we'll draw concentric squares that approximate a circle + float centerX = rect.x + rect.width / 2f; + float centerY = rect.y + rect.height / 2f; + float radius = rect.width / 2f; + + for (float r = radius; r > 0; r -= 0.5f) + { + float size = r * 2f; + GUI.DrawTexture(new Rect(centerX - r, centerY - r, size, size), texture); + } + } + + private float RoundToNiceNumber(float value, bool roundUp) + { + if (value <= 0f) return 0f; + + // Find the order of magnitude + float exponent = Mathf.Floor(Mathf.Log10(value)); + float fraction = value / Mathf.Pow(10f, exponent); + + // Round to 1, 2, or 5 times the magnitude + float niceFraction; + if (roundUp) + { + if (fraction <= 1f) niceFraction = 1f; + else if (fraction <= 2f) niceFraction = 2f; + else if (fraction <= 5f) niceFraction = 5f; + else niceFraction = 10f; + } + else + { + if (fraction < 1.5f) niceFraction = 1f; + else if (fraction < 3.5f) niceFraction = 2f; + else if (fraction < 7.5f) niceFraction = 5f; + else niceFraction = 10f; + } + + return niceFraction * Mathf.Pow(10f, exponent); + } + + private void DrawChartTooltip(Vector2 mousePos, string text) + { + if (string.IsNullOrEmpty(text)) return; + + var tooltipStyle = new GUIStyle(GUI.skin.box) + { + fontSize = 15, + normal = { textColor = Color.white, background = chartTooltipBgTex }, + padding = new RectOffset(8, 8, 6, 6), + alignment = TextAnchor.MiddleLeft, + wordWrap = false, + richText = true + }; + + GUIContent content = new GUIContent(text); + Vector2 size = tooltipStyle.CalcSize(content); + + // Position tooltip offset from mouse, but keep it within screen bounds + float tooltipX = mousePos.x + 15; + float tooltipY = mousePos.y + 15; + + // Adjust if tooltip would go off-screen + if (tooltipX + size.x > Screen.width) + tooltipX = mousePos.x - size.x - 5; + if (tooltipY + size.y > Screen.height) + tooltipY = mousePos.y - size.y - 5; + + Rect tooltipRect = new Rect(tooltipX, tooltipY, size.x, size.y); + GUI.Box(tooltipRect, content, tooltipStyle); } virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) @@ -2479,7 +3778,7 @@ protected void DrawTechLevelSelector() plusStr = string.Empty; if (cost > 0d) { - plusStr += cost.ToString("N0") + "f"; + plusStr += cost.ToString("N0") + "√"; autobuy = false; canBuy = true; } @@ -2531,9 +3830,20 @@ private void EngineManagerGUI(int WindowID) GUILayout.Space(6); // Space before table DrawConfigSelectors(FilteredDisplayConfigs(false)); + // Draw failure probability chart for current config + if (config != null) + { + GUILayout.Space(8); + DrawFailureProbabilityChart(config, guiWindowRect.width - 10, 360); + } + DrawTechLevelSelector(); - GUILayout.Space(-80); // Remove all bottom padding - window ends right at table + // Only use negative space if no chart (chart needs the room) + if (config == null || !config.HasValue("cycleReliabilityStart")) + GUILayout.Space(-80); // Remove all bottom padding - window ends right at table + else + GUILayout.Space(8); // Add space after chart if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty)) { diff --git a/Source/Utilities/TestFlightWrapper.cs b/Source/Utilities/TestFlightWrapper.cs index 599be013..e1a419ef 100644 --- a/Source/Utilities/TestFlightWrapper.cs +++ b/Source/Utilities/TestFlightWrapper.cs @@ -1,23 +1,39 @@ using System; using System.Linq; using System.Reflection; +using UnityEngine; namespace RealFuels { public static class TestFlightWrapper { private const BindingFlags tfBindingFlags = BindingFlags.Public | BindingFlags.Static; + private const BindingFlags tfInstanceFlags = BindingFlags.Public | BindingFlags.Instance; private static Type tfInterface = null; + private static Type tfCore = null; private static MethodInfo addInteropValue = null; + private static MethodInfo getFlightData = null; + private static MethodInfo getMaxData = null; static TestFlightWrapper() { - if (AssemblyLoader.loadedAssemblies.FirstOrDefault(a => a.assembly.GetName().Name == "TestFlightCore")?.assembly is Assembly) + if (AssemblyLoader.loadedAssemblies.FirstOrDefault(a => a.assembly.GetName().Name == "TestFlightCore")?.assembly is Assembly tfAssembly) { tfInterface = Type.GetType("TestFlightCore.TestFlightInterface, TestFlightCore", false); - Type[] argumentTypes = new[] { typeof(Part), typeof(string), typeof(string), typeof(string) }; - addInteropValue = tfInterface.GetMethod("AddInteropValue", tfBindingFlags, null, argumentTypes, null); + tfCore = Type.GetType("TestFlightCore.TestFlightCore, TestFlightCore", false); + + if (tfInterface != null) + { + Type[] argumentTypes = new[] { typeof(Part), typeof(string), typeof(string), typeof(string) }; + addInteropValue = tfInterface.GetMethod("AddInteropValue", tfBindingFlags, null, argumentTypes, null); + } + + if (tfCore != null) + { + getFlightData = tfCore.GetMethod("GetFlightData", tfInstanceFlags); + getMaxData = tfCore.GetMethod("GetMaximumData", tfInstanceFlags); + } } } @@ -33,5 +49,74 @@ public static void AddInteropValue(Part part, string name, string value, string catch { } } } + + /// + /// Gets the current flight data for the active TestFlight configuration on this part. + /// Returns -1 if TestFlight is not available or no data found. + /// + public static float GetCurrentFlightData(Part part) + { + if (tfCore == null || getFlightData == null || part == null) + return -1f; + + try + { + // Find TestFlightCore module on the part + foreach (PartModule pm in part.Modules) + { + if (pm.GetType() == tfCore || pm.GetType().IsSubclassOf(tfCore)) + { + object result = getFlightData.Invoke(pm, null); + if (result is float flightData) + return flightData; + } + } + } + catch { } + + return -1f; + } + + /// + /// Gets the maximum data value for the active TestFlight configuration on this part. + /// Returns -1 if TestFlight is not available or no data found. + /// + public static float GetMaximumData(Part part) + { + if (tfCore == null || getMaxData == null || part == null) + return -1f; + + try + { + // Find TestFlightCore module on the part + foreach (PartModule pm in part.Modules) + { + if (pm.GetType() == tfCore || pm.GetType().IsSubclassOf(tfCore)) + { + object result = getMaxData.Invoke(pm, null); + if (result is float maxData) + return maxData; + } + } + } + catch { } + + return -1f; + } + + /// + /// Gets the current data percentage (0 to 1) between 0 and max data. + /// Returns -1 if TestFlight is not available or no valid data. + /// + public static float GetDataPercentage(Part part) + { + float currentData = GetCurrentFlightData(part); + float maxData = GetMaximumData(part); + + if (currentData < 0f || maxData <= 0f) + return -1f; + + return Mathf.Clamp01(currentData / maxData); + } } } From 783732c5ddadcab0f22be65005c65f86bc1ec078 Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 14:04:55 -0800 Subject: [PATCH 03/12] Implement feature X to enhance user experience and optimize performance --- Source/Engines/ModuleEngineConfigs.cs | 736 ++++++++++++++++++++------ 1 file changed, 582 insertions(+), 154 deletions(-) diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index ec923484..3177639c 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1428,7 +1428,11 @@ public void OnDestroy() } private static Vector3 mousePos = Vector3.zero; - private Rect guiWindowRect = new Rect(0, 0, 0, 0); + private static Rect guiWindowRect = new Rect(0, 0, 0, 0); + private static uint lastPartId = 0; + private static int lastConfigCount = 0; + private static bool lastCompactView = false; + private static bool lastHasChart = false; private string myToolTip = string.Empty; private int counterTT; private bool editorLocked = false; @@ -1439,17 +1443,33 @@ public void OnDestroy() private bool useLogScaleX = false; // Toggle for logarithmic x-axis on failure chart private bool useLogScaleY = false; // Toggle for logarithmic y-axis on failure chart + // Column visibility customization + private bool showColumnMenu = false; + private static Rect columnMenuRect = new Rect(100, 100, 280, 650); // Separate window rect - tall enough for all columns + private static bool[] columnsVisibleFull = new bool[19]; + private static bool[] columnsVisibleCompact = new bool[19]; + private static bool columnVisibilityInitialized = false; + // Simulation controls for data percentage and cluster size private bool useSimulatedData = false; // Whether to override real TestFlight data - private float simulatedDataPercentage = 0f; // Simulated data percentage (0-1) + private float simulatedDataValue = 0f; // Simulated data value in du (data units) private int clusterSize = 1; // Number of engines in cluster (default 1) private string clusterSizeInput = "1"; // Text input for cluster size - private string dataPercentageInput = "0"; // Text input for data percentage + private string dataValueInput = "0"; // Text input for data value in du + + // Chart axis limit controls + private bool useCustomAxisLimits = false; // Whether to use custom axis limits + private float customMaxTime = 0f; // Custom max time for X axis + private float customMaxFailProb = 0f; // Custom max failure probability for Y axis (0-1 scale) + private float autoMaxTime = 0f; // Auto-calculated max time (for reset) + private float autoMaxFailProb = 0f; // Auto-calculated max failure prob (for reset) + private string maxTimeInput = "0"; // Text input for max time + private string maxFailProbInput = "0"; // Text input for max failure prob private const int ConfigRowHeight = 22; private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) // Dynamic column widths - calculated based on content - private float[] ConfigColumnWidths = new float[18]; + private float[] ConfigColumnWidths = new float[19]; // Added du column private static Texture2D rowHoverTex; private static Texture2D rowCurrentTex; @@ -1502,8 +1522,31 @@ public void OnGUI() { int posAdd = inPartsEditor ? 256 : 0; int posMult = (offsetGUIPos == -1) ? (part.Modules.Contains("ModuleFuelTanks") ? 1 : 0) : offsetGUIPos; - // Set position, width and height will auto-size based on content - guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 100, 0); // Start small, will grow + // Set position with minimal initial size - both width and height will auto-size tightly + guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 100, 100); + } + + // Only reset height when switching parts or when content changes (compact view, config count, chart visibility) + // This prevents flickering during dragging and slider interaction + uint currentPartId = part.persistentId; + int currentConfigCount = FilteredDisplayConfigs(false).Count; + bool currentHasChart = config != null && config.HasValue("cycleReliabilityStart"); + bool contentChanged = currentPartId != lastPartId + || currentConfigCount != lastConfigCount + || compactView != lastCompactView + || currentHasChart != lastHasChart; + + if (contentChanged) + { + float savedX = guiWindowRect.x; + float savedY = guiWindowRect.y; + float savedWidth = guiWindowRect.width; + guiWindowRect = new Rect(savedX, savedY, savedWidth, 100); + + lastPartId = currentPartId; + lastConfigCount = currentConfigCount; + lastCompactView = compactView; + lastHasChart = currentHasChart; } mousePos = Input.mousePosition; //Mouse location; based on Kerbal Engineer Redux code @@ -1521,6 +1564,18 @@ public void OnGUI() } guiWindowRect = GUILayout.Window(unchecked((int)part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", part.partInfo.title), Styles.styleEditorPanel); // "Configure " + part.partInfo.title + + // Draw column menu as separate window if open + if (showColumnMenu) + { + columnMenuRect = GUI.Window(unchecked((int)part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Settings", HighLogic.Skin.window); + } + } + + private void DrawColumnMenuWindow(int windowID) + { + DrawColumnMenu(new Rect(0, 20, columnMenuRect.width, columnMenuRect.height - 20)); + GUI.DragWindow(new Rect(0, 0, columnMenuRect.width, 20)); } private void EditorLock() @@ -1723,6 +1778,7 @@ protected void CalculateColumnWidths(List rows) GetBoolSymbol(row.Node, "pressureFed"), GetRatedBurnTimeString(row.Node), GetTestedBurnTimeString(row.Node), // NEW: Tested burn time column + GetFlightDataString(), // NEW: Data (du) column GetIgnitionReliabilityStartString(row.Node), GetIgnitionReliabilityEndString(row.Node), GetCycleReliabilityStartString(row.Node), @@ -1744,13 +1800,14 @@ protected void CalculateColumnWidths(List rows) } // Action column needs fixed width for two buttons - ConfigColumnWidths[17] = 160f; + ConfigColumnWidths[18] = 160f; // Set minimum widths for specific columns ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); // Ull ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); // PFed ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); // Rated burn ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f); // Tested burn + ConfigColumnWidths[11] = Mathf.Max(ConfigColumnWidths[11], 80f); // Data (du) } protected void DrawConfigTable(IEnumerable rows) @@ -1772,7 +1829,8 @@ protected void DrawConfigTable(IEnumerable rows) // Update window width to fit table exactly (accounting for window padding: 5px left + 5px right = 10px) float requiredWindowWidth = totalWidth + 10f; // Table width + padding - guiWindowRect.width = requiredWindowWidth; + const float minWindowWidth = 900f; // Minimum width to prevent squishing + guiWindowRect.width = Mathf.Max(requiredWindowWidth, minWindowWidth); Rect headerRowRect = GUILayoutUtility.GetRect(GUIContent.none, GUI.skin.label, GUILayout.Height(45)); float headerStartX = headerRowRect.x; // No left margin @@ -1836,6 +1894,90 @@ protected void DrawConfigTable(IEnumerable rows) GUILayout.EndScrollView(); } + private void DrawColumnMenu(Rect menuRect) + { + InitializeColumnVisibility(); + + // Column names + string[] columnNames = { + "Name", "Thrust", "Min%", "ISP", "Mass", "Gimbal", + "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)", "Data (du)", + "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", + "Tech", "Cost", "Actions" + }; + + float yPos = menuRect.y + 10; + float leftX = menuRect.x + 10; + float rightX = menuRect.x + menuRect.width / 2 + 5; + + var headerStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white } + }; + + var labelStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 12, + normal = { textColor = Color.white } + }; + + // Title + GUI.Label(new Rect(leftX, yPos, menuRect.width - 20, 20), "Column Visibility", headerStyle); + yPos += 25; + + // Separator + if (Event.current.type == EventType.Repaint) + { + Texture2D separatorTex = Styles.CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.5f)); + GUI.DrawTexture(new Rect(leftX, yPos, menuRect.width - 20, 1), separatorTex); + } + yPos += 10; + + // Headers for Full and Compact + GUI.Label(new Rect(leftX + 100, yPos, 60, 20), "Full", headerStyle); + GUI.Label(new Rect(leftX + 170, yPos, 60, 20), "Compact", headerStyle); + yPos += 25; + + // Scrollable area for columns + Rect scrollRect = new Rect(leftX, yPos, menuRect.width - 20, menuRect.height - 80); + Rect viewRect = new Rect(0, 0, scrollRect.width - 20, columnNames.Length * 25); + + GUI.BeginGroup(scrollRect); + float itemY = 0; + + for (int i = 0; i < columnNames.Length; i++) + { + // Column name + GUI.Label(new Rect(5, itemY, 90, 20), columnNames[i], labelStyle); + + // Full view checkbox + bool newFullVisible = GUI.Toggle(new Rect(105, itemY, 20, 20), columnsVisibleFull[i], ""); + if (newFullVisible != columnsVisibleFull[i]) + { + columnsVisibleFull[i] = newFullVisible; + } + + // Compact view checkbox + bool newCompactVisible = GUI.Toggle(new Rect(175, itemY, 20, 20), columnsVisibleCompact[i], ""); + if (newCompactVisible != columnsVisibleCompact[i]) + { + columnsVisibleCompact[i] = newCompactVisible; + } + + itemY += 25; + } + + GUI.EndGroup(); + + // Close button + if (GUI.Button(new Rect(menuRect.x + menuRect.width - 60, menuRect.y + menuRect.height - 30, 50, 20), "Close")) + { + showColumnMenu = false; + } + } + private void DrawHeaderRow(Rect headerRect) { float currentX = headerRect.x; @@ -1896,36 +2038,41 @@ private void DrawHeaderRow(Rect headerRect) } if (IsColumnVisible(11)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[11], headerRect.height), - "Ign No Data", "Ignition reliability at 0 data"); + "Data (du)", "Current flight data from TestFlight"); currentX += ConfigColumnWidths[11]; } if (IsColumnVisible(12)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[12], headerRect.height), - "Ign Max Data", "Ignition reliability at max data"); + "Ign No Data", "Ignition reliability at 0 data"); currentX += ConfigColumnWidths[12]; } if (IsColumnVisible(13)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[13], headerRect.height), - "Burn No Data", "Cycle reliability at 0 data"); + "Ign Max Data", "Ignition reliability at max data"); currentX += ConfigColumnWidths[13]; } if (IsColumnVisible(14)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[14], headerRect.height), - "Burn Max Data", "Cycle reliability at max data"); + "Burn No Data", "Cycle reliability at 0 data"); currentX += ConfigColumnWidths[14]; } if (IsColumnVisible(15)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[15], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); + "Burn Max Data", "Cycle reliability at max data"); currentX += ConfigColumnWidths[15]; } if (IsColumnVisible(16)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[16], headerRect.height), - "Extra Cost", "Extra cost for this config"); + Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); currentX += ConfigColumnWidths[16]; } if (IsColumnVisible(17)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[17], headerRect.height), + "Extra Cost", "Extra cost for this config"); + currentX += ConfigColumnWidths[17]; + } + if (IsColumnVisible(18)) { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[18], headerRect.height), "", "Switch and purchase actions"); // No label, just tooltip } } @@ -2044,37 +2191,42 @@ private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered } if (IsColumnVisible(11)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetFlightDataString(), secondaryStyle); currentX += ConfigColumnWidths[11]; } if (IsColumnVisible(12)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[12]; } if (IsColumnVisible(13)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[13]; } if (IsColumnVisible(14)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[14]; } if (IsColumnVisible(15)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetTechString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[15]; } if (IsColumnVisible(16)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[16], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[16], rowRect.height), GetTechString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[16]; } if (IsColumnVisible(17)) { - DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[17], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[17], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); + currentX += ConfigColumnWidths[17]; + } + + if (IsColumnVisible(18)) { + DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[18], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); } } @@ -2279,15 +2431,35 @@ private string GetBoolSymbol(ConfigNode node, string key) return isTrue ? "✓" : "✗"; // Orange for restriction, gray for no restriction } + private void InitializeColumnVisibility() + { + if (columnVisibilityInitialized) + return; + + // Initialize full view: all columns visible by default + for (int i = 0; i < 19; i++) + columnsVisibleFull[i] = true; + + // Initialize compact view: only essential columns + for (int i = 0; i < 19; i++) + columnsVisibleCompact[i] = false; + + // Essential columns for compact view + int[] compactColumns = { 0, 1, 3, 4, 6, 9, 10, 11, 16, 17, 18 }; // 11 is Flight Data (moved after Tested) + foreach (int col in compactColumns) + columnsVisibleCompact[col] = true; + + columnVisibilityInitialized = true; + } + private bool IsColumnVisible(int columnIndex) { - if (!compactView) - return true; // All columns visible in full view + InitializeColumnVisibility(); + + if (columnIndex < 0 || columnIndex >= 19) + return false; - // Compact view: show only essential columns - // 0: Name, 1: Thrust, 3: ISP, 4: Mass, 6: Ignitions, 9: Rated Burn, 10: Tested Burn, 15: Tech, 16: Cost, 17: Actions - return columnIndex == 0 || columnIndex == 1 || columnIndex == 3 || columnIndex == 4 || - columnIndex == 6 || columnIndex == 9 || columnIndex == 10 || columnIndex == 15 || columnIndex == 16 || columnIndex == 17; + return compactView ? columnsVisibleCompact[columnIndex] : columnsVisibleFull[columnIndex]; } private string GetRatedBurnTimeString(ConfigNode node) @@ -2363,6 +2535,18 @@ private string GetCycleReliabilityEndString(ConfigNode node) return "-"; } + private string GetFlightDataString() + { + // Get current flight data from TestFlight + float currentData = TestFlightWrapper.GetCurrentFlightData(part); + float maxData = TestFlightWrapper.GetMaximumData(part); + + if (currentData < 0f || maxData <= 0f) + return "-"; + + return $"{currentData:F0} / {maxData:F0}"; + } + private string GetTechString(ConfigNode node) { if (!node.HasValue("techRequired")) @@ -2720,6 +2904,59 @@ private float FailureProbToYPosition(float failureProb, float yAxisMax, float pl } } + /// + /// Creates a TestFlight-style non-linear reliability curve that maps data units to reliability. + /// Uses the same formula as TestFlight_Generic_Engines.cfg with default parameters: + /// - reliabilityMidV = 0.75 (achieve 75% of improvement at the kink) + /// - reliabilityMidH = 0.4 (kink occurs at 30% of max data, i.e., 3000 du) + /// - reliabilityMidTangentWeight = 0.5 + /// The curve has 3 keys: (0, start), (3000, interpolated), (10000, end) + /// + private FloatCurve CreateReliabilityCurve(float reliabilityStart, float reliabilityEnd) + { + FloatCurve curve = new FloatCurve(); + + // TestFlight works with failure chance, not reliability + float failChanceStart = 1f - reliabilityStart; + float failChanceEnd = 1f - reliabilityEnd; + + // Default TestFlight parameters + const float reliabilityMidV = 0.75f; // 75% of improvement at kink + const float reliabilityMidH = 0.4f; // Kink at 40% through the range + const float reliabilityMidTangentWeight = 0.5f; + const float maxData = 10000f; + + // Key 0: (0 du, failChanceStart) + curve.Add(0f, failChanceStart); + + // Key 1: The "kink" point where you've achieved 75% of the reliability improvement + float key1X = reliabilityMidH * 5000f + 1000f; // = 3000 du + float key1Y = failChanceStart + reliabilityMidV * (failChanceEnd - failChanceStart); + + // Calculate tangent at key 1 (weighted average of two tangents) + float tangentPart1 = (failChanceEnd - failChanceStart) * 0.0001f * reliabilityMidTangentWeight; + float linearTangent = (failChanceEnd - key1Y) / (maxData - key1X); + float tangentPart2 = linearTangent * (1f - reliabilityMidTangentWeight); + float key1Tangent = tangentPart1 + tangentPart2; + + curve.Add(key1X, key1Y, key1Tangent, key1Tangent); + + // Key 2: (10000 du, failChanceEnd) with flat tangent + curve.Add(maxData, failChanceEnd, 0f, 0f); + + return curve; + } + + /// + /// Evaluates reliability at a given data value using TestFlight's non-linear curve. + /// + private float EvaluateReliabilityAtData(float dataUnits, float reliabilityStart, float reliabilityEnd) + { + FloatCurve curve = CreateReliabilityCurve(reliabilityStart, reliabilityEnd); + float failChance = curve.Evaluate(dataUnits); + return 1f - failChance; // Convert back to reliability + } + private void DrawFailureProbabilityChart(ConfigNode configNode, float width, float height) { // Ensure textures are cached to prevent loss on window focus change @@ -2769,7 +3006,17 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo // Extend max time to show the full cycle curve beyond where it reaches 100× modifier // The cycle curve reaches maximum at 2.5× (rated or tested), extend to 3.5× to see asymptotic behavior - float maxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; + autoMaxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; + + // Use custom max time if enabled, otherwise use auto-calculated + float maxTime = useCustomAxisLimits ? customMaxTime : autoMaxTime; + + // Initialize custom values if first time or reset + if (!useCustomAxisLimits || customMaxTime == 0f) + { + customMaxTime = autoMaxTime; + maxTimeInput = $"{customMaxTime:F0}"; + } Rect chartRect = new Rect(containerRect.x, containerRect.y, chartWidth, height); Rect plotArea = new Rect(chartRect.x + padding, chartRect.y + padding, plotWidth, plotHeight); @@ -2851,17 +3098,23 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo } // Get current TestFlight data (or use simulated value) - float realDataPercentage = TestFlightWrapper.GetDataPercentage(part); - float dataPercentage = useSimulatedData ? simulatedDataPercentage : realDataPercentage; - bool hasCurrentData = (useSimulatedData && simulatedDataPercentage >= 0f) || (dataPercentage >= 0f && dataPercentage <= 1f); + float realCurrentData = TestFlightWrapper.GetCurrentFlightData(part); + float realMaxData = TestFlightWrapper.GetMaximumData(part); + float realDataPercentage = (realMaxData > 0f) ? (realCurrentData / realMaxData) : -1f; + + // Calculate data percentage from simulated or real values + float currentDataValue = useSimulatedData ? simulatedDataValue : realCurrentData; + float maxDataValue = realMaxData > 0f ? realMaxData : 10000f; // Default to 10000 if no TestFlight data + float dataPercentage = (maxDataValue > 0f) ? Mathf.Clamp01(currentDataValue / maxDataValue) : 0f; + bool hasCurrentData = (useSimulatedData && currentDataValue >= 0f) || (realCurrentData >= 0f && realMaxData > 0f); float[] failureProbsCurrent = null; float cycleReliabilityCurrent = 0f; float baseRateCurrent = 0f; if (hasCurrentData) { - // Interpolate current reliability between start and end based on data percentage - cycleReliabilityCurrent = Mathf.Lerp(cycleReliabilityStart, cycleReliabilityEnd, dataPercentage); + // Use TestFlight's non-linear reliability curve to get current reliability + cycleReliabilityCurrent = EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); baseRateCurrent = -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime; failureProbsCurrent = new float[curvePoints]; @@ -2918,9 +3171,19 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo float yAxisMaxRaw = Mathf.Min(1f, maxFailureProb + 0.02f); // Round up to a "nice" number for clean axis labels - float yAxisMax = RoundToNiceNumber(yAxisMaxRaw, true); + autoMaxFailProb = RoundToNiceNumber(yAxisMaxRaw, true); // Ensure minimum range for readability - if (yAxisMax < 0.05f) yAxisMax = 0.05f; + if (autoMaxFailProb < 0.05f) autoMaxFailProb = 0.05f; + + // Use custom max failure prob if enabled, otherwise use auto-calculated + float yAxisMax = useCustomAxisLimits ? customMaxFailProb : autoMaxFailProb; + + // Initialize custom values if first time or reset + if (!useCustomAxisLimits || customMaxFailProb == 0f) + { + customMaxFailProb = autoMaxFailProb; + maxFailProbInput = $"{(customMaxFailProb * 100f):F1}"; + } // Draw grid lines and labels with dynamic scale var labelStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.grey } }; @@ -3207,7 +3470,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo bool near100xMarker = Mathf.Abs(mousePos.x - max100xX) < 8f; string tooltipText = ""; - string valueColor = "#88DDFF"; // Light cyan for values + string valueColor = "#E6D68A"; // Muted yellow for time values and numeric values if (nearStartupMarker) { @@ -3296,12 +3559,37 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo string blueColor = "#7DD9FF"; // Match lighter blue line (current data) string greenColor = "#4DE64D"; // Match green line (max data) + // Calculate failure and survival percentages + float mouseFailStartPercent = mouseFailStart * 100f; + float mouseFailEndPercent = mouseFailEnd * 100f; + float mouseFailCurrentPercent = hasCurrentData ? mouseFailCurrent * 100f : 0f; + + float mouseSurviveStart = (1f - mouseFailStart) * 100f; + float mouseSurviveEnd = (1f - mouseFailEnd) * 100f; + float mouseSurviveCurrent = hasCurrentData ? (1f - mouseFailCurrent) * 100f : 0f; + tooltipText = $"{zoneName}\n\n"; - tooltipText += $"At {timeStr}, this engine has a:\n\n"; - tooltipText += $" {mouseFailStart * 100f:F2}% chance to fail (0 data)\n"; + + // Format failure percentages in X%/X%/X% format + if (hasCurrentData) + { + tooltipText += $"Failure chance: {mouseFailStartPercent:F2}% / {mouseFailCurrentPercent:F2}% / {mouseFailEndPercent:F2}%\n"; + } + else + { + tooltipText += $"Failure chance: {mouseFailStartPercent:F2}% / {mouseFailEndPercent:F2}%\n"; + } + + // Format survival percentages in X%/X%/X% format if (hasCurrentData) - tooltipText += $" {mouseFailCurrent * 100f:F2}% chance to fail (current data)\n"; - tooltipText += $" {mouseFailEnd * 100f:F2}% chance to fail (max data)\n\n"; + { + tooltipText += $"This engine has a {mouseSurviveStart:F1}% / {mouseSurviveCurrent:F1}% / {mouseSurviveEnd:F1}% chance to survive to {timeStr}\n\n"; + } + else + { + tooltipText += $"This engine has a {mouseSurviveStart:F1}% / {mouseSurviveEnd:F1}% chance to survive to {timeStr}\n\n"; + } + tooltipText += $"Cycle modifier: {cycleModifier:F2}×"; } @@ -3315,12 +3603,13 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); - // Calculate current ignition reliability - float ignitionReliabilityCurrent = hasCurrentData ? Mathf.Lerp(ignitionReliabilityStart, ignitionReliabilityEnd, dataPercentage) : 0f; + // Calculate current ignition reliability using TestFlight's non-linear curve + float ignitionReliabilityCurrent = hasCurrentData ? EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd) : 0f; DrawFailureInfoPanel(infoRect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, - hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, realDataPercentage); + hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, + currentDataValue, maxDataValue, realCurrentData, realMaxData); // Draw tooltip last so it appears on top of everything DrawChartTooltip(finalMousePos, finalTooltipText); @@ -3333,18 +3622,20 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); - // Calculate current ignition reliability - float ignitionReliabilityCurrent = hasCurrentData ? Mathf.Lerp(ignitionReliabilityStart, ignitionReliabilityEnd, dataPercentage) : 0f; + // Calculate current ignition reliability using TestFlight's non-linear curve + float ignitionReliabilityCurrent = hasCurrentData ? EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd) : 0f; DrawFailureInfoPanel(infoRect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, - hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, realDataPercentage); + hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, + currentDataValue, maxDataValue, realCurrentData, realMaxData); } } private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, float cycleReliabilityStart, float cycleReliabilityEnd, float ignitionReliabilityStart, float ignitionReliabilityEnd, - bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage, float realDataPercentage) + bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage, + float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData) { // Draw background if (Event.current.type == EventType.Repaint) @@ -3406,7 +3697,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu // Style for rich text labels var textStyle = new GUIStyle(GUI.skin.label) { - fontSize = 13, + fontSize = 15, normal = { textColor = Color.white }, wordWrap = true, richText = true, @@ -3415,7 +3706,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu var headerStyle = new GUIStyle(GUI.skin.label) { - fontSize = 15, + fontSize = 17, fontStyle = FontStyle.Bold, normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, wordWrap = true, @@ -3425,7 +3716,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu var sectionStyle = new GUIStyle(GUI.skin.label) { - fontSize = 14, + fontSize = 16, fontStyle = FontStyle.Bold, normal = { textColor = new Color(1f, 0.5f, 0.2f) }, // Orange for 0 data wordWrap = true, @@ -3437,186 +3728,253 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu string orangeColor = "#FF8033"; // 0 data string blueColor = "#7DD9FF"; // Current data (lighter blue) string greenColor = "#4DE64D"; // Max data - string valueColor = "#88DDFF"; // Time values + string valueColor = "#E6D68A"; // Time values - faded yellow // Start at same vertical position as chart title for alignment float yPos = rect.y + 4; - // Title - GUI.Label(new Rect(rect.x, yPos, rect.width, 20), "Engine Reliability:", headerStyle); - yPos += 24; + // === COMBINED RELIABILITY SECTION === + // Color-coded section header + string headerText = $"At Starting"; + if (hasCurrentData) + { + string dataLabel = maxDataValue > 0f ? $"{currentDataValue:F0} du" : $"{dataPercentage * 100f:F0}%"; + headerText += $" / Current ({dataLabel})"; + } + headerText += $" / Max:"; - // === 0 DATA SECTION (Orange) === - sectionStyle.normal.textColor = new Color(1f, 0.5f, 0.2f); - GUI.Label(new Rect(rect.x, yPos, rect.width, 18), "At 0 Data:", sectionStyle); + sectionStyle.normal.textColor = Color.white; // Use white for base, colors come from rich text + GUI.Label(new Rect(rect.x, yPos, rect.width, 20), headerText, sectionStyle); yPos += 20; - // Build narrative text for 0 data + // Build single narrative with all values string engineText = clusterSize > 1 ? $"A cluster of {clusterSize} engines" : "This engine"; - string text0Data = $"{engineText} has a {ignitionSuccessStart:F1}% chance for all to ignite, "; - text0Data += $"then a {ratedSuccessStart:F1}% chance for all to burn for {ratedBurnTime:F0}s (rated)"; - if (hasTestedBurnTime) - text0Data += $", and a {testedSuccessStart:F1}% chance for all to burn to {testedBurnTime:F0}s (tested)"; - text0Data += "."; + string forAll = clusterSize > 1 ? " for all" : ""; + string combinedText = $"{engineText} has a "; - float height0 = textStyle.CalcHeight(new GUIContent(text0Data), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, height0), text0Data, textStyle); - yPos += height0 + 8; + // Ignition success rates (with larger font for percentages) + if (hasCurrentData) + combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessCurrent:F1}% / {ignitionSuccessEnd:F1}%"; + else + combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessEnd:F1}%"; + + combinedText += $" chance{forAll} to ignite, then a "; - // === CURRENT DATA SECTION (Blue) - only if available === + // Rated burn success rates (with larger font for percentages) if (hasCurrentData) + combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessCurrent:F1}% / {ratedSuccessEnd:F1}%"; + else + combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessEnd:F1}%"; + + combinedText += $" chance{forAll} to burn for {ratedBurnTime:F0}s (rated)"; + + // Tested burn success rates (if applicable, with larger font for percentages) + if (hasTestedBurnTime) { - sectionStyle.normal.textColor = new Color(0.49f, 0.85f, 1.0f); // Lighter blue to match line - GUI.Label(new Rect(rect.x, yPos, rect.width, 18), $"At Current Data ({dataPercentage * 100f:F0}%):", sectionStyle); - yPos += 20; + combinedText += ", and a "; + if (hasCurrentData) + combinedText += $"{testedSuccessStart:F1}% / {testedSuccessCurrent:F1}% / {testedSuccessEnd:F1}%"; + else + combinedText += $"{testedSuccessStart:F1}% / {testedSuccessEnd:F1}%"; - // Build narrative text for current data - string textCurrentData = $"{engineText} has a {ignitionSuccessCurrent:F1}% chance for all to ignite, "; - textCurrentData += $"then a {ratedSuccessCurrent:F1}% chance for all to burn for {ratedBurnTime:F0}s (rated)"; - if (hasTestedBurnTime) - textCurrentData += $", and a {testedSuccessCurrent:F1}% chance for all to burn to {testedBurnTime:F0}s (tested)"; - textCurrentData += "."; + combinedText += $" chance{forAll} to burn to {testedBurnTime:F0}s (tested)"; + } + combinedText += "."; + + float combinedHeight = textStyle.CalcHeight(new GUIContent(combinedText), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, combinedHeight), combinedText, textStyle); + yPos += combinedHeight + 12; - float heightCurrent = textStyle.CalcHeight(new GUIContent(textCurrentData), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, heightCurrent), textCurrentData, textStyle); - yPos += heightCurrent + 8; + // === SIDE-BY-SIDE LAYOUT: DATA GAINS (LEFT) AND CONTROLS (RIGHT) === + // Separator line across full width + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), chartSeparatorTex); } + yPos += 10; - // === MAX DATA SECTION (Green) === - sectionStyle.normal.textColor = new Color(0.3f, 0.9f, 0.3f); - GUI.Label(new Rect(rect.x, yPos, rect.width, 18), "At Max Data:", sectionStyle); - yPos += 20; + // Split the panel into two columns + float columnStartY = yPos; + float leftColumnWidth = rect.width * 0.5f; + float rightColumnWidth = rect.width * 0.5f; + float leftColumnX = rect.x; + float rightColumnX = rect.x + leftColumnWidth; - // Build narrative text for max data - string textMaxData = $"{engineText} has a {ignitionSuccessEnd:F1}% chance for all to ignite, "; - textMaxData += $"then a {ratedSuccessEnd:F1}% chance for all to burn for {ratedBurnTime:F0}s (rated)"; - if (hasTestedBurnTime) - textMaxData += $", and a {testedSuccessEnd:F1}% chance for all to burn to {testedBurnTime:F0}s (tested)"; - textMaxData += "."; + // Track heights of both columns to know where to place bottom separator + float leftColumnEndY = columnStartY; + float rightColumnEndY = columnStartY; + + // === LEFT COLUMN: DATA GAINS === + float leftYPos = columnStartY; + string infoColor = "#FFEE88"; // Light yellow + string purpleColor = "#CCB3FF"; // Light purple - matches section header + + // Calculate data collection info + float ratedContinuousBurnTime = ratedBurnTime; + float dataRate = 640f / ratedContinuousBurnTime; + float duPerFlight = dataRate * ratedBurnTime; + + // Section header + sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); // Light purple/lavender + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, 20), "How To Gain Data:", sectionStyle); + leftYPos += 20; + + // Bullet list + var bulletStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + normal = { textColor = Color.white }, + wordWrap = false, + richText = true, + padding = new RectOffset(8, 8, 1, 1) + }; + + float bulletHeight = 18; + + // Full rated burn entry + string ratedBurnText = $"• Full rated burn ({ratedBurnTime:F0}s) gains {duPerFlight:F0} du"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), ratedBurnText, bulletStyle); + leftYPos += bulletHeight; + + // Failure types + string[] failureTypes = { "Explode", "Shutdown", "Ignition Fail", "Perf. Loss", "Reduced Thrust" }; + int[] failureDu = { 1300, 1100, 1050, 800, 700 }; + + for (int i = 0; i < failureTypes.Length; i++) + { + string bulletText = $"• {failureTypes[i]} gains {failureDu[i]} du"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), bulletText, bulletStyle); + leftYPos += bulletHeight; + } - float heightMax = textStyle.CalcHeight(new GUIContent(textMaxData), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, heightMax), textMaxData, textStyle); - yPos += heightMax + 16; + leftColumnEndY = leftYPos + 8; - // === SIMULATION CONTROLS === - bool hasRealData = realDataPercentage >= 0f && realDataPercentage <= 1f; + // === RIGHT COLUMN: SIMULATION CONTROLS === + float rightYPos = columnStartY; + bool hasRealData = realCurrentData >= 0f && realMaxData > 0f; var controlStyle = new GUIStyle(GUI.skin.label) { fontSize = 12, + fontStyle = FontStyle.Bold, normal = { textColor = new Color(0.8f, 0.8f, 0.8f) }, padding = new RectOffset(8, 8, 2, 2) }; var sliderStyle = GUI.skin.horizontalSlider; var thumbStyle = GUI.skin.horizontalSliderThumb; - var buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 11 }; - var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 11, alignment = TextAnchor.MiddleCenter }; + var buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 12, fontStyle = FontStyle.Bold }; + var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - // Separator line - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), chartSeparatorTex); - } - yPos += 6; - - // === BUTTONS ROW: Log X, Log Y, Reset (3 side by side) === - float btnWidth = (rect.width - 24) / 3f; + // Buttons row - all horizontal + float logBtnWidth = 60f; float btnSpacing = 4f; + float resetBtnWidth = rightColumnWidth - 24 - (logBtnWidth * 2) - (btnSpacing * 2); + + var boldButtonStyle = new GUIStyle(buttonStyle) { fontStyle = FontStyle.Bold }; // X-axis log scale toggle string toggleLabelX = useLogScaleX ? "X: Lin" : "X: Log"; - if (GUI.Button(new Rect(rect.x + 8, yPos, btnWidth, 20), toggleLabelX, buttonStyle)) + if (GUI.Button(new Rect(rightColumnX + 8, rightYPos, logBtnWidth, 20), toggleLabelX, boldButtonStyle)) { useLogScaleX = !useLogScaleX; } // Y-axis log scale toggle string toggleLabelY = useLogScaleY ? "Y: Lin" : "Y: Log"; - if (GUI.Button(new Rect(rect.x + 8 + btnWidth + btnSpacing, yPos, btnWidth, 20), toggleLabelY, buttonStyle)) + if (GUI.Button(new Rect(rightColumnX + 8 + logBtnWidth + btnSpacing, rightYPos, logBtnWidth, 20), toggleLabelY, boldButtonStyle)) { useLogScaleY = !useLogScaleY; } // Reset button - string resetButtonText = hasRealData ? $"{realDataPercentage * 100f:F0}%" : "0%"; - if (GUI.Button(new Rect(rect.x + 8 + (btnWidth + btnSpacing) * 2, yPos, btnWidth, 20), resetButtonText, buttonStyle)) + string resetButtonText = "Reset All"; + if (GUI.Button(new Rect(rightColumnX + 8 + (logBtnWidth + btnSpacing) * 2, rightYPos, resetBtnWidth, 20), resetButtonText, buttonStyle)) { + // Reset data value if (hasRealData) { - simulatedDataPercentage = realDataPercentage; - dataPercentageInput = $"{realDataPercentage * 100f:F0}"; + simulatedDataValue = realCurrentData; + dataValueInput = $"{realCurrentData:F0}"; useSimulatedData = false; } else { - simulatedDataPercentage = 0f; - dataPercentageInput = "0"; + simulatedDataValue = 0f; + dataValueInput = "0"; useSimulatedData = true; } + + // Reset cluster clusterSize = 1; clusterSizeInput = "1"; - } - yPos += 26; + // Reset axis limits to auto + useCustomAxisLimits = false; + customMaxTime = autoMaxTime; + customMaxFailProb = autoMaxFailProb; + maxTimeInput = $"{customMaxTime:F0}"; + maxFailProbInput = $"{(customMaxFailProb * 100f):F1}"; + } + rightYPos += 24; - // === TWO SLIDERS SIDE BY SIDE === - float halfWidth = (rect.width - 24) / 2f; - float leftX = rect.x; - float rightX = rect.x + halfWidth + 8; + // Define width for slider controls + float btnWidth = rightColumnWidth - 16; - // LEFT: DATA PERCENTAGE - GUI.Label(new Rect(leftX, yPos, halfWidth, 16), "Data %", controlStyle); - GUI.Label(new Rect(rightX, yPos, halfWidth, 16), "Cluster", controlStyle); - yPos += 16; + // Data slider + GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Data (du)", controlStyle); + rightYPos += 16; // Initialize simulated value to real data if not yet simulating if (!useSimulatedData) { - simulatedDataPercentage = hasRealData ? realDataPercentage : 0f; - dataPercentageInput = $"{simulatedDataPercentage * 100f:F0}"; + simulatedDataValue = hasRealData ? realCurrentData : 0f; + dataValueInput = $"{simulatedDataValue:F0}"; } - // Data % slider - simulatedDataPercentage = GUI.HorizontalSlider(new Rect(leftX + 8, yPos, halfWidth - 60, 16), - simulatedDataPercentage * 100f, 0f, 100f, sliderStyle, thumbStyle) / 100f; + // Data slider + simulatedDataValue = GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), + simulatedDataValue, 0f, maxDataValue, sliderStyle, thumbStyle); // Mark as simulated if user moved slider away from real data - if (hasRealData && Mathf.Abs(simulatedDataPercentage - realDataPercentage) > 0.001f) + if (hasRealData && Mathf.Abs(simulatedDataValue - realCurrentData) > 0.1f) { useSimulatedData = true; } - else if (!hasRealData && simulatedDataPercentage > 0.001f) + else if (!hasRealData && simulatedDataValue > 0.1f) { useSimulatedData = true; } - // Data % input field - dataPercentageInput = $"{simulatedDataPercentage * 100f:F0}"; - GUI.SetNextControlName("dataPercentInput"); - string newDataInput = GUI.TextField(new Rect(leftX + halfWidth - 45, yPos - 2, 40, 20), - dataPercentageInput, 5, inputStyle); + // Data input field + dataValueInput = $"{simulatedDataValue:F0}"; + GUI.SetNextControlName("dataValueInput"); + string newDataInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), + dataValueInput, 6, inputStyle); - if (newDataInput != dataPercentageInput) + if (newDataInput != dataValueInput) { - dataPercentageInput = newDataInput; - if (GUI.GetNameOfFocusedControl() == "dataPercentInput" && float.TryParse(dataPercentageInput, out float inputDataPercent)) + dataValueInput = newDataInput; + if (GUI.GetNameOfFocusedControl() == "dataValueInput" && float.TryParse(dataValueInput, out float inputDataValue)) { - inputDataPercent = Mathf.Clamp(inputDataPercent, 0f, 100f); - simulatedDataPercentage = inputDataPercent / 100f; + inputDataValue = Mathf.Clamp(inputDataValue, 0f, maxDataValue); + simulatedDataValue = inputDataValue; useSimulatedData = true; } } + rightYPos += 24; - // RIGHT: CLUSTER SIZE // Cluster slider - clusterSize = Mathf.RoundToInt(GUI.HorizontalSlider(new Rect(rightX + 8, yPos, halfWidth - 60, 16), - clusterSize, 1f, 20f, sliderStyle, thumbStyle)); + GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Cluster", controlStyle); + rightYPos += 16; + + clusterSize = Mathf.RoundToInt(GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), + clusterSize, 1f, 100f, sliderStyle, thumbStyle)); // Cluster input field clusterSizeInput = clusterSize.ToString(); GUI.SetNextControlName("clusterSizeInput"); - string newClusterInput = GUI.TextField(new Rect(rightX + halfWidth - 45, yPos - 2, 40, 20), + string newClusterInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), clusterSizeInput, 3, inputStyle); if (newClusterInput != clusterSizeInput) @@ -3624,11 +3982,75 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu clusterSizeInput = newClusterInput; if (GUI.GetNameOfFocusedControl() == "clusterSizeInput" && int.TryParse(clusterSizeInput, out int inputCluster)) { - inputCluster = Mathf.Clamp(inputCluster, 1, 20); + inputCluster = Mathf.Clamp(inputCluster, 1, 100); clusterSize = inputCluster; clusterSizeInput = clusterSize.ToString(); } } + rightYPos += 24; + + // Max Time slider + GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Max Time (s)", controlStyle); + rightYPos += 16; + + customMaxTime = GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), + customMaxTime, 10f, autoMaxTime * 2f, sliderStyle, thumbStyle); + + if (Mathf.Abs(customMaxTime - autoMaxTime) > 1f) + useCustomAxisLimits = true; + + // Max Time input field + maxTimeInput = $"{customMaxTime:F0}"; + GUI.SetNextControlName("maxTimeInput"); + string newMaxTimeInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), + maxTimeInput, 6, inputStyle); + + if (newMaxTimeInput != maxTimeInput) + { + maxTimeInput = newMaxTimeInput; + if (GUI.GetNameOfFocusedControl() == "maxTimeInput" && float.TryParse(maxTimeInput, out float inputMaxTime)) + { + inputMaxTime = Mathf.Clamp(inputMaxTime, 10f, autoMaxTime * 10f); + customMaxTime = inputMaxTime; + useCustomAxisLimits = true; + } + } + rightYPos += 24; + + // Max Fail % slider + GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Max Fail %", controlStyle); + rightYPos += 16; + + float customMaxFailPercent = customMaxFailProb * 100f; + customMaxFailPercent = GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), + customMaxFailPercent, 1f, 100f, sliderStyle, thumbStyle); + customMaxFailProb = customMaxFailPercent / 100f; + + if (Mathf.Abs(customMaxFailProb - autoMaxFailProb) > 0.01f) + useCustomAxisLimits = true; + + // Max Fail % input field + maxFailProbInput = $"{customMaxFailPercent:F1}"; + GUI.SetNextControlName("maxFailProbInput"); + string newMaxFailInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), + maxFailProbInput, 5, inputStyle); + + if (newMaxFailInput != maxFailProbInput) + { + maxFailProbInput = newMaxFailInput; + if (GUI.GetNameOfFocusedControl() == "maxFailProbInput" && float.TryParse(maxFailProbInput, out float inputMaxFail)) + { + inputMaxFail = Mathf.Clamp(inputMaxFail, 1f, 200f); + customMaxFailProb = inputMaxFail / 100f; + useCustomAxisLimits = true; + } + } + rightYPos += 24; + + rightColumnEndY = rightYPos; + + // Use the taller column to determine the final height + yPos = Mathf.Max(leftColumnEndY, rightColumnEndY) + 8; } private void DrawLine(Vector2 start, Vector2 end, Texture2D texture, float width) @@ -3816,7 +4238,10 @@ protected void DrawTechLevelSelector() private void EngineManagerGUI(int WindowID) { - GUILayout.Space(6); // Breathing room at top + // Use BeginVertical with GUILayout.ExpandHeight(false) to prevent extra vertical space + GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); + + GUILayout.Space(4); // Minimal top padding GUILayout.BeginHorizontal(); GUILayout.Label(EditorDescription); @@ -3825,25 +4250,28 @@ private void EngineManagerGUI(int WindowID) { compactView = !compactView; } + if (GUILayout.Button("Settings", GUILayout.Width(70))) + { + showColumnMenu = !showColumnMenu; + } GUILayout.EndHorizontal(); - GUILayout.Space(6); // Space before table + GUILayout.Space(4); // Minimal space before table DrawConfigSelectors(FilteredDisplayConfigs(false)); // Draw failure probability chart for current config - if (config != null) + if (config != null && config.HasValue("cycleReliabilityStart")) { - GUILayout.Space(8); + GUILayout.Space(6); DrawFailureProbabilityChart(config, guiWindowRect.width - 10, 360); + GUILayout.Space(6); // Consistent small space after chart } DrawTechLevelSelector(); - // Only use negative space if no chart (chart needs the room) - if (config == null || !config.HasValue("cycleReliabilityStart")) - GUILayout.Space(-80); // Remove all bottom padding - window ends right at table - else - GUILayout.Space(8); // Add space after chart + GUILayout.Space(4); // Minimal bottom padding + + GUILayout.EndVertical(); if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty)) { From 70d25b373c4e84ed6ed5cd77365b174106ade318 Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 16:45:38 -0800 Subject: [PATCH 04/12] Implement feature X to enhance user experience and fix bug Y in module Z --- Source/Engines/ModuleEngineConfigs.cs | 512 ++++++++++++++------------ 1 file changed, 271 insertions(+), 241 deletions(-) diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index 3177639c..d29698b1 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1460,11 +1460,11 @@ public void OnDestroy() // Chart axis limit controls private bool useCustomAxisLimits = false; // Whether to use custom axis limits private float customMaxTime = 0f; // Custom max time for X axis - private float customMaxFailProb = 0f; // Custom max failure probability for Y axis (0-1 scale) + private float customMinSurvivalProb = 0f; // Custom min survival probability for Y axis (0-1 scale) private float autoMaxTime = 0f; // Auto-calculated max time (for reset) - private float autoMaxFailProb = 0f; // Auto-calculated max failure prob (for reset) + private float autoMinSurvivalProb = 0f; // Auto-calculated min survival prob (for reset) private string maxTimeInput = "0"; // Text input for max time - private string maxFailProbInput = "0"; // Text input for max failure prob + private string minSurvivalProbInput = "100"; // Text input for min survival prob private const int ConfigRowHeight = 22; private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) @@ -2883,24 +2883,49 @@ private float XPositionToTime(float xPos, float maxTime, float plotX, float plot } /// - /// Convert failure probability to y-position on the chart, using either linear or logarithmic scale. + /// Formats time in seconds to a human-readable string in the format: xd xh xm xs + /// Omits zero values (e.g., "1h 30m" instead of "0d 1h 30m 0s") /// - private float FailureProbToYPosition(float failureProb, float yAxisMax, float plotY, float plotHeight, bool useLogScale) + private string FormatTime(float timeInSeconds) + { + if (timeInSeconds < 0.1f) + return "0s"; + + int totalSeconds = Mathf.RoundToInt(timeInSeconds); + int days = totalSeconds / 86400; + int hours = (totalSeconds % 86400) / 3600; + int minutes = (totalSeconds % 3600) / 60; + int seconds = totalSeconds % 60; + + string result = ""; + if (days > 0) result += $"{days}d "; + if (hours > 0) result += $"{hours}h "; + if (minutes > 0) result += $"{minutes}m "; + if (seconds > 0 || string.IsNullOrEmpty(result)) result += $"{seconds}s"; + + return result.TrimEnd(); + } + + /// + /// Convert survival probability to y-position on the chart, using either linear or logarithmic scale. + /// Higher survival appears higher on the chart (100% at top, 0% at bottom). + /// + private float SurvivalProbToYPosition(float survivalProb, float yAxisMin, float plotY, float plotHeight, bool useLogScale) { if (useLogScale) { // Logarithmic scale: use log10(prob + 0.0001) to handle near-zero values - // The +0.0001 offset prevents log(0) and provides a visible baseline - float logProb = Mathf.Log10(failureProb + 0.0001f); - float logMax = Mathf.Log10(yAxisMax + 0.0001f); - float logMin = Mathf.Log10(0.0001f); // Minimum visible value + float logProb = Mathf.Log10(survivalProb + 0.0001f); + float logMax = Mathf.Log10(1f + 0.0001f); // Max is 100% survival + float logMin = Mathf.Log10(yAxisMin + 0.0001f); float normalizedLog = (logProb - logMin) / (logMax - logMin); return plotY + plotHeight - (normalizedLog * plotHeight); } else { - // Linear scale - return plotY + plotHeight - ((failureProb / yAxisMax) * plotHeight); + // Linear scale: map from yAxisMin (bottom) to 1.0 (top) + float normalizedSurvival = (survivalProb - yAxisMin) / (1f - yAxisMin); + return plotY + plotHeight - (normalizedSurvival * plotHeight); } } @@ -3006,17 +3031,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo // Extend max time to show the full cycle curve beyond where it reaches 100× modifier // The cycle curve reaches maximum at 2.5× (rated or tested), extend to 3.5× to see asymptotic behavior - autoMaxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; - - // Use custom max time if enabled, otherwise use auto-calculated - float maxTime = useCustomAxisLimits ? customMaxTime : autoMaxTime; - - // Initialize custom values if first time or reset - if (!useCustomAxisLimits || customMaxTime == 0f) - { - customMaxTime = autoMaxTime; - maxTimeInput = $"{customMaxTime:F0}"; - } + float maxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; Rect chartRect = new Rect(containerRect.x, containerRect.y, chartWidth, height); Rect plotArea = new Rect(chartRect.x + padding, chartRect.y + padding, plotWidth, plotHeight); @@ -3030,11 +3045,11 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo GUI.DrawTexture(chartRect, chartBgTex); } - // Calculate failure probabilities for both curves to determine Y-axis scale + // Calculate survival probabilities for both curves to determine Y-axis scale const int curvePoints = 100; - float[] failureProbsStart = new float[curvePoints]; - float[] failureProbsEnd = new float[curvePoints]; - float maxFailureProb = 0f; + float[] survivalProbsStart = new float[curvePoints]; + float[] survivalProbsEnd = new float[curvePoints]; + float minSurvivalProb = 1f; // Base failure rates (from reliability at rated burn time) float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; @@ -3044,19 +3059,19 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo { float t = (i / (float)(curvePoints - 1)) * maxTime; - // Calculate failure using TestFlight's cycle curve + // Calculate survival using TestFlight's cycle curve // For t <= ratedBurnTime: standard exponential reliability // For t > ratedBurnTime: integrate the cycle modifier to account for varying failure rate // Calculate for start (0 data) - float failureProbStart = 0f; + float survivalProbStart = 0f; if (t <= ratedBurnTime) { - failureProbStart = 1f - Mathf.Pow(cycleReliabilityStart, t / ratedBurnTime); + survivalProbStart = Mathf.Pow(cycleReliabilityStart, t / ratedBurnTime); } else { - // Base failure up to rated time + // Base survival up to rated time float survivalToRated = cycleReliabilityStart; // Integrate cycle modifier from ratedBurnTime to t using numerical integration @@ -3066,21 +3081,20 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo float additionalFailRate = baseRateStart * integratedModifier; // Total survival = survive to rated * survive additional time - float survivalProb = survivalToRated * Mathf.Exp(-additionalFailRate); - failureProbStart = Mathf.Clamp01(1f - survivalProb); + survivalProbStart = Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); } - failureProbsStart[i] = failureProbStart; - maxFailureProb = Mathf.Max(maxFailureProb, failureProbStart); + survivalProbsStart[i] = survivalProbStart; + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbStart); // Calculate for end (max data) - float failureProbEnd = 0f; + float survivalProbEnd = 0f; if (t <= ratedBurnTime) { - failureProbEnd = 1f - Mathf.Pow(cycleReliabilityEnd, t / ratedBurnTime); + survivalProbEnd = Mathf.Pow(cycleReliabilityEnd, t / ratedBurnTime); } else { - // Base failure up to rated time + // Base survival up to rated time float survivalToRated = cycleReliabilityEnd; // Integrate cycle modifier from ratedBurnTime to t @@ -3090,11 +3104,10 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo float additionalFailRate = baseRateEnd * integratedModifier; // Total survival = survive to rated * survive additional time - float survivalProb = survivalToRated * Mathf.Exp(-additionalFailRate); - failureProbEnd = Mathf.Clamp01(1f - survivalProb); + survivalProbEnd = Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); } - failureProbsEnd[i] = failureProbEnd; - maxFailureProb = Mathf.Max(maxFailureProb, failureProbEnd); + survivalProbsEnd[i] = survivalProbEnd; + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbEnd); } // Get current TestFlight data (or use simulated value) @@ -3107,7 +3120,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo float maxDataValue = realMaxData > 0f ? realMaxData : 10000f; // Default to 10000 if no TestFlight data float dataPercentage = (maxDataValue > 0f) ? Mathf.Clamp01(currentDataValue / maxDataValue) : 0f; bool hasCurrentData = (useSimulatedData && currentDataValue >= 0f) || (realCurrentData >= 0f && realMaxData > 0f); - float[] failureProbsCurrent = null; + float[] survivalProbsCurrent = null; float cycleReliabilityCurrent = 0f; float baseRateCurrent = 0f; @@ -3116,74 +3129,57 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo // Use TestFlight's non-linear reliability curve to get current reliability cycleReliabilityCurrent = EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); baseRateCurrent = -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime; - failureProbsCurrent = new float[curvePoints]; + survivalProbsCurrent = new float[curvePoints]; for (int i = 0; i < curvePoints; i++) { float t = (i / (float)(curvePoints - 1)) * maxTime; - float failureProbCurrent = 0f; + float survivalProbCurrent = 0f; if (t <= ratedBurnTime) { - failureProbCurrent = 1f - Mathf.Pow(cycleReliabilityCurrent, t / ratedBurnTime); + survivalProbCurrent = Mathf.Pow(cycleReliabilityCurrent, t / ratedBurnTime); } else { float survivalToRated = cycleReliabilityCurrent; float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); float additionalFailRate = baseRateCurrent * integratedModifier; - float survivalProb = survivalToRated * Mathf.Exp(-additionalFailRate); - failureProbCurrent = Mathf.Clamp01(1f - survivalProb); + survivalProbCurrent = Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); } - failureProbsCurrent[i] = failureProbCurrent; - maxFailureProb = Mathf.Max(maxFailureProb, failureProbCurrent); + survivalProbsCurrent[i] = survivalProbCurrent; + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbCurrent); } } - // Apply cluster math: for N engines, probability at least one fails = 1 - (1 - singleFailProb)^N + // Apply cluster math: for N engines, cluster survival = (single survival)^N if (clusterSize > 1) { for (int i = 0; i < curvePoints; i++) { - // Transform each failure probability for cluster - float singleSurvival = 1f - failureProbsStart[i]; - failureProbsStart[i] = 1f - Mathf.Pow(singleSurvival, clusterSize); - - singleSurvival = 1f - failureProbsEnd[i]; - failureProbsEnd[i] = 1f - Mathf.Pow(singleSurvival, clusterSize); + // Transform each survival probability for cluster + survivalProbsStart[i] = Mathf.Pow(survivalProbsStart[i], clusterSize); + survivalProbsEnd[i] = Mathf.Pow(survivalProbsEnd[i], clusterSize); if (hasCurrentData) { - singleSurvival = 1f - failureProbsCurrent[i]; - failureProbsCurrent[i] = 1f - Mathf.Pow(singleSurvival, clusterSize); + survivalProbsCurrent[i] = Mathf.Pow(survivalProbsCurrent[i], clusterSize); } - // Update max failure probability after cluster transformation - maxFailureProb = Mathf.Max(maxFailureProb, failureProbsStart[i]); - maxFailureProb = Mathf.Max(maxFailureProb, failureProbsEnd[i]); + // Update min survival probability after cluster transformation + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsStart[i]); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsEnd[i]); if (hasCurrentData) - maxFailureProb = Mathf.Max(maxFailureProb, failureProbsCurrent[i]); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsCurrent[i]); } } - // Set Y-axis max to 2% above the maximum failure probability - float yAxisMaxRaw = Mathf.Min(1f, maxFailureProb + 0.02f); + // Set Y-axis min to 2% below the minimum survival probability (but don't go below 0) + float yAxisMinRaw = Mathf.Max(0f, minSurvivalProb - 0.02f); - // Round up to a "nice" number for clean axis labels - autoMaxFailProb = RoundToNiceNumber(yAxisMaxRaw, true); - // Ensure minimum range for readability - if (autoMaxFailProb < 0.05f) autoMaxFailProb = 0.05f; - - // Use custom max failure prob if enabled, otherwise use auto-calculated - float yAxisMax = useCustomAxisLimits ? customMaxFailProb : autoMaxFailProb; - - // Initialize custom values if first time or reset - if (!useCustomAxisLimits || customMaxFailProb == 0f) - { - customMaxFailProb = autoMaxFailProb; - maxFailProbInput = $"{(customMaxFailProb * 100f):F1}"; - } + // Round down to a "nice" number for clean axis labels + float yAxisMin = RoundToNiceNumber(yAxisMinRaw, false); // Draw grid lines and labels with dynamic scale var labelStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.grey } }; @@ -3192,16 +3188,16 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo { // Logarithmic Y-axis labels: 0.01%, 0.1%, 1%, 10%, 100% float[] logValues = { 0.0001f, 0.001f, 0.01f, 0.1f, 1f }; // As fractions - foreach (float failureProb in logValues) + foreach (float survivalProb in logValues) { - if (failureProb > yAxisMax) break; // Don't show labels beyond max + if (survivalProb < yAxisMin) continue; // Don't show labels below min - float y = FailureProbToYPosition(failureProb, yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + float y = SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, useLogScaleY); Rect lineRect = new Rect(plotArea.x, y, plotArea.width, 1); if (Event.current.type == EventType.Repaint) GUI.DrawTexture(lineRect, chartGridMajorTex); - float labelValue = failureProb * 100f; + float labelValue = survivalProb * 100f; string label = labelValue < 0.1f ? $"{labelValue:F3}%" : (labelValue < 1f ? $"{labelValue:F2}%" : (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%")); GUI.Label(new Rect(plotArea.x - 35, y - 10, 30, 20), label, labelStyle); } @@ -3209,11 +3205,12 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo else { // Linear Y-axis: Major gridlines at 20% intervals, minor at 10% - // Draw all gridlines (major + minor) + // Draw all gridlines (major + minor) from yAxisMin to 100% for (int i = 0; i <= 10; i++) { - bool isMajor = (i % 2 == 0); // Major gridlines at 0%, 20%, 40%, 60%, 80%, 100% - float y = plotArea.y + plotArea.height - (i * plotArea.height / 10f); + bool isMajor = (i % 2 == 0); // Major gridlines at yAxisMin%, ..., 100% + float survivalProb = yAxisMin + (i / 10f) * (1f - yAxisMin); + float y = SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, useLogScaleY); Rect lineRect = new Rect(plotArea.x, y, plotArea.width, 1); if (Event.current.type == EventType.Repaint) @@ -3225,7 +3222,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo // Only show labels on major gridlines if (isMajor) { - float labelValue = (i / 10f) * yAxisMax * 100f; + float labelValue = survivalProb * 100f; string label = labelValue < 1f ? $"{labelValue:F2}%" : (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%"); GUI.Label(new Rect(plotArea.x - 35, y - 10, 30, 20), label, labelStyle); } @@ -3243,58 +3240,94 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo float max100xTime = referenceBurnTime * 2.5f; float max100xX = TimeToXPosition(max100xTime, maxTime, plotArea.x, plotArea.width, useLogScaleX); + // Clamp all zone boundaries to plot area to prevent drawing outside + float plotAreaRight = plotArea.x + plotArea.width; + startupEndX = Mathf.Clamp(startupEndX, plotArea.x, plotAreaRight); + ratedCushionedX = Mathf.Clamp(ratedCushionedX, plotArea.x, plotAreaRight); + testedX = Mathf.Clamp(testedX, plotArea.x, plotAreaRight); + max100xX = Mathf.Clamp(max100xX, plotArea.x, plotAreaRight); + if (Event.current.type == EventType.Repaint) { // Zone 1: Startup (0-5s) - Dark blue (high initial risk) - GUI.DrawTexture(new Rect(plotArea.x, plotArea.y, startupEndX - plotArea.x, plotArea.height), chartStartupZoneTex); + float zone1Width = Mathf.Max(0, startupEndX - plotArea.x); + if (zone1Width > 0) + GUI.DrawTexture(new Rect(plotArea.x, plotArea.y, zone1Width, plotArea.height), chartStartupZoneTex); // Zone 2: Rated Operation (5s to ratedBurnTime+5) - Green (safe zone) - GUI.DrawTexture(new Rect(startupEndX, plotArea.y, ratedCushionedX - startupEndX, plotArea.height), - chartGreenZoneTex); + float zone2Width = Mathf.Max(0, ratedCushionedX - startupEndX); + if (zone2Width > 0) + GUI.DrawTexture(new Rect(startupEndX, plotArea.y, zone2Width, plotArea.height), chartGreenZoneTex); if (hasTestedBurnTime) { // Zone 3: Tested Overburn (rated+5 to tested) - Yellow (reduced penalty overburn) - GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, testedX - ratedCushionedX, plotArea.height), - chartYellowZoneTex); + float zone3Width = Mathf.Max(0, testedX - ratedCushionedX); + if (zone3Width > 0) + GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, zone3Width, plotArea.height), chartYellowZoneTex); // Zone 4: Severe Overburn (tested to 100×) - Red (danger zone) - GUI.DrawTexture(new Rect(testedX, plotArea.y, max100xX - testedX, plotArea.height), - chartRedZoneTex); + float zone4Width = Mathf.Max(0, max100xX - testedX); + if (zone4Width > 0) + GUI.DrawTexture(new Rect(testedX, plotArea.y, zone4Width, plotArea.height), chartRedZoneTex); // Zone 5: Maximum Overburn (100× to end) - Darker red (nearly linear failure increase) - GUI.DrawTexture(new Rect(max100xX, plotArea.y, plotArea.x + plotArea.width - max100xX, plotArea.height), - chartDarkRedZoneTex); + float zone5Width = Mathf.Max(0, plotAreaRight - max100xX); + if (zone5Width > 0) + GUI.DrawTexture(new Rect(max100xX, plotArea.y, zone5Width, plotArea.height), chartDarkRedZoneTex); } else { // Zone 3: Overburn (rated+5 to 100×) - Red (danger zone) - GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, max100xX - ratedCushionedX, plotArea.height), - chartRedZoneTex); + float zone3Width = Mathf.Max(0, max100xX - ratedCushionedX); + if (zone3Width > 0) + GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, zone3Width, plotArea.height), chartRedZoneTex); // Zone 4: Maximum Overburn (100× to end) - Darker red (nearly linear failure increase) - GUI.DrawTexture(new Rect(max100xX, plotArea.y, plotArea.x + plotArea.width - max100xX, plotArea.height), - chartDarkRedZoneTex); + float zone4Width = Mathf.Max(0, plotAreaRight - max100xX); + if (zone4Width > 0) + GUI.DrawTexture(new Rect(max100xX, plotArea.y, zone4Width, plotArea.height), chartDarkRedZoneTex); } } - // Draw vertical zone separators (thinner and less prominent) + // Check if mouse is hovering over any vertical separator for thick line rendering + Vector2 mousePos = Event.current.mousePosition; + bool mouseInPlot = plotArea.Contains(mousePos); + bool nearStartupMarker = mouseInPlot && Mathf.Abs(mousePos.x - startupEndX) < 8f; + bool nearRatedMarker = mouseInPlot && Mathf.Abs(mousePos.x - ratedCushionedX) < 8f; + bool nearTestedMarker = mouseInPlot && hasTestedBurnTime && Mathf.Abs(mousePos.x - testedX) < 8f; + bool near100xMarker = mouseInPlot && Mathf.Abs(mousePos.x - max100xX) < 8f; + + // Draw vertical zone separators - only if within plot area, thicker when hovering if (Event.current.type == EventType.Repaint) { // Startup zone end (5s) - Blue - GUI.DrawTexture(new Rect(startupEndX, plotArea.y, 1, plotArea.height), chartMarkerBlueTex); + if (startupEndX >= plotArea.x && startupEndX <= plotAreaRight) + { + float lineWidth = nearStartupMarker ? 4f : 1f; + GUI.DrawTexture(new Rect(startupEndX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerBlueTex); + } // Rated burn time (+ 5s cushion) - Green - GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, 1, plotArea.height), chartMarkerGreenTex); + if (ratedCushionedX >= plotArea.x && ratedCushionedX <= plotAreaRight) + { + float lineWidth = nearRatedMarker ? 4f : 1f; + GUI.DrawTexture(new Rect(ratedCushionedX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerGreenTex); + } // Tested burn time (if present) - Yellow - if (hasTestedBurnTime) + if (hasTestedBurnTime && testedX >= plotArea.x && testedX <= plotAreaRight) { - GUI.DrawTexture(new Rect(testedX, plotArea.y, 1, plotArea.height), chartMarkerYellowTex); + float lineWidth = nearTestedMarker ? 4f : 1f; + GUI.DrawTexture(new Rect(testedX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerYellowTex); } // 100× modifier point (maximum cycle penalty) - Dark Red - GUI.DrawTexture(new Rect(max100xX, plotArea.y, 1, plotArea.height), chartMarkerDarkRedTex); + if (max100xX >= plotArea.x && max100xX <= plotAreaRight) + { + float lineWidth = near100xMarker ? 4f : 1f; + GUI.DrawTexture(new Rect(max100xX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerDarkRedTex); + } } // Now calculate point positions for all curves using the dynamic Y scale @@ -3308,7 +3341,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo float x = TimeToXPosition(t, maxTime, plotArea.x, plotArea.width, useLogScaleX); // Start curve (0 data) - orange - float yStart = FailureProbToYPosition(failureProbsStart[i], yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + float yStart = SurvivalProbToYPosition(survivalProbsStart[i], yAxisMin, plotArea.y, plotArea.height, useLogScaleY); if (float.IsNaN(x) || float.IsNaN(yStart) || float.IsInfinity(x) || float.IsInfinity(yStart)) { x = plotArea.x; @@ -3317,7 +3350,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo pointsStart[i] = new Vector2(x, yStart); // End curve (max data) - green - float yEnd = FailureProbToYPosition(failureProbsEnd[i], yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + float yEnd = SurvivalProbToYPosition(survivalProbsEnd[i], yAxisMin, plotArea.y, plotArea.height, useLogScaleY); if (float.IsNaN(x) || float.IsNaN(yEnd) || float.IsInfinity(x) || float.IsInfinity(yEnd)) { x = plotArea.x; @@ -3328,7 +3361,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo // Current data curve - light blue if (hasCurrentData) { - float yCurrent = FailureProbToYPosition(failureProbsCurrent[i], yAxisMax, plotArea.y, plotArea.height, useLogScaleY); + float yCurrent = SurvivalProbToYPosition(survivalProbsCurrent[i], yAxisMin, plotArea.y, plotArea.height, useLogScaleY); if (float.IsNaN(x) || float.IsNaN(yCurrent) || float.IsInfinity(x) || float.IsInfinity(yCurrent)) { x = plotArea.x; @@ -3338,18 +3371,30 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo } } - // Draw all curves + // Draw all curves (with clipping to plot area) if (Event.current.type == EventType.Repaint) { // Draw start curve (0 data) in orange for (int i = 0; i < pointsStart.Length - 1; i++) { + // Skip line segments outside the plot area + if (pointsStart[i].x > plotArea.x + plotArea.width && pointsStart[i + 1].x > plotArea.x + plotArea.width) + continue; + if (pointsStart[i].x < plotArea.x && pointsStart[i + 1].x < plotArea.x) + continue; + DrawLine(pointsStart[i], pointsStart[i + 1], chartOrangeLineTex, 2.5f); } // Draw end curve (max data) in green for (int i = 0; i < pointsEnd.Length - 1; i++) { + // Skip line segments outside the plot area + if (pointsEnd[i].x > plotArea.x + plotArea.width && pointsEnd[i + 1].x > plotArea.x + plotArea.width) + continue; + if (pointsEnd[i].x < plotArea.x && pointsEnd[i + 1].x < plotArea.x) + continue; + DrawLine(pointsEnd[i], pointsEnd[i + 1], chartGreenLineTex, 2.5f); } @@ -3358,6 +3403,12 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo { for (int i = 0; i < pointsCurrent.Length - 1; i++) { + // Skip line segments outside the plot area + if (pointsCurrent[i].x > plotArea.x + plotArea.width && pointsCurrent[i + 1].x > plotArea.x + plotArea.width) + continue; + if (pointsCurrent[i].x < plotArea.x && pointsCurrent[i + 1].x < plotArea.x) + continue; + DrawLine(pointsCurrent[i], pointsCurrent[i + 1], chartBlueLineTex, 2.5f); } } @@ -3374,7 +3425,7 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo { if (time > maxTime) break; float x = TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, useLogScaleX); - string label = time < 60f ? $"{time:F0}s" : $"{time / 60:F0}m"; + string label = FormatTime(time); GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), label, timeStyle); } } @@ -3385,17 +3436,18 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo { float time = (i / 4f) * maxTime; float x = TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, useLogScaleX); - GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), $"{time / 60:F0}m", timeStyle); + GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), FormatTime(time), timeStyle); } } // Chart title var titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, fontStyle = FontStyle.Bold, normal = { textColor = Color.white }, alignment = TextAnchor.MiddleCenter }; - GUI.Label(new Rect(chartRect.x, chartRect.y + 4, chartWidth, 24), "Failure Probability vs Burn Time", titleStyle); + GUI.Label(new Rect(chartRect.x, chartRect.y + 4, chartWidth, 24), "Survival Probability vs Burn Time", titleStyle); - // Legend with colored circles + // Legend with colored circles (positioned at top right) var legendStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.white }, alignment = TextAnchor.UpperLeft }; - float legendX = plotArea.x + 10; + float legendWidth = 110f; // Approximate width of legend items + float legendX = plotArea.x + plotArea.width - legendWidth; float legendY = plotArea.y + 5; // Orange circle and line for 0 data @@ -3424,11 +3476,11 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo } // Tooltip handling and hover line - Vector2 mousePos = Event.current.mousePosition; - if (plotArea.Contains(mousePos)) + if (mouseInPlot) { - // Draw vertical hover line - if (Event.current.type == EventType.Repaint) + // Draw vertical hover line (but not when hovering over a marker) + bool hoveringAnyMarker = nearStartupMarker || nearRatedMarker || nearTestedMarker || near100xMarker; + if (Event.current.type == EventType.Repaint && !hoveringAnyMarker) { GUI.DrawTexture(new Rect(mousePos.x, plotArea.y, 1, plotArea.height), chartHoverLineTex); } @@ -3463,12 +3515,6 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo // Calculate cycle modifier at this time float cycleModifier = cycleCurve.Evaluate(mouseT); - // Check if hovering near vertical markers for specific marker info - bool nearStartupMarker = Mathf.Abs(mousePos.x - startupEndX) < 8f; - bool nearRatedMarker = Mathf.Abs(mousePos.x - ratedCushionedX) < 8f; - bool nearTestedMarker = hasTestedBurnTime && Mathf.Abs(mousePos.x - testedX) < 8f; - bool near100xMarker = Mathf.Abs(mousePos.x - max100xX) < 8f; - string tooltipText = ""; string valueColor = "#E6D68A"; // Muted yellow for time values and numeric values @@ -3478,68 +3524,63 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo } else if (nearRatedMarker) { - float ratedMinutes = ratedBurnTime / 60f; - string ratedTimeStr = ratedMinutes >= 1f ? $"{ratedMinutes:F1}m" : $"{ratedBurnTime:F0}s"; + string ratedTimeStr = FormatTime(ratedBurnTime); tooltipText = $"Rated Burn Time\n\nThis engine is designed to run for {ratedTimeStr}.\nBeyond this point, overburn penalties increase failure risk."; } else if (nearTestedMarker) { - float testedMinutes = testedBurnTime / 60f; - string testedTimeStr = testedMinutes >= 1f ? $"{testedMinutes:F1}m" : $"{testedBurnTime:F0}s"; + string testedTimeStr = FormatTime(testedBurnTime); tooltipText = $"Tested Overburn Limit\n\nThis engine was tested to {testedTimeStr} in real life.\nFailure risk reaches {overburnPenalty:F1}× at this point.\nBeyond here, risk increases rapidly toward certain failure."; } else if (near100xMarker) { - float max100xMinutes = max100xTime / 60f; - string max100xTimeStr = max100xMinutes >= 1f ? $"{max100xMinutes:F1}m" : $"{max100xTime:F0}s"; + string max100xTimeStr = FormatTime(max100xTime); tooltipText = $"Maximum Cycle Penalty (100×)\n\nAt {max100xTimeStr}, the failure rate multiplier reaches its maximum of 100×.\n\nBeyond this point, it doesn't get much worse—failure probability increases nearly linearly with time."; } else { - // Calculate failure probabilities at mouse position - float mouseFailStart = 0f; - float mouseFailEnd = 0f; - float mouseFailCurrent = 0f; + // Calculate survival probabilities at mouse position + float mouseSurviveStart = 0f; + float mouseSurviveEnd = 0f; + float mouseSurviveCurrent = 0f; if (mouseT <= ratedBurnTime) { - mouseFailStart = 1f - Mathf.Pow(cycleReliabilityStart, mouseT / ratedBurnTime); - mouseFailEnd = 1f - Mathf.Pow(cycleReliabilityEnd, mouseT / ratedBurnTime); + mouseSurviveStart = Mathf.Pow(cycleReliabilityStart, mouseT / ratedBurnTime); + mouseSurviveEnd = Mathf.Pow(cycleReliabilityEnd, mouseT / ratedBurnTime); if (hasCurrentData) - mouseFailCurrent = 1f - Mathf.Pow(cycleReliabilityCurrent, mouseT / ratedBurnTime); + mouseSurviveCurrent = Mathf.Pow(cycleReliabilityCurrent, mouseT / ratedBurnTime); } else { float survivalToRatedStart = cycleReliabilityStart; float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, mouseT, 20); float additionalFailRate = baseRateStart * integratedModifier; - mouseFailStart = Mathf.Clamp01(1f - (survivalToRatedStart * Mathf.Exp(-additionalFailRate))); + mouseSurviveStart = Mathf.Clamp01(survivalToRatedStart * Mathf.Exp(-additionalFailRate)); float survivalToRatedEnd = cycleReliabilityEnd; additionalFailRate = baseRateEnd * integratedModifier; - mouseFailEnd = Mathf.Clamp01(1f - (survivalToRatedEnd * Mathf.Exp(-additionalFailRate))); + mouseSurviveEnd = Mathf.Clamp01(survivalToRatedEnd * Mathf.Exp(-additionalFailRate)); if (hasCurrentData) { float survivalToRatedCurrent = cycleReliabilityCurrent; additionalFailRate = baseRateCurrent * integratedModifier; - mouseFailCurrent = Mathf.Clamp01(1f - (survivalToRatedCurrent * Mathf.Exp(-additionalFailRate))); + mouseSurviveCurrent = Mathf.Clamp01(survivalToRatedCurrent * Mathf.Exp(-additionalFailRate)); } } - // Apply cluster math to tooltip values + // Apply cluster math to tooltip values (cluster survival = single^N) if (clusterSize > 1) { - mouseFailStart = 1f - Mathf.Pow(1f - mouseFailStart, clusterSize); - mouseFailEnd = 1f - Mathf.Pow(1f - mouseFailEnd, clusterSize); + mouseSurviveStart = Mathf.Pow(mouseSurviveStart, clusterSize); + mouseSurviveEnd = Mathf.Pow(mouseSurviveEnd, clusterSize); if (hasCurrentData) - mouseFailCurrent = 1f - Mathf.Pow(1f - mouseFailCurrent, clusterSize); + mouseSurviveCurrent = Mathf.Pow(mouseSurviveCurrent, clusterSize); } // Format time string - float minutes = Mathf.Floor(mouseT / 60f); - float seconds = mouseT % 60f; - string timeStr = minutes > 0 ? $"{minutes:F0}m {seconds:F0}s" : $"{seconds:F1}s"; + string timeStr = FormatTime(mouseT); // Color code the zone name based on zone type string zoneColor = ""; @@ -3554,40 +3595,23 @@ private void DrawFailureProbabilityChart(ConfigNode configNode, float width, flo else zoneColor = "#CC2222"; // Dark red for maximum overburn - // Build tooltip with color-coded values (valueColor already defined above) + // Build simplified tooltip string orangeColor = "#FF8033"; // Match orange line (0 data) string blueColor = "#7DD9FF"; // Match lighter blue line (current data) string greenColor = "#4DE64D"; // Match green line (max data) - // Calculate failure and survival percentages - float mouseFailStartPercent = mouseFailStart * 100f; - float mouseFailEndPercent = mouseFailEnd * 100f; - float mouseFailCurrentPercent = hasCurrentData ? mouseFailCurrent * 100f : 0f; - - float mouseSurviveStart = (1f - mouseFailStart) * 100f; - float mouseSurviveEnd = (1f - mouseFailEnd) * 100f; - float mouseSurviveCurrent = hasCurrentData ? (1f - mouseFailCurrent) * 100f : 0f; + string entityName = clusterSize > 1 ? "cluster" : "engine"; tooltipText = $"{zoneName}\n\n"; - // Format failure percentages in X%/X%/X% format - if (hasCurrentData) - { - tooltipText += $"Failure chance: {mouseFailStartPercent:F2}% / {mouseFailCurrentPercent:F2}% / {mouseFailEndPercent:F2}%\n"; - } - else - { - tooltipText += $"Failure chance: {mouseFailStartPercent:F2}% / {mouseFailEndPercent:F2}%\n"; - } - - // Format survival percentages in X%/X%/X% format + // Show survival percentages in X%/X%/X% format if (hasCurrentData) { - tooltipText += $"This engine has a {mouseSurviveStart:F1}% / {mouseSurviveCurrent:F1}% / {mouseSurviveEnd:F1}% chance to survive to {timeStr}\n\n"; + tooltipText += $"This {entityName} has a {mouseSurviveStart * 100f:F1}% / {mouseSurviveCurrent * 100f:F1}% / {mouseSurviveEnd * 100f:F1}% chance to survive to {timeStr}\n\n"; } else { - tooltipText += $"This engine has a {mouseSurviveStart:F1}% / {mouseSurviveEnd:F1}% chance to survive to {timeStr}\n\n"; + tooltipText += $"This {entityName} has a {mouseSurviveStart * 100f:F1}% / {mouseSurviveEnd * 100f:F1}% chance to survive to {timeStr}\n\n"; } tooltipText += $"Cycle modifier: {cycleModifier:F2}×"; @@ -3745,7 +3769,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu sectionStyle.normal.textColor = Color.white; // Use white for base, colors come from rich text GUI.Label(new Rect(rect.x, yPos, rect.width, 20), headerText, sectionStyle); - yPos += 20; + yPos += 24; // Build single narrative with all values string engineText = clusterSize > 1 ? $"A cluster of {clusterSize} engines" : "This engine"; @@ -3766,7 +3790,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu else combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessEnd:F1}%"; - combinedText += $" chance{forAll} to burn for {ratedBurnTime:F0}s (rated)"; + combinedText += $" chance{forAll} to burn for {FormatTime(ratedBurnTime)} (rated)"; // Tested burn success rates (if applicable, with larger font for percentages) if (hasTestedBurnTime) @@ -3777,7 +3801,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu else combinedText += $"{testedSuccessStart:F1}% / {testedSuccessEnd:F1}%"; - combinedText += $" chance{forAll} to burn to {testedBurnTime:F0}s (tested)"; + combinedText += $" chance{forAll} to burn to {FormatTime(testedBurnTime)} (tested)"; } combinedText += "."; @@ -3806,7 +3830,6 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu // === LEFT COLUMN: DATA GAINS === float leftYPos = columnStartY; - string infoColor = "#FFEE88"; // Light yellow string purpleColor = "#CCB3FF"; // Light purple - matches section header // Calculate data collection info @@ -3817,9 +3840,9 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu // Section header sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); // Light purple/lavender GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, 20), "How To Gain Data:", sectionStyle); - leftYPos += 20; + leftYPos += 24; - // Bullet list + // Styles var bulletStyle = new GUIStyle(GUI.skin.label) { fontSize = 14, @@ -3829,24 +3852,52 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu padding = new RectOffset(8, 8, 1, 1) }; + var indentedBulletStyle = new GUIStyle(bulletStyle) + { + padding = new RectOffset(24, 8, 1, 1) + }; + + var footerStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + normal = { textColor = new Color(0.6f, 0.6f, 0.6f) }, + padding = new RectOffset(8, 8, 1, 1) + }; + float bulletHeight = 18; - // Full rated burn entry - string ratedBurnText = $"• Full rated burn ({ratedBurnTime:F0}s) gains {duPerFlight:F0} du"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), ratedBurnText, bulletStyle); + // Failures section + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), "An engine can fail in 5 ways:", bulletStyle); leftYPos += bulletHeight; - // Failure types - string[] failureTypes = { "Explode", "Shutdown", "Ignition Fail", "Perf. Loss", "Reduced Thrust" }; - int[] failureDu = { 1300, 1100, 1050, 800, 700 }; + // In-flight failures (based on weight distribution: total = 58) + // Values capped at 1000 du (per-flight max) + string[] failureTypes = { "Shutdown", "Perf. Loss", "Reduced Thrust", "Explode" }; + int[] failureDu = { 1000, 800, 700, 1000 }; + float[] failurePercents = { 55.2f, 27.6f, 13.8f, 3.4f }; // weights: 32, 16, 8, 2 out of 58 for (int i = 0; i < failureTypes.Length; i++) { - string bulletText = $"• {failureTypes[i]} gains {failureDu[i]} du"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), bulletText, bulletStyle); + string failText = $" ({failurePercents[i]:F0}%) {failureTypes[i]} +{failureDu[i]} du"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), failText, indentedBulletStyle); leftYPos += bulletHeight; } + // Ignition failure (separate from cycle failures) + string ignitionText = $" Ignition Fail +1000 du"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), ignitionText, indentedBulletStyle); + leftYPos += bulletHeight + 4; + + // Running gains + string runningText = $"Running gains {dataRate:F1} du/s"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), runningText, bulletStyle); + leftYPos += bulletHeight + 8; + + // Footer note + string footerText = "(no more than 1000 du per flight)"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), footerText, footerStyle); + leftYPos += bulletHeight; + leftColumnEndY = leftYPos + 8; // === RIGHT COLUMN: SIMULATION CONTROLS === @@ -3908,13 +3959,6 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu // Reset cluster clusterSize = 1; clusterSizeInput = "1"; - - // Reset axis limits to auto - useCustomAxisLimits = false; - customMaxTime = autoMaxTime; - customMaxFailProb = autoMaxFailProb; - maxTimeInput = $"{customMaxTime:F0}"; - maxFailProbInput = $"{(customMaxFailProb * 100f):F1}"; } rightYPos += 24; @@ -3989,68 +4033,54 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu } rightYPos += 24; - // Max Time slider - GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Max Time (s)", controlStyle); - rightYPos += 16; - - customMaxTime = GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), - customMaxTime, 10f, autoMaxTime * 2f, sliderStyle, thumbStyle); - - if (Mathf.Abs(customMaxTime - autoMaxTime) > 1f) - useCustomAxisLimits = true; - - // Max Time input field - maxTimeInput = $"{customMaxTime:F0}"; - GUI.SetNextControlName("maxTimeInput"); - string newMaxTimeInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), - maxTimeInput, 6, inputStyle); + rightColumnEndY = rightYPos; - if (newMaxTimeInput != maxTimeInput) + // Draw vertical separator between columns + if (Event.current.type == EventType.Repaint) { - maxTimeInput = newMaxTimeInput; - if (GUI.GetNameOfFocusedControl() == "maxTimeInput" && float.TryParse(maxTimeInput, out float inputMaxTime)) - { - inputMaxTime = Mathf.Clamp(inputMaxTime, 10f, autoMaxTime * 10f); - customMaxTime = inputMaxTime; - useCustomAxisLimits = true; - } + float separatorX = rect.x + leftColumnWidth; + float separatorHeight = Mathf.Max(leftColumnEndY, rightColumnEndY) - columnStartY; + GUI.DrawTexture(new Rect(separatorX, columnStartY, 1, separatorHeight), chartSeparatorTex); } - rightYPos += 24; - - // Max Fail % slider - GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Max Fail %", controlStyle); - rightYPos += 16; - float customMaxFailPercent = customMaxFailProb * 100f; - customMaxFailPercent = GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), - customMaxFailPercent, 1f, 100f, sliderStyle, thumbStyle); - customMaxFailProb = customMaxFailPercent / 100f; + // Use the taller column to determine where to place bottom separator + yPos = Mathf.Max(leftColumnEndY, rightColumnEndY) + 8; - if (Mathf.Abs(customMaxFailProb - autoMaxFailProb) > 0.01f) - useCustomAxisLimits = true; + // Draw horizontal separator + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), chartSeparatorTex); + } + yPos += 10; - // Max Fail % input field - maxFailProbInput = $"{customMaxFailPercent:F1}"; - GUI.SetNextControlName("maxFailProbInput"); - string newMaxFailInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), - maxFailProbInput, 5, inputStyle); + // === FAILURE RATE SECTION === + // Calculate failure rate based on current data slider value + float cycleReliabilityAtCurrentData = EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); - if (newMaxFailInput != maxFailProbInput) + // Apply cluster math if needed + if (clusterSize > 1) { - maxFailProbInput = newMaxFailInput; - if (GUI.GetNameOfFocusedControl() == "maxFailProbInput" && float.TryParse(maxFailProbInput, out float inputMaxFail)) - { - inputMaxFail = Mathf.Clamp(inputMaxFail, 1f, 200f); - customMaxFailProb = inputMaxFail / 100f; - useCustomAxisLimits = true; - } + cycleReliabilityAtCurrentData = Mathf.Pow(cycleReliabilityAtCurrentData, clusterSize); } - rightYPos += 24; - rightColumnEndY = rightYPos; + float failureRate = 1f - cycleReliabilityAtCurrentData; + float oneInX = failureRate > 0.0001f ? (1f / failureRate) : 9999f; - // Use the taller column to determine the final height - yPos = Mathf.Max(leftColumnEndY, rightColumnEndY) + 8; + // Display in large font + var failureRateStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 18, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white }, + alignment = TextAnchor.MiddleCenter, + richText = true + }; + + string failureRateColor = "#FF6666"; // Light red to emphasize the failure rate + string failureText = $"With {currentDataValue:F0} du, 1 in {oneInX:F1} rated burns will fail ({FormatTime(ratedBurnTime)})"; + float failureTextHeight = failureRateStyle.CalcHeight(new GUIContent(failureText), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, failureTextHeight), failureText, failureRateStyle); + yPos += failureTextHeight + 8; } private void DrawLine(Vector2 start, Vector2 end, Texture2D texture, float width) From 6c3ea714d92399032f295cf7afb5b0c5c8ca2bac Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 18:35:26 -0800 Subject: [PATCH 05/12] Refactor column management in ModuleEngineConfigs to remove unused data columns and update visibility logic --- Source/Engines/ModuleEngineConfigs.cs | 99 ++++++++++----------------- 1 file changed, 37 insertions(+), 62 deletions(-) diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index d29698b1..72d7d823 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1446,8 +1446,8 @@ public void OnDestroy() // Column visibility customization private bool showColumnMenu = false; private static Rect columnMenuRect = new Rect(100, 100, 280, 650); // Separate window rect - tall enough for all columns - private static bool[] columnsVisibleFull = new bool[19]; - private static bool[] columnsVisibleCompact = new bool[19]; + private static bool[] columnsVisibleFull = new bool[18]; + private static bool[] columnsVisibleCompact = new bool[18]; private static bool columnVisibilityInitialized = false; // Simulation controls for data percentage and cluster size @@ -1469,7 +1469,7 @@ public void OnDestroy() private const int ConfigRowHeight = 22; private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) // Dynamic column widths - calculated based on content - private float[] ConfigColumnWidths = new float[19]; // Added du column + private float[] ConfigColumnWidths = new float[18]; private static Texture2D rowHoverTex; private static Texture2D rowCurrentTex; @@ -1778,7 +1778,6 @@ protected void CalculateColumnWidths(List rows) GetBoolSymbol(row.Node, "pressureFed"), GetRatedBurnTimeString(row.Node), GetTestedBurnTimeString(row.Node), // NEW: Tested burn time column - GetFlightDataString(), // NEW: Data (du) column GetIgnitionReliabilityStartString(row.Node), GetIgnitionReliabilityEndString(row.Node), GetCycleReliabilityStartString(row.Node), @@ -1800,14 +1799,13 @@ protected void CalculateColumnWidths(List rows) } // Action column needs fixed width for two buttons - ConfigColumnWidths[18] = 160f; + ConfigColumnWidths[17] = 160f; // Set minimum widths for specific columns ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); // Ull ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); // PFed ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); // Rated burn ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f); // Tested burn - ConfigColumnWidths[11] = Mathf.Max(ConfigColumnWidths[11], 80f); // Data (du) } protected void DrawConfigTable(IEnumerable rows) @@ -1901,7 +1899,7 @@ private void DrawColumnMenu(Rect menuRect) // Column names string[] columnNames = { "Name", "Thrust", "Min%", "ISP", "Mass", "Gimbal", - "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)", "Data (du)", + "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)", "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", "Tech", "Cost", "Actions" }; @@ -2038,41 +2036,36 @@ private void DrawHeaderRow(Rect headerRect) } if (IsColumnVisible(11)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[11], headerRect.height), - "Data (du)", "Current flight data from TestFlight"); + "Ign No Data", "Ignition reliability at 0 data"); currentX += ConfigColumnWidths[11]; } if (IsColumnVisible(12)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[12], headerRect.height), - "Ign No Data", "Ignition reliability at 0 data"); + "Ign Max Data", "Ignition reliability at max data"); currentX += ConfigColumnWidths[12]; } if (IsColumnVisible(13)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[13], headerRect.height), - "Ign Max Data", "Ignition reliability at max data"); + "Burn No Data", "Cycle reliability at 0 data"); currentX += ConfigColumnWidths[13]; } if (IsColumnVisible(14)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[14], headerRect.height), - "Burn No Data", "Cycle reliability at 0 data"); + "Burn Max Data", "Cycle reliability at max data"); currentX += ConfigColumnWidths[14]; } if (IsColumnVisible(15)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[15], headerRect.height), - "Burn Max Data", "Cycle reliability at max data"); + Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); currentX += ConfigColumnWidths[15]; } if (IsColumnVisible(16)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[16], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); + "Extra Cost", "Extra cost for this config"); currentX += ConfigColumnWidths[16]; } if (IsColumnVisible(17)) { DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[17], headerRect.height), - "Extra Cost", "Extra cost for this config"); - currentX += ConfigColumnWidths[17]; - } - if (IsColumnVisible(18)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[18], headerRect.height), "", "Switch and purchase actions"); // No label, just tooltip } } @@ -2191,42 +2184,37 @@ private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered } if (IsColumnVisible(11)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetFlightDataString(), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[11]; } if (IsColumnVisible(12)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[12]; } if (IsColumnVisible(13)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[13]; } if (IsColumnVisible(14)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[14]; } if (IsColumnVisible(15)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetTechString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[15]; } if (IsColumnVisible(16)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[16], rowRect.height), GetTechString(row.Node), secondaryStyle); + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[16], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); currentX += ConfigColumnWidths[16]; } if (IsColumnVisible(17)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[17], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[17]; - } - - if (IsColumnVisible(18)) { - DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[18], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); + DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[17], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); } } @@ -2437,15 +2425,15 @@ private void InitializeColumnVisibility() return; // Initialize full view: all columns visible by default - for (int i = 0; i < 19; i++) + for (int i = 0; i < 18; i++) columnsVisibleFull[i] = true; // Initialize compact view: only essential columns - for (int i = 0; i < 19; i++) + for (int i = 0; i < 18; i++) columnsVisibleCompact[i] = false; // Essential columns for compact view - int[] compactColumns = { 0, 1, 3, 4, 6, 9, 10, 11, 16, 17, 18 }; // 11 is Flight Data (moved after Tested) + int[] compactColumns = { 0, 1, 3, 4, 6, 9, 10, 15, 16, 17 }; // Tech, Cost, Actions foreach (int col in compactColumns) columnsVisibleCompact[col] = true; @@ -2456,7 +2444,7 @@ private bool IsColumnVisible(int columnIndex) { InitializeColumnVisibility(); - if (columnIndex < 0 || columnIndex >= 19) + if (columnIndex < 0 || columnIndex >= 18) return false; return compactView ? columnsVisibleCompact[columnIndex] : columnsVisibleFull[columnIndex]; @@ -3867,7 +3855,7 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu float bulletHeight = 18; // Failures section - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), "An engine can fail in 5 ways:", bulletStyle); + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), "An engine can fail in 4 ways:", bulletStyle); leftYPos += bulletHeight; // In-flight failures (based on weight distribution: total = 58) @@ -3883,14 +3871,16 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu leftYPos += bulletHeight; } - // Ignition failure (separate from cycle failures) - string ignitionText = $" Ignition Fail +1000 du"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), ignitionText, indentedBulletStyle); - leftYPos += bulletHeight + 4; + leftYPos += 4; // Running gains string runningText = $"Running gains {dataRate:F1} du/s"; GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), runningText, bulletStyle); + leftYPos += bulletHeight; + + // Ignition failure (separate from cycle failures) + string ignitionText = $"Ignition Fail +1000 du"; + GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), ignitionText, bulletStyle); leftYPos += bulletHeight + 8; // Footer note @@ -3917,30 +3907,15 @@ private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBu var buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 12, fontStyle = FontStyle.Bold }; var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - // Buttons row - all horizontal - float logBtnWidth = 60f; - float btnSpacing = 4f; - float resetBtnWidth = rightColumnWidth - 24 - (logBtnWidth * 2) - (btnSpacing * 2); - - var boldButtonStyle = new GUIStyle(buttonStyle) { fontStyle = FontStyle.Bold }; - - // X-axis log scale toggle - string toggleLabelX = useLogScaleX ? "X: Lin" : "X: Log"; - if (GUI.Button(new Rect(rightColumnX + 8, rightYPos, logBtnWidth, 20), toggleLabelX, boldButtonStyle)) - { - useLogScaleX = !useLogScaleX; - } - - // Y-axis log scale toggle - string toggleLabelY = useLogScaleY ? "Y: Lin" : "Y: Log"; - if (GUI.Button(new Rect(rightColumnX + 8 + logBtnWidth + btnSpacing, rightYPos, logBtnWidth, 20), toggleLabelY, boldButtonStyle)) - { - useLogScaleY = !useLogScaleY; - } + // Section header + sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); // Light purple/lavender + GUI.Label(new Rect(rightColumnX, rightYPos, rightColumnWidth, 20), "Simulate:", sectionStyle); + rightYPos += 24; - // Reset button - string resetButtonText = "Reset All"; - if (GUI.Button(new Rect(rightColumnX + 8 + (logBtnWidth + btnSpacing) * 2, rightYPos, resetBtnWidth, 20), resetButtonText, buttonStyle)) + // Reset button (full width) + float resetBtnWidth = rightColumnWidth - 16; + string resetButtonText = hasRealData ? $"Set to Current du ({realCurrentData:F0})" : "Set to Current du (0)"; + if (GUI.Button(new Rect(rightColumnX + 8, rightYPos, resetBtnWidth, 20), resetButtonText, buttonStyle)) { // Reset data value if (hasRealData) From 252c2767255a6a64cdb44099320ff8707b7d1eea Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 21:11:05 -0800 Subject: [PATCH 06/12] Refactor code structure --- Source/Engines/ChartMath.cs | 410 ++++ Source/Engines/EngineConfigChart.cs | 534 +++++ Source/Engines/EngineConfigGUI.cs | 120 + Source/Engines/EngineConfigInfoPanel.cs | 399 ++++ Source/Engines/EngineConfigIntegrations.cs | 182 ++ Source/Engines/EngineConfigPropellants.cs | 91 + Source/Engines/EngineConfigStyles.cs | 245 ++ Source/Engines/EngineConfigTechLevels.cs | 248 ++ Source/Engines/EngineConfigTextures.cs | 185 ++ Source/Engines/ModuleBimodalEngineConfigs.cs | 8 +- Source/Engines/ModuleEngineConfigs.cs | 2151 ++---------------- Source/RealFuels.csproj | 8 + 12 files changed, 2585 insertions(+), 1996 deletions(-) create mode 100644 Source/Engines/ChartMath.cs create mode 100644 Source/Engines/EngineConfigChart.cs create mode 100644 Source/Engines/EngineConfigGUI.cs create mode 100644 Source/Engines/EngineConfigInfoPanel.cs create mode 100644 Source/Engines/EngineConfigIntegrations.cs create mode 100644 Source/Engines/EngineConfigPropellants.cs create mode 100644 Source/Engines/EngineConfigStyles.cs create mode 100644 Source/Engines/EngineConfigTechLevels.cs create mode 100644 Source/Engines/EngineConfigTextures.cs diff --git a/Source/Engines/ChartMath.cs b/Source/Engines/ChartMath.cs new file mode 100644 index 00000000..3092e1ab --- /dev/null +++ b/Source/Engines/ChartMath.cs @@ -0,0 +1,410 @@ +using System; +using UnityEngine; + +namespace RealFuels +{ + /// + /// Mathematical calculations for chart rendering and TestFlight curve evaluation. + /// All chart math extracted into static utility methods. + /// + public static class ChartMath + { + private const int CurvePoints = 100; + + #region Data Structures + + public struct SurvivalCurveData + { + public float[] SurvivalProbs; + public float[] SurvivalProbsEnd; + public float MinSurvivalProb; + } + + #endregion + + #region TestFlight Curve Building + + /// + /// Build the TestFlight cycle curve exactly as TestFlight_Generic_Engines.cfg does. + /// + public static FloatCurve BuildTestFlightCycleCurve(float ratedBurnTime, float testedBurnTime, float overburnPenalty, bool hasTestedBurnTime) + { + FloatCurve curve = new FloatCurve(); + + curve.Add(0.00f, 10.00f); + curve.Add(5.00f, 1.00f, -0.8f, 0f); + + float rbtCushioned = ratedBurnTime + 5f; + curve.Add(rbtCushioned, 1f, 0f, 0f); + + if (hasTestedBurnTime) + { + float ratedToTestedInterval = testedBurnTime - rbtCushioned; + float tbtTransitionSlope = 3.135f / ratedToTestedInterval * (overburnPenalty - 1.0f); + curve.Add(testedBurnTime, overburnPenalty, tbtTransitionSlope, tbtTransitionSlope); + + float failTime = testedBurnTime * 2.5f; + float tbtToFailInterval = failTime - testedBurnTime; + float failInSlope = 1.989f / tbtToFailInterval * (100f - overburnPenalty); + curve.Add(failTime, 100f, failInSlope, 0f); + } + else + { + float failTime = ratedBurnTime * 2.5f; + float rbtToFailInterval = failTime - rbtCushioned; + float failInSlope = 292.8f / rbtToFailInterval; + curve.Add(failTime, 100f, failInSlope, 0f); + } + + return curve; + } + + /// + /// Creates a TestFlight-style non-linear reliability curve that maps data units to reliability. + /// + public static FloatCurve CreateReliabilityCurve(float reliabilityStart, float reliabilityEnd) + { + FloatCurve curve = new FloatCurve(); + + float failChanceStart = 1f - reliabilityStart; + float failChanceEnd = 1f - reliabilityEnd; + + const float reliabilityMidV = 0.75f; + const float reliabilityMidH = 0.4f; + const float reliabilityMidTangentWeight = 0.5f; + const float maxData = 10000f; + + curve.Add(0f, failChanceStart); + + float key1X = reliabilityMidH * 5000f + 1000f; + float key1Y = failChanceStart + reliabilityMidV * (failChanceEnd - failChanceStart); + + float tangentPart1 = (failChanceEnd - failChanceStart) * 0.0001f * reliabilityMidTangentWeight; + float linearTangent = (failChanceEnd - key1Y) / (maxData - key1X); + float tangentPart2 = linearTangent * (1f - reliabilityMidTangentWeight); + float key1Tangent = tangentPart1 + tangentPart2; + + curve.Add(key1X, key1Y, key1Tangent, key1Tangent); + curve.Add(maxData, failChanceEnd, 0f, 0f); + + return curve; + } + + /// + /// Evaluates reliability at a given data value using TestFlight's non-linear curve. + /// + public static float EvaluateReliabilityAtData(float dataUnits, float reliabilityStart, float reliabilityEnd) + { + FloatCurve curve = CreateReliabilityCurve(reliabilityStart, reliabilityEnd); + float failChance = curve.Evaluate(dataUnits); + return 1f - failChance; + } + + #endregion + + #region Survival Calculation + + /// + /// Calculate survival curves for start and end reliability. + /// + public static SurvivalCurveData CalculateSurvivalCurves(float cycleReliabilityStart, float cycleReliabilityEnd, + float ratedBurnTime, FloatCurve cycleCurve, float maxTime, int clusterSize) + { + float[] survivalProbsStart = new float[CurvePoints]; + float[] survivalProbsEnd = new float[CurvePoints]; + float minSurvivalProb = 1f; + + float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; + float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; + + for (int i = 0; i < CurvePoints; i++) + { + float t = (i / (float)(CurvePoints - 1)) * maxTime; + + survivalProbsStart[i] = CalculateSurvivalProbAtTime(t, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve); + survivalProbsEnd[i] = CalculateSurvivalProbAtTime(t, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve); + + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsStart[i]); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsEnd[i]); + } + + // Apply cluster math + if (clusterSize > 1) + { + for (int i = 0; i < CurvePoints; i++) + { + survivalProbsStart[i] = Mathf.Pow(survivalProbsStart[i], clusterSize); + survivalProbsEnd[i] = Mathf.Pow(survivalProbsEnd[i], clusterSize); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsStart[i]); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsEnd[i]); + } + } + + float yAxisMin = RoundToNiceNumber(Mathf.Max(0f, minSurvivalProb - 0.02f), false); + + return new SurvivalCurveData + { + SurvivalProbs = survivalProbsStart, + SurvivalProbsEnd = survivalProbsEnd, + MinSurvivalProb = yAxisMin + }; + } + + /// + /// Calculate survival curve for a single reliability value. + /// + public static SurvivalCurveData CalculateSurvivalCurve(float cycleReliability, float ratedBurnTime, + FloatCurve cycleCurve, float maxTime, int clusterSize) + { + float[] survivalProbs = new float[CurvePoints]; + float minSurvivalProb = 1f; + + float baseRate = -Mathf.Log(cycleReliability) / ratedBurnTime; + + for (int i = 0; i < CurvePoints; i++) + { + float t = (i / (float)(CurvePoints - 1)) * maxTime; + survivalProbs[i] = CalculateSurvivalProbAtTime(t, ratedBurnTime, cycleReliability, baseRate, cycleCurve); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbs[i]); + } + + if (clusterSize > 1) + { + for (int i = 0; i < CurvePoints; i++) + { + survivalProbs[i] = Mathf.Pow(survivalProbs[i], clusterSize); + minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbs[i]); + } + } + + return new SurvivalCurveData + { + SurvivalProbs = survivalProbs, + SurvivalProbsEnd = new float[CurvePoints], + MinSurvivalProb = minSurvivalProb + }; + } + + /// + /// Calculate survival probability at a specific time. + /// + public static float CalculateSurvivalProbAtTime(float time, float ratedBurnTime, + float cycleReliability, float baseRate, FloatCurve cycleCurve) + { + if (time <= ratedBurnTime) + { + return Mathf.Pow(cycleReliability, time / ratedBurnTime); + } + else + { + float survivalToRated = cycleReliability; + float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, time, 20); + float additionalFailRate = baseRate * integratedModifier; + return Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); + } + } + + /// + /// Numerically integrate the cycle curve from t1 to t2 using trapezoidal rule. + /// + public static float IntegrateCycleCurve(FloatCurve curve, float t1, float t2, int steps) + { + if (t2 <= t1) return 0f; + + float dt = (t2 - t1) / steps; + float sum = 0f; + + for (int i = 0; i < steps; i++) + { + float tStart = t1 + i * dt; + float tEnd = tStart + dt; + float valueStart = curve.Evaluate(tStart); + float valueEnd = curve.Evaluate(tEnd); + sum += (valueStart + valueEnd) * 0.5f * dt; + } + + return sum; + } + + #endregion + + #region Coordinate Conversion + + /// + /// Convert time to x-position on the chart. + /// + public static float TimeToXPosition(float time, float maxTime, float plotX, float plotWidth, bool useLogScale) + { + if (useLogScale) + { + float logTime = Mathf.Log10(time + 1f); + float logMax = Mathf.Log10(maxTime + 1f); + return plotX + (logTime / logMax) * plotWidth; + } + else + { + return plotX + (time / maxTime) * plotWidth; + } + } + + /// + /// Convert x-position back to time. + /// + public static float XPositionToTime(float xPos, float maxTime, float plotX, float plotWidth, bool useLogScale) + { + float normalizedX = (xPos - plotX) / plotWidth; + normalizedX = Mathf.Clamp01(normalizedX); + + if (useLogScale) + { + float logMax = Mathf.Log10(maxTime + 1f); + return Mathf.Pow(10f, normalizedX * logMax) - 1f; + } + else + { + return normalizedX * maxTime; + } + } + + /// + /// Convert survival probability to y-position on the chart. + /// + public static float SurvivalProbToYPosition(float survivalProb, float yAxisMin, float plotY, float plotHeight, bool useLogScale) + { + if (useLogScale) + { + float logProb = Mathf.Log10(survivalProb + 0.0001f); + float logMax = Mathf.Log10(1f + 0.0001f); + float logMin = Mathf.Log10(yAxisMin + 0.0001f); + float normalizedLog = (logProb - logMin) / (logMax - logMin); + return plotY + plotHeight - (normalizedLog * plotHeight); + } + else + { + float normalizedSurvival = (survivalProb - yAxisMin) / (1f - yAxisMin); + return plotY + plotHeight - (normalizedSurvival * plotHeight); + } + } + + #endregion + + #region Formatting + + /// + /// Format time in seconds to human-readable string (xd xh xm xs). + /// + public static string FormatTime(float timeInSeconds) + { + if (timeInSeconds < 0.1f) return "0s"; + + int totalSeconds = Mathf.RoundToInt(timeInSeconds); + int days = totalSeconds / 86400; + int hours = (totalSeconds % 86400) / 3600; + int minutes = (totalSeconds % 3600) / 60; + int seconds = totalSeconds % 60; + + string result = ""; + if (days > 0) result += $"{days}d "; + if (hours > 0) result += $"{hours}h "; + if (minutes > 0) result += $"{minutes}m "; + if (seconds > 0 || string.IsNullOrEmpty(result)) result += $"{seconds}s"; + + return result.TrimEnd(); + } + + /// + /// Format MTBF (mean time between failures) in human-readable units. + /// + public static string FormatMTBF(float mtbfSeconds) + { + if (float.IsInfinity(mtbfSeconds) || float.IsNaN(mtbfSeconds)) + return "∞"; + + if (mtbfSeconds < 60f) return $"{mtbfSeconds:F1}s"; + if (mtbfSeconds < 3600f) return $"{mtbfSeconds / 60f:F1}m"; + if (mtbfSeconds < 86400f) return $"{mtbfSeconds / 3600f:F1}h"; + if (mtbfSeconds < 31536000f) return $"{mtbfSeconds / 86400f:F1}d"; + return $"{mtbfSeconds / 31536000f:F1}y"; + } + + #endregion + + #region Drawing Helpers + + /// + /// Draw a line between two points with rotation. + /// + public static void DrawLine(Vector2 start, Vector2 end, Texture2D texture, float width) + { + if (texture == null) return; + + Vector2 diff = end - start; + float angle = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg; + float length = diff.magnitude; + + Matrix4x4 matrixBackup = GUI.matrix; + try + { + GUIUtility.RotateAroundPivot(angle, start); + GUI.DrawTexture(new Rect(start.x, start.y - width / 2, length, width), texture); + } + finally + { + GUI.matrix = matrixBackup; + } + } + + /// + /// Draw a filled circle. + /// + public static void DrawCircle(Rect rect, Texture2D texture) + { + if (texture == null || Event.current.type != EventType.Repaint) return; + + float centerX = rect.x + rect.width / 2f; + float centerY = rect.y + rect.height / 2f; + float radius = rect.width / 2f; + + for (float r = radius; r > 0; r -= 0.5f) + { + float size = r * 2f; + GUI.DrawTexture(new Rect(centerX - r, centerY - r, size, size), texture); + } + } + + #endregion + + #region Utilities + + /// + /// Round a value to a "nice" number (1, 2, or 5 times a power of 10). + /// + public static float RoundToNiceNumber(float value, bool roundUp) + { + if (value <= 0f) return 0f; + + float exponent = Mathf.Floor(Mathf.Log10(value)); + float fraction = value / Mathf.Pow(10f, exponent); + + float niceFraction; + if (roundUp) + { + if (fraction <= 1f) niceFraction = 1f; + else if (fraction <= 2f) niceFraction = 2f; + else if (fraction <= 5f) niceFraction = 5f; + else niceFraction = 10f; + } + else + { + if (fraction < 1.5f) niceFraction = 1f; + else if (fraction < 3.5f) niceFraction = 2f; + else if (fraction < 7.5f) niceFraction = 5f; + else niceFraction = 10f; + } + + return niceFraction * Mathf.Pow(10f, exponent); + } + + #endregion + } +} diff --git a/Source/Engines/EngineConfigChart.cs b/Source/Engines/EngineConfigChart.cs new file mode 100644 index 00000000..0c161db8 --- /dev/null +++ b/Source/Engines/EngineConfigChart.cs @@ -0,0 +1,534 @@ +using System; +using UnityEngine; + +namespace RealFuels +{ + /// + /// Handles all chart rendering for engine configuration failure probability visualization. + /// Displays survival probability curves based on TestFlight data. + /// + public class EngineConfigChart + { + private readonly ModuleEngineConfigsBase _module; + private readonly EngineConfigTextures _textures; + + // Chart state + private bool _useLogScaleX = false; + private bool _useLogScaleY = false; + + // Simulation state + private bool _useSimulatedData = false; + private float _simulatedDataValue = 0f; + private int _clusterSize = 1; + private string _clusterSizeInput = "1"; + private string _dataValueInput = "0"; + + // Public properties for external access + public bool UseLogScaleX { get => _useLogScaleX; set => _useLogScaleX = value; } + public bool UseLogScaleY { get => _useLogScaleY; set => _useLogScaleY = value; } + public bool UseSimulatedData { get => _useSimulatedData; set => _useSimulatedData = value; } + public float SimulatedDataValue { get => _simulatedDataValue; set => _simulatedDataValue = value; } + public int ClusterSize { get => _clusterSize; set => _clusterSize = value; } + public string ClusterSizeInput { get => _clusterSizeInput; set => _clusterSizeInput = value; } + public string DataValueInput { get => _dataValueInput; set => _dataValueInput = value; } + + public EngineConfigChart(ModuleEngineConfigsBase module) + { + _module = module; + _textures = EngineConfigTextures.Instance; + } + + /// + /// Draws the failure probability chart and info panel side by side. + /// + public void Draw(ConfigNode configNode, float width, float height) + { + _textures.EnsureInitialized(); + EngineConfigStyles.Initialize(); + + // Values are copied to CONFIG level by ModuleManager patch + if (!configNode.HasValue("cycleReliabilityStart")) return; + if (!configNode.HasValue("cycleReliabilityEnd")) return; + if (!float.TryParse(configNode.GetValue("cycleReliabilityStart"), out float cycleReliabilityStart)) return; + if (!float.TryParse(configNode.GetValue("cycleReliabilityEnd"), out float cycleReliabilityEnd)) return; + + // Validate reliability is in valid range + if (cycleReliabilityStart <= 0f || cycleReliabilityStart > 1f) return; + if (cycleReliabilityEnd <= 0f || cycleReliabilityEnd > 1f) return; + + float ratedBurnTime = 0; + if (!configNode.TryGetValue("ratedBurnTime", ref ratedBurnTime) || ratedBurnTime <= 0) return; + + float ratedContinuousBurnTime = ratedBurnTime; + configNode.TryGetValue("ratedContinuousBurnTime", ref ratedContinuousBurnTime); + + // Skip chart if this is a cumulative-limited engine (continuous << total) + if (ratedContinuousBurnTime < ratedBurnTime * 0.9f) return; + + // Read testedBurnTime to match TestFlight's exact behavior + float testedBurnTime = 0f; + bool hasTestedBurnTime = configNode.TryGetValue("testedBurnTime", ref testedBurnTime) && testedBurnTime > ratedBurnTime; + + // Split the area: chart on left (58%), info on right (42%) + float chartWidth = width * 0.58f; + float infoWidth = width * 0.42f; + + float overburnPenalty = 2.0f; + configNode.TryGetValue("overburnPenalty", ref overburnPenalty); + + // Build the actual TestFlight cycle curve + FloatCurve cycleCurve = ChartMath.BuildTestFlightCycleCurve(ratedBurnTime, testedBurnTime, overburnPenalty, hasTestedBurnTime); + + // Main container + Rect containerRect = GUILayoutUtility.GetRect(width, height); + + // Chart area (left side) + const float padding = 38f; + float plotWidth = chartWidth - padding * 2; + float plotHeight = height - padding * 2; + + float maxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; + + Rect chartRect = new Rect(containerRect.x, containerRect.y, chartWidth, height); + Rect plotArea = new Rect(chartRect.x + padding, chartRect.y + padding, plotWidth, plotHeight); + + // Info panel area (right side) + Rect infoRect = new Rect(containerRect.x + chartWidth, containerRect.y, infoWidth, height); + + // Calculate survival curves + var curveData = ChartMath.CalculateSurvivalCurves( + cycleReliabilityStart, cycleReliabilityEnd, + ratedBurnTime, cycleCurve, maxTime, _clusterSize); + + // Get current data + float realCurrentData = TestFlightWrapper.GetCurrentFlightData(_module.part); + float realMaxData = TestFlightWrapper.GetMaximumData(_module.part); + float currentDataValue = _useSimulatedData ? _simulatedDataValue : realCurrentData; + float maxDataValue = realMaxData > 0f ? realMaxData : 10000f; + float dataPercentage = (maxDataValue > 0f) ? Mathf.Clamp01(currentDataValue / maxDataValue) : 0f; + bool hasCurrentData = (_useSimulatedData && currentDataValue >= 0f) || (realCurrentData >= 0f && realMaxData > 0f); + + float cycleReliabilityCurrent = 0f; + ChartMath.SurvivalCurveData currentCurveData = default; + + if (hasCurrentData) + { + cycleReliabilityCurrent = ChartMath.EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); + currentCurveData = ChartMath.CalculateSurvivalCurve( + cycleReliabilityCurrent, ratedBurnTime, cycleCurve, maxTime, _clusterSize); + } + + // Draw chart + DrawChartBackground(chartRect); + DrawChartZones(plotArea, ratedBurnTime, testedBurnTime, hasTestedBurnTime, maxTime, overburnPenalty); + DrawGrid(plotArea, curveData.MinSurvivalProb, maxTime); + DrawCurves(plotArea, curveData, currentCurveData, hasCurrentData, maxTime, curveData.MinSurvivalProb); + DrawAxisLabels(chartRect, plotArea, maxTime, curveData.MinSurvivalProb); + DrawLegend(plotArea, hasCurrentData); + DrawChartTooltip(plotArea, curveData, currentCurveData, hasCurrentData, + cycleReliabilityStart, cycleReliabilityCurrent, cycleReliabilityEnd, + ratedBurnTime, testedBurnTime, hasTestedBurnTime, maxTime, overburnPenalty, cycleCurve); + + // Draw info panel + DrawInfoPanel(infoRect, configNode, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, hasCurrentData, cycleReliabilityCurrent, + dataPercentage, currentDataValue, maxDataValue, realCurrentData, realMaxData); + } + + #region Chart Background & Zones + + private void DrawChartBackground(Rect chartRect) + { + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(chartRect, _textures.ChartBg); + } + + // Chart title + GUI.Label(new Rect(chartRect.x, chartRect.y + 4, chartRect.width, 24), + "Survival Probability vs Burn Time", EngineConfigStyles.ChartTitle); + } + + private void DrawChartZones(Rect plotArea, float ratedBurnTime, float testedBurnTime, + bool hasTestedBurnTime, float maxTime, float overburnPenalty) + { + // Zone boundaries + float startupEndX = ChartMath.TimeToXPosition(5f, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + float ratedCushionedX = ChartMath.TimeToXPosition(ratedBurnTime + 5f, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + float testedX = hasTestedBurnTime ? ChartMath.TimeToXPosition(testedBurnTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX) : 0f; + + float referenceBurnTime = hasTestedBurnTime ? testedBurnTime : ratedBurnTime; + float max100xTime = referenceBurnTime * 2.5f; + float max100xX = ChartMath.TimeToXPosition(max100xTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + + // Clamp to plot area + float plotAreaRight = plotArea.x + plotArea.width; + startupEndX = Mathf.Clamp(startupEndX, plotArea.x, plotAreaRight); + ratedCushionedX = Mathf.Clamp(ratedCushionedX, plotArea.x, plotAreaRight); + testedX = Mathf.Clamp(testedX, plotArea.x, plotAreaRight); + max100xX = Mathf.Clamp(max100xX, plotArea.x, plotAreaRight); + + if (Event.current.type != EventType.Repaint) return; + + // Draw zone backgrounds + DrawZoneRect(plotArea.x, startupEndX, plotArea, _textures.ChartStartupZone); + DrawZoneRect(startupEndX, ratedCushionedX, plotArea, _textures.ChartGreenZone); + + if (hasTestedBurnTime) + { + DrawZoneRect(ratedCushionedX, testedX, plotArea, _textures.ChartYellowZone); + DrawZoneRect(testedX, max100xX, plotArea, _textures.ChartRedZone); + DrawZoneRect(max100xX, plotAreaRight, plotArea, _textures.ChartDarkRedZone); + } + else + { + DrawZoneRect(ratedCushionedX, max100xX, plotArea, _textures.ChartRedZone); + DrawZoneRect(max100xX, plotAreaRight, plotArea, _textures.ChartDarkRedZone); + } + + // Draw zone markers + Vector2 mousePos = Event.current.mousePosition; + bool mouseInPlot = plotArea.Contains(mousePos); + + DrawZoneMarker(startupEndX, plotArea, _textures.ChartMarkerBlue, mouseInPlot, mousePos); + DrawZoneMarker(ratedCushionedX, plotArea, _textures.ChartMarkerGreen, mouseInPlot, mousePos); + if (hasTestedBurnTime) DrawZoneMarker(testedX, plotArea, _textures.ChartMarkerYellow, mouseInPlot, mousePos); + DrawZoneMarker(max100xX, plotArea, _textures.ChartMarkerDarkRed, mouseInPlot, mousePos); + } + + private void DrawZoneRect(float x1, float x2, Rect plotArea, Texture2D texture) + { + float width = Mathf.Max(0, x2 - x1); + if (width > 0) + GUI.DrawTexture(new Rect(x1, plotArea.y, width, plotArea.height), texture); + } + + private void DrawZoneMarker(float x, Rect plotArea, Texture2D texture, bool mouseInPlot, Vector2 mousePos) + { + if (x < plotArea.x || x > plotArea.x + plotArea.width) return; + + bool nearMarker = mouseInPlot && Mathf.Abs(mousePos.x - x) < 8f; + float lineWidth = nearMarker ? 4f : 1f; + GUI.DrawTexture(new Rect(x - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), texture); + } + + #endregion + + #region Grid & Axes + + private void DrawGrid(Rect plotArea, float yAxisMin, float maxTime) + { + GUIStyle labelStyle = EngineConfigStyles.GridLabel; + + if (_useLogScaleY) + { + float[] logValues = { 0.0001f, 0.001f, 0.01f, 0.1f, 1f }; + foreach (float survivalProb in logValues) + { + if (survivalProb < yAxisMin) continue; + float y = ChartMath.SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, _useLogScaleY); + DrawGridLine(plotArea.x, y, plotArea.width); + DrawYAxisLabel(plotArea.x, y, survivalProb); + } + } + else + { + for (int i = 0; i <= 10; i++) + { + bool isMajor = (i % 2 == 0); + float survivalProb = yAxisMin + (i / 10f) * (1f - yAxisMin); + float y = ChartMath.SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, _useLogScaleY); + + DrawGridLine(plotArea.x, y, plotArea.width, isMajor); + if (isMajor) DrawYAxisLabel(plotArea.x, y, survivalProb); + } + } + } + + private void DrawGridLine(float x, float y, float width, bool major = true) + { + if (Event.current.type != EventType.Repaint) return; + Rect lineRect = new Rect(x, y, width, 1); + GUI.DrawTexture(lineRect, major ? _textures.ChartGridMajor : _textures.ChartGridMinor); + } + + private void DrawYAxisLabel(float x, float y, float survivalProb) + { + float labelValue = survivalProb * 100f; + string label = labelValue < 1f ? $"{labelValue:F2}%" : + (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%"); + GUI.Label(new Rect(x - 35, y - 10, 30, 20), label, EngineConfigStyles.GridLabel); + } + + private void DrawAxisLabels(Rect chartRect, Rect plotArea, float maxTime, float yAxisMin) + { + // X-axis labels + GUIStyle timeStyle = EngineConfigStyles.TimeLabel; + + if (_useLogScaleX) + { + float[] logTimes = { 0.1f, 1f, 10f, 60f, 300f, 600f, 1800f, 3600f }; + foreach (float time in logTimes) + { + if (time > maxTime) break; + float x = ChartMath.TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), + ChartMath.FormatTime(time), timeStyle); + } + } + else + { + for (int i = 0; i <= 4; i++) + { + float time = (i / 4f) * maxTime; + float x = ChartMath.TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), + ChartMath.FormatTime(time), timeStyle); + } + } + } + + #endregion + + #region Curve Drawing + + private void DrawCurves(Rect plotArea, ChartMath.SurvivalCurveData startCurve, + ChartMath.SurvivalCurveData currentCurve, bool hasCurrentData, + float maxTime, float yAxisMin) + { + if (Event.current.type != EventType.Repaint) return; + + // Convert to screen positions + Vector2[] pointsStart = ConvertToScreenPoints(startCurve.SurvivalProbs, plotArea, maxTime, yAxisMin); + Vector2[] pointsEnd = ConvertToScreenPoints(startCurve.SurvivalProbsEnd, plotArea, maxTime, yAxisMin); + Vector2[] pointsCurrent = hasCurrentData ? ConvertToScreenPoints(currentCurve.SurvivalProbs, plotArea, maxTime, yAxisMin) : null; + + // Draw curves + DrawCurveLine(pointsStart, _textures.ChartOrangeLine, plotArea); + DrawCurveLine(pointsEnd, _textures.ChartGreenLine, plotArea); + if (hasCurrentData && pointsCurrent != null) + DrawCurveLine(pointsCurrent, _textures.ChartBlueLine, plotArea); + } + + private Vector2[] ConvertToScreenPoints(float[] survivalProbs, Rect plotArea, float maxTime, float yAxisMin) + { + int count = survivalProbs.Length; + Vector2[] points = new Vector2[count]; + + for (int i = 0; i < count; i++) + { + float t = (i / (float)(count - 1)) * maxTime; + float x = ChartMath.TimeToXPosition(t, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + float y = ChartMath.SurvivalProbToYPosition(survivalProbs[i], yAxisMin, plotArea.y, plotArea.height, _useLogScaleY); + + if (float.IsNaN(x) || float.IsNaN(y) || float.IsInfinity(x) || float.IsInfinity(y)) + { + x = plotArea.x; + y = plotArea.y + plotArea.height; + } + points[i] = new Vector2(x, y); + } + + return points; + } + + private void DrawCurveLine(Vector2[] points, Texture2D texture, Rect plotArea) + { + float plotAreaRight = plotArea.x + plotArea.width; + + for (int i = 0; i < points.Length - 1; i++) + { + // Skip segments outside plot area + if (points[i].x > plotAreaRight && points[i + 1].x > plotAreaRight) continue; + if (points[i].x < plotArea.x && points[i + 1].x < plotArea.x) continue; + + ChartMath.DrawLine(points[i], points[i + 1], texture, 2.5f); + } + } + + #endregion + + #region Legend + + private void DrawLegend(Rect plotArea, bool hasCurrentData) + { + GUIStyle legendStyle = EngineConfigStyles.Legend; + float legendWidth = 110f; + float legendX = plotArea.x + plotArea.width - legendWidth; + float legendY = plotArea.y + 5; + + // Orange circle and line for 0 data + ChartMath.DrawCircle(new Rect(legendX, legendY + 5, 8, 8), _textures.ChartOrangeLine); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 7, 15, 3), _textures.ChartOrangeLine); + GUI.Label(new Rect(legendX + 28, legendY, 80, 18), "0 Data", legendStyle); + + if (hasCurrentData) + { + ChartMath.DrawCircle(new Rect(legendX, legendY + 23, 8, 8), _textures.ChartBlueLine); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), _textures.ChartBlueLine); + GUI.Label(new Rect(legendX + 28, legendY + 18, 100, 18), "Current Data", legendStyle); + + ChartMath.DrawCircle(new Rect(legendX, legendY + 41, 8, 8), _textures.ChartGreenLine); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 43, 15, 3), _textures.ChartGreenLine); + GUI.Label(new Rect(legendX + 28, legendY + 36, 80, 18), "Max Data", legendStyle); + } + else + { + ChartMath.DrawCircle(new Rect(legendX, legendY + 23, 8, 8), _textures.ChartGreenLine); + GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), _textures.ChartGreenLine); + GUI.Label(new Rect(legendX + 28, legendY + 18, 80, 18), "Max Data", legendStyle); + } + } + + #endregion + + #region Tooltip + + private void DrawChartTooltip(Rect plotArea, ChartMath.SurvivalCurveData startCurve, + ChartMath.SurvivalCurveData currentCurve, bool hasCurrentData, + float cycleReliabilityStart, float cycleReliabilityCurrent, float cycleReliabilityEnd, + float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, + float maxTime, float overburnPenalty, FloatCurve cycleCurve) + { + Vector2 mousePos = Event.current.mousePosition; + if (!plotArea.Contains(mousePos)) return; + + // Draw hover line + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(mousePos.x, plotArea.y, 1, plotArea.height), _textures.ChartHoverLine); + } + + // Calculate tooltip content + float mouseT = ChartMath.XPositionToTime(mousePos.x, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + mouseT = Mathf.Clamp(mouseT, 0f, maxTime); + + string tooltipText = BuildTooltipText(mouseT, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityCurrent, cycleReliabilityEnd, + hasCurrentData, cycleCurve, maxTime, overburnPenalty); + + DrawTooltip(mousePos, tooltipText); + } + + private string BuildTooltipText(float time, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, + float cycleReliabilityStart, float cycleReliabilityCurrent, float cycleReliabilityEnd, + bool hasCurrentData, FloatCurve cycleCurve, float maxTime, float overburnPenalty) + { + // Determine zone + string zoneName; + string zoneColor; + + if (time <= 5f) + { + zoneName = "Engine Startup"; + zoneColor = "#6699CC"; + } + else if (time <= ratedBurnTime + 5f) + { + zoneName = "Rated Operation"; + zoneColor = "#66DD66"; + } + else if (hasTestedBurnTime && time <= testedBurnTime) + { + zoneName = "Tested Overburn"; + zoneColor = "#FFCC44"; + } + else if (time <= (hasTestedBurnTime ? testedBurnTime : ratedBurnTime) * 2.5f) + { + zoneName = "Severe Overburn"; + zoneColor = "#FF6666"; + } + else + { + zoneName = "Maximum Overburn"; + zoneColor = "#CC2222"; + } + + float cycleModifier = cycleCurve.Evaluate(time); + string valueColor = "#E6D68A"; + string timeStr = ChartMath.FormatTime(time); + + // Calculate survival probabilities at this time + float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; + float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; + float baseRateCurrent = hasCurrentData ? -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime : 0f; + + float surviveStart = ChartMath.CalculateSurvivalProbAtTime(time, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve); + float surviveEnd = ChartMath.CalculateSurvivalProbAtTime(time, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve); + float surviveCurrent = hasCurrentData ? ChartMath.CalculateSurvivalProbAtTime(time, ratedBurnTime, cycleReliabilityCurrent, baseRateCurrent, cycleCurve) : 0f; + + // Apply cluster math + if (_clusterSize > 1) + { + surviveStart = Mathf.Pow(surviveStart, _clusterSize); + surviveEnd = Mathf.Pow(surviveEnd, _clusterSize); + if (hasCurrentData) surviveCurrent = Mathf.Pow(surviveCurrent, _clusterSize); + } + + string orangeColor = "#FF8033"; + string blueColor = "#7DD9FF"; + string greenColor = "#4DE64D"; + string entityName = _clusterSize > 1 ? "cluster" : "engine"; + + string tooltip = $"{zoneName}\n\n"; + tooltip += $"This {entityName} has a "; + + if (hasCurrentData) + tooltip += $"{surviveStart * 100f:F1}% / {surviveCurrent * 100f:F1}% / {surviveEnd * 100f:F1}%"; + else + tooltip += $"{surviveStart * 100f:F1}% / {surviveEnd * 100f:F1}%"; + + tooltip += $" chance to survive to {timeStr}\n\n"; + tooltip += $"Cycle modifier: {cycleModifier:F2}×"; + + return tooltip; + } + + private void DrawTooltip(Vector2 mousePos, string text) + { + if (string.IsNullOrEmpty(text)) return; + + GUIStyle tooltipStyle = EngineConfigStyles.ChartTooltip; + tooltipStyle.normal.background = _textures.ChartTooltipBg; + + GUIContent content = new GUIContent(text); + Vector2 size = tooltipStyle.CalcSize(content); + + float tooltipX = mousePos.x + 15; + float tooltipY = mousePos.y + 15; + + if (tooltipX + size.x > Screen.width) tooltipX = mousePos.x - size.x - 5; + if (tooltipY + size.y > Screen.height) tooltipY = mousePos.y - size.y - 5; + + Rect tooltipRect = new Rect(tooltipX, tooltipY, size.x, size.y); + GUI.Box(tooltipRect, content, tooltipStyle); + } + + #endregion + + #region Info Panel Integration + + private void DrawInfoPanel(Rect rect, ConfigNode configNode, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, + float cycleReliabilityStart, float cycleReliabilityEnd, bool hasCurrentData, float cycleReliabilityCurrent, + float dataPercentage, float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData) + { + float ignitionReliabilityStart = 1f; + float ignitionReliabilityEnd = 1f; + configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); + configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); + + float ignitionReliabilityCurrent = hasCurrentData ? + ChartMath.EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd) : 0f; + + var infoPanel = new EngineConfigInfoPanel(_module); + infoPanel.Draw(rect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, + hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, + currentDataValue, maxDataValue, realCurrentData, realMaxData, + ref _useSimulatedData, ref _simulatedDataValue, ref _clusterSize, + ref _clusterSizeInput, ref _dataValueInput); + } + + #endregion + } +} diff --git a/Source/Engines/EngineConfigGUI.cs b/Source/Engines/EngineConfigGUI.cs new file mode 100644 index 00000000..a73fb7d4 --- /dev/null +++ b/Source/Engines/EngineConfigGUI.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEngine; +using KSP.Localization; + +namespace RealFuels +{ + /// + /// Handles all GUI rendering for ModuleEngineConfigs. + /// Manages the configuration selector window, column visibility, tooltips, and user interaction. + /// + public class EngineConfigGUI + { + private readonly ModuleEngineConfigsBase _module; + private readonly EngineConfigTechLevels _techLevels; + private readonly EngineConfigTextures _textures; + private EngineConfigChart _chart; + + // GUI state + private static Vector3 mousePos = Vector3.zero; + private static Rect guiWindowRect = new Rect(0, 0, 0, 0); + private static uint lastPartId = 0; + private static int lastConfigCount = 0; + private static bool lastCompactView = false; + private static bool lastHasChart = false; + private string myToolTip = string.Empty; + private int counterTT; + private bool editorLocked = false; + + private Vector2 configScrollPos = Vector2.zero; + private GUIContent configGuiContent; + private bool compactView = false; + private bool useLogScaleX = false; + private bool useLogScaleY = false; + + // Column visibility customization + private bool showColumnMenu = false; + private static Rect columnMenuRect = new Rect(100, 100, 280, 650); + private static bool[] columnsVisibleFull = new bool[18]; + private static bool[] columnsVisibleCompact = new bool[18]; + private static bool columnVisibilityInitialized = false; + + // Simulation controls + private bool useSimulatedData = false; + private float simulatedDataValue = 0f; + private int clusterSize = 1; + private string clusterSizeInput = "1"; + private string dataValueInput = "0"; + + private const int ConfigRowHeight = 22; + private const int ConfigMaxVisibleRows = 16; + private float[] ConfigColumnWidths = new float[18]; + + private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; + private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); + + public EngineConfigGUI(ModuleEngineConfigsBase module) + { + _module = module; + _techLevels = new EngineConfigTechLevels(module); + _textures = EngineConfigTextures.Instance; + } + + private EngineConfigChart Chart + { + get + { + if (_chart == null) + { + _chart = new EngineConfigChart(_module); + _chart.UseLogScaleX = useLogScaleX; + _chart.UseLogScaleY = useLogScaleY; + _chart.UseSimulatedData = useSimulatedData; + _chart.SimulatedDataValue = simulatedDataValue; + _chart.ClusterSize = clusterSize; + } + return _chart; + } + } + + #region Main GUI Entry Point + + public void OnGUI() + { + // GUI rendering code will go here + // This is a placeholder - will be filled with actual implementation + } + + #endregion + + #region Helper Methods + + internal void MarkWindowDirty() + { + lastPartId = 0; + } + + private void EditorLock() + { + if (!editorLocked) + { + EditorLogic.fetch.Lock(true, true, true, _module.GetInstanceID().ToString()); + editorLocked = true; + } + } + + private void EditorUnlock() + { + if (editorLocked) + { + EditorLogic.fetch.Unlock(_module.GetInstanceID().ToString()); + editorLocked = false; + } + } + + #endregion + } +} diff --git a/Source/Engines/EngineConfigInfoPanel.cs b/Source/Engines/EngineConfigInfoPanel.cs new file mode 100644 index 00000000..082b7394 --- /dev/null +++ b/Source/Engines/EngineConfigInfoPanel.cs @@ -0,0 +1,399 @@ +using System; +using UnityEngine; + +namespace RealFuels +{ + /// + /// Handles the info panel display showing reliability stats, data gains, and simulation controls. + /// + public class EngineConfigInfoPanel + { + private readonly ModuleEngineConfigsBase _module; + private readonly EngineConfigTextures _textures; + + public EngineConfigInfoPanel(ModuleEngineConfigsBase module) + { + _module = module; + _textures = EngineConfigTextures.Instance; + } + + public void Draw(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, + float cycleReliabilityStart, float cycleReliabilityEnd, float ignitionReliabilityStart, float ignitionReliabilityEnd, + bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage, + float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData, + ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, + ref string clusterSizeInput, ref string dataValueInput) + { + // Draw background + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(rect, _textures.InfoPanelBg); + } + + float yPos = rect.y + 4; + + // Draw reliability section + yPos = DrawReliabilitySection(rect, yPos, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, + hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, + dataPercentage, currentDataValue, maxDataValue, clusterSize); + + // Separator + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), _textures.ChartSeparator); + } + yPos += 10; + + // Side-by-side: Data Gains (left) and Controls (right) + yPos = DrawSideBySideSection(rect, yPos, ratedBurnTime, maxDataValue, realCurrentData, realMaxData, + ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput); + + // Bottom separator + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), _textures.ChartSeparator); + } + yPos += 10; + + // Failure rate summary + DrawFailureRateSummary(rect, yPos, ratedBurnTime, currentDataValue, cycleReliabilityStart, + cycleReliabilityEnd, clusterSize); + } + + #region Reliability Section + + private float DrawReliabilitySection(Rect rect, float yPos, + float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, + float cycleReliabilityStart, float cycleReliabilityEnd, + float ignitionReliabilityStart, float ignitionReliabilityEnd, + bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, + float dataPercentage, float currentDataValue, float maxDataValue, int clusterSize) + { + // Calculate success probabilities + float ratedSuccessStart = cycleReliabilityStart * 100f; + float ratedSuccessEnd = cycleReliabilityEnd * 100f; + float ignitionSuccessStart = ignitionReliabilityStart * 100f; + float ignitionSuccessEnd = ignitionReliabilityEnd * 100f; + + float testedSuccessStart = 0f; + float testedSuccessEnd = 0f; + if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) + { + float testedRatio = testedBurnTime / ratedBurnTime; + testedSuccessStart = Mathf.Pow(cycleReliabilityStart, testedRatio) * 100f; + testedSuccessEnd = Mathf.Pow(cycleReliabilityEnd, testedRatio) * 100f; + } + + float ratedSuccessCurrent = 0f; + float testedSuccessCurrent = 0f; + float ignitionSuccessCurrent = 0f; + if (hasCurrentData) + { + ratedSuccessCurrent = cycleReliabilityCurrent * 100f; + ignitionSuccessCurrent = ignitionReliabilityCurrent * 100f; + if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) + { + float testedRatio = testedBurnTime / ratedBurnTime; + testedSuccessCurrent = Mathf.Pow(cycleReliabilityCurrent, testedRatio) * 100f; + } + } + + // Apply cluster math + if (clusterSize > 1) + { + ignitionSuccessStart = Mathf.Pow(ignitionSuccessStart / 100f, clusterSize) * 100f; + ignitionSuccessEnd = Mathf.Pow(ignitionSuccessEnd / 100f, clusterSize) * 100f; + ratedSuccessStart = Mathf.Pow(ratedSuccessStart / 100f, clusterSize) * 100f; + ratedSuccessEnd = Mathf.Pow(ratedSuccessEnd / 100f, clusterSize) * 100f; + testedSuccessStart = Mathf.Pow(testedSuccessStart / 100f, clusterSize) * 100f; + testedSuccessEnd = Mathf.Pow(testedSuccessEnd / 100f, clusterSize) * 100f; + + if (hasCurrentData) + { + ignitionSuccessCurrent = Mathf.Pow(ignitionSuccessCurrent / 100f, clusterSize) * 100f; + ratedSuccessCurrent = Mathf.Pow(ratedSuccessCurrent / 100f, clusterSize) * 100f; + testedSuccessCurrent = Mathf.Pow(testedSuccessCurrent / 100f, clusterSize) * 100f; + } + } + + // Color codes + string orangeColor = "#FF8033"; + string blueColor = "#7DD9FF"; + string greenColor = "#4DE64D"; + string valueColor = "#E6D68A"; + + // Header + string headerText = $"At Starting"; + if (hasCurrentData) + { + string dataLabel = maxDataValue > 0f ? $"{currentDataValue:F0} du" : $"{dataPercentage * 100f:F0}%"; + headerText += $" / Current ({dataLabel})"; + } + headerText += $" / Max:"; + + GUIStyle sectionStyle = EngineConfigStyles.InfoSection; + sectionStyle.normal.textColor = Color.white; + GUI.Label(new Rect(rect.x, yPos, rect.width, 20), headerText, sectionStyle); + yPos += 24; + + // Build narrative + string engineText = clusterSize > 1 ? $"A cluster of {clusterSize} engines" : "This engine"; + string forAll = clusterSize > 1 ? " for all" : ""; + string combinedText = $"{engineText} has a "; + + // Ignition success rates + if (hasCurrentData) + combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessCurrent:F1}% / {ignitionSuccessEnd:F1}%"; + else + combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessEnd:F1}%"; + + combinedText += $" chance{forAll} to ignite, then a "; + + // Rated burn success rates + if (hasCurrentData) + combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessCurrent:F1}% / {ratedSuccessEnd:F1}%"; + else + combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessEnd:F1}%"; + + combinedText += $" chance{forAll} to burn for {ChartMath.FormatTime(ratedBurnTime)} (rated)"; + + // Tested burn success rates + if (hasTestedBurnTime) + { + combinedText += ", and a "; + if (hasCurrentData) + combinedText += $"{testedSuccessStart:F1}% / {testedSuccessCurrent:F1}% / {testedSuccessEnd:F1}%"; + else + combinedText += $"{testedSuccessStart:F1}% / {testedSuccessEnd:F1}%"; + + combinedText += $" chance{forAll} to burn to {ChartMath.FormatTime(testedBurnTime)} (tested)"; + } + combinedText += "."; + + GUIStyle textStyle = EngineConfigStyles.InfoText; + float combinedHeight = textStyle.CalcHeight(new GUIContent(combinedText), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, combinedHeight), combinedText, textStyle); + yPos += combinedHeight + 12; + + return yPos; + } + + #endregion + + #region Side-by-Side Section + + private float DrawSideBySideSection(Rect rect, float yPos, float ratedBurnTime, float maxDataValue, + float realCurrentData, float realMaxData, + ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, + ref string clusterSizeInput, ref string dataValueInput) + { + float columnStartY = yPos; + float leftColumnWidth = rect.width * 0.5f; + float rightColumnWidth = rect.width * 0.5f; + float leftColumnX = rect.x; + float rightColumnX = rect.x + leftColumnWidth; + + // Draw left column: Data Gains + float leftColumnEndY = DrawDataGainsSection(leftColumnX, leftColumnWidth, columnStartY, ratedBurnTime); + + // Draw right column: Simulation Controls + float rightColumnEndY = DrawSimulationControls(rightColumnX, rightColumnWidth, columnStartY, + maxDataValue, realCurrentData, realMaxData, + ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput); + + // Draw vertical separator + if (Event.current.type == EventType.Repaint) + { + float separatorX = rect.x + leftColumnWidth; + float separatorHeight = Mathf.Max(leftColumnEndY, rightColumnEndY) - columnStartY; + GUI.DrawTexture(new Rect(separatorX, columnStartY, 1, separatorHeight), _textures.ChartSeparator); + } + + return Mathf.Max(leftColumnEndY, rightColumnEndY) + 8; + } + + private float DrawDataGainsSection(float x, float width, float yPos, float ratedBurnTime) + { + string purpleColor = "#CCB3FF"; + + float ratedContinuousBurnTime = ratedBurnTime; + float dataRate = 640f / ratedContinuousBurnTime; + + // Section header + GUIStyle sectionStyle = EngineConfigStyles.InfoSection; + sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); + GUI.Label(new Rect(x, yPos, width, 20), "How To Gain Data:", sectionStyle); + yPos += 24; + + GUIStyle bulletStyle = EngineConfigStyles.Bullet; + GUIStyle indentedBulletStyle = EngineConfigStyles.IndentedBullet; + GUIStyle footerStyle = EngineConfigStyles.Footer; + float bulletHeight = 18; + + // Failures section + GUI.Label(new Rect(x, yPos, width, bulletHeight), "An engine can fail in 4 ways:", bulletStyle); + yPos += bulletHeight; + + string[] failureTypes = { "Shutdown", "Perf. Loss", "Reduced Thrust", "Explode" }; + int[] failureDu = { 1000, 800, 700, 1000 }; + float[] failurePercents = { 55.2f, 27.6f, 13.8f, 3.4f }; + + for (int i = 0; i < failureTypes.Length; i++) + { + string failText = $" ({failurePercents[i]:F0}%) {failureTypes[i]} +{failureDu[i]} du"; + GUI.Label(new Rect(x, yPos, width, bulletHeight), failText, indentedBulletStyle); + yPos += bulletHeight; + } + + yPos += 4; + + // Running gains + string runningText = $"Running gains {dataRate:F1} du/s"; + GUI.Label(new Rect(x, yPos, width, bulletHeight), runningText, bulletStyle); + yPos += bulletHeight; + + // Ignition failure + string ignitionText = $"Ignition Fail +1000 du"; + GUI.Label(new Rect(x, yPos, width, bulletHeight), ignitionText, bulletStyle); + yPos += bulletHeight + 8; + + // Footer + string footerText = "(no more than 1000 du per flight)"; + GUI.Label(new Rect(x, yPos, width, bulletHeight), footerText, footerStyle); + yPos += bulletHeight; + + return yPos; + } + + private float DrawSimulationControls(float x, float width, float yPos, float maxDataValue, + float realCurrentData, float realMaxData, + ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, + ref string clusterSizeInput, ref string dataValueInput) + { + bool hasRealData = realCurrentData >= 0f && realMaxData > 0f; + + GUIStyle sectionStyle = EngineConfigStyles.InfoSection; + sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); + GUI.Label(new Rect(x, yPos, width, 20), "Simulate:", sectionStyle); + yPos += 24; + + GUIStyle buttonStyle = EngineConfigStyles.CompactButton; + var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + GUIStyle controlStyle = EngineConfigStyles.Control; + + // Reset button + float resetBtnWidth = width - 16; + string resetButtonText = hasRealData ? $"Set to Current du ({realCurrentData:F0})" : "Set to Current du (0)"; + if (GUI.Button(new Rect(x + 8, yPos, resetBtnWidth, 20), resetButtonText, buttonStyle)) + { + if (hasRealData) + { + simulatedDataValue = realCurrentData; + dataValueInput = $"{realCurrentData:F0}"; + useSimulatedData = false; + } + else + { + simulatedDataValue = 0f; + dataValueInput = "0"; + useSimulatedData = true; + } + clusterSize = 1; + clusterSizeInput = "1"; + } + yPos += 24; + + // Data slider + float btnWidth = width - 16; + GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Data (du)", controlStyle); + yPos += 16; + + if (!useSimulatedData) + { + simulatedDataValue = hasRealData ? realCurrentData : 0f; + dataValueInput = $"{simulatedDataValue:F0}"; + } + + simulatedDataValue = GUI.HorizontalSlider(new Rect(x + 8, yPos, btnWidth - 50, 16), + simulatedDataValue, 0f, maxDataValue, GUI.skin.horizontalSlider, GUI.skin.horizontalSliderThumb); + + if (hasRealData && Mathf.Abs(simulatedDataValue - realCurrentData) > 0.1f) + useSimulatedData = true; + else if (!hasRealData && simulatedDataValue > 0.1f) + useSimulatedData = true; + + dataValueInput = $"{simulatedDataValue:F0}"; + GUI.SetNextControlName("dataValueInput"); + string newDataInput = GUI.TextField(new Rect(x + btnWidth - 35, yPos - 2, 40, 20), + dataValueInput, 6, inputStyle); + + if (newDataInput != dataValueInput) + { + dataValueInput = newDataInput; + if (GUI.GetNameOfFocusedControl() == "dataValueInput" && float.TryParse(dataValueInput, out float inputDataValue)) + { + inputDataValue = Mathf.Clamp(inputDataValue, 0f, maxDataValue); + simulatedDataValue = inputDataValue; + useSimulatedData = true; + } + } + yPos += 24; + + // Cluster slider + GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Cluster", controlStyle); + yPos += 16; + + clusterSize = Mathf.RoundToInt(GUI.HorizontalSlider(new Rect(x + 8, yPos, btnWidth - 50, 16), + clusterSize, 1f, 100f, GUI.skin.horizontalSlider, GUI.skin.horizontalSliderThumb)); + + clusterSizeInput = clusterSize.ToString(); + GUI.SetNextControlName("clusterSizeInput"); + string newClusterInput = GUI.TextField(new Rect(x + btnWidth - 35, yPos - 2, 40, 20), + clusterSizeInput, 3, inputStyle); + + if (newClusterInput != clusterSizeInput) + { + clusterSizeInput = newClusterInput; + if (GUI.GetNameOfFocusedControl() == "clusterSizeInput" && int.TryParse(clusterSizeInput, out int inputCluster)) + { + inputCluster = Mathf.Clamp(inputCluster, 1, 100); + clusterSize = inputCluster; + clusterSizeInput = clusterSize.ToString(); + } + } + yPos += 24; + + return yPos; + } + + #endregion + + #region Failure Rate Summary + + private void DrawFailureRateSummary(Rect rect, float yPos, float ratedBurnTime, float currentDataValue, + float cycleReliabilityStart, float cycleReliabilityEnd, int clusterSize) + { + float cycleReliabilityAtCurrentData = ChartMath.EvaluateReliabilityAtData(currentDataValue, + cycleReliabilityStart, cycleReliabilityEnd); + + if (clusterSize > 1) + { + cycleReliabilityAtCurrentData = Mathf.Pow(cycleReliabilityAtCurrentData, clusterSize); + } + + float failureRate = 1f - cycleReliabilityAtCurrentData; + float oneInX = failureRate > 0.0001f ? (1f / failureRate) : 9999f; + + string valueColor = "#E6D68A"; + string failureRateColor = "#FF6666"; + string failureText = $"With {currentDataValue:F0} du, 1 in {oneInX:F1} rated burns will fail ({ChartMath.FormatTime(ratedBurnTime)})"; + + GUIStyle failureRateStyle = EngineConfigStyles.FailureRate; + float failureTextHeight = failureRateStyle.CalcHeight(new GUIContent(failureText), rect.width); + GUI.Label(new Rect(rect.x, yPos, rect.width, failureTextHeight), failureText, failureRateStyle); + } + + #endregion + } +} diff --git a/Source/Engines/EngineConfigIntegrations.cs b/Source/Engines/EngineConfigIntegrations.cs new file mode 100644 index 00000000..ee19ccb0 --- /dev/null +++ b/Source/Engines/EngineConfigIntegrations.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; + +namespace RealFuels +{ + /// + /// Handles integration with B9PartSwitch and TestFlight mods. + /// Manages B9PS module linking and TestFlight interop values. + /// + public class EngineConfigIntegrations + { + private readonly ModuleEngineConfigsBase _module; + + // B9PartSwitch reflection fields + protected static bool _b9psReflectionInitialized = false; + protected static FieldInfo B9PS_moduleID; + protected static MethodInfo B9PS_SwitchSubtype; + protected static FieldInfo B9PS_switchInFlight; + + public Dictionary B9PSModules; + protected Dictionary RequestedB9PSVariants = new Dictionary(); + + public EngineConfigIntegrations(ModuleEngineConfigsBase module) + { + _module = module; + InitializeB9PSReflection(); + } + + #region TestFlight Integration + + /// + /// Updates TestFlight interop values with current configuration. + /// + public void UpdateTFInterops() + { + TestFlightWrapper.AddInteropValue(_module.part, _module.isMaster ? "engineConfig" : "vernierConfig", _module.configuration, "RealFuels"); + } + + #endregion + + #region B9PartSwitch Integration + + /// + /// Initializes reflection for B9PartSwitch if available. + /// + private void InitializeB9PSReflection() + { + if (_b9psReflectionInitialized || !Utilities.B9PSFound) return; + B9PS_moduleID = Type.GetType("B9PartSwitch.CustomPartModule, B9PartSwitch")?.GetField("moduleID"); + B9PS_SwitchSubtype = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetMethod("SwitchSubtype"); + B9PS_switchInFlight = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetField("switchInFlight"); + _b9psReflectionInitialized = true; + } + + /// + /// Loads all B9PartSwitch modules that are linked to configs. + /// + public void LoadB9PSModules() + { + IEnumerable b9psModuleIDs = _module.configs + .Where(cfg => cfg.HasNode("LinkB9PSModule")) + .SelectMany(cfg => cfg.GetNodes("LinkB9PSModule")) + .Select(link => link?.GetValue("name")) + .Where(moduleID => moduleID != null) + .Distinct(); + + B9PSModules = new Dictionary(b9psModuleIDs.Count()); + + foreach (string moduleID in b9psModuleIDs) + { + var module = ModuleEngineConfigsBase.GetSpecifiedModules(_module.part, string.Empty, -1, "ModuleB9PartSwitch", false) + .FirstOrDefault(m => (string)B9PS_moduleID?.GetValue(m) == moduleID); + if (module == null) + Debug.LogError($"*RFMEC* B9PartSwitch module with ID {moduleID} was not found for {_module.part}!"); + else + B9PSModules[moduleID] = module; + } + } + + /// + /// Hide the GUI for all `ModuleB9PartSwitch`s managed by RF. + /// This is somewhat of a hack-ish approach... + /// + public void HideB9PSVariantSelectors() + { + if (B9PSModules == null) return; + foreach (var module in B9PSModules.Values) + { + module.Fields["currentSubtypeTitle"].guiActive = false; + module.Fields["currentSubtypeTitle"].guiActiveEditor = false; + module.Fields["currentSubtypeIndex"].guiActive = false; + module.Fields["currentSubtypeIndex"].guiActiveEditor = false; + module.Events["ShowSubtypesWindow"].guiActive = false; + module.Events["ShowSubtypesWindow"].guiActiveEditor = false; + } + } + + /// + /// Coroutine to hide B9PS in-flight selector after a frame delay. + /// + private IEnumerator HideB9PSInFlightSelector_Coroutine(PartModule module) + { + yield return null; + module.Events["ShowSubtypesWindow"].guiActive = false; + } + + /// + /// Requests B9PS variant changes for the given config. + /// + public void RequestB9PSVariantsForConfig(ConfigNode node) + { + if (B9PSModules == null || B9PSModules.Count == 0) return; + RequestedB9PSVariants.Clear(); + if (node.GetNodes("LinkB9PSModule") is ConfigNode[] links) + { + foreach (ConfigNode link in links) + { + string moduleID = null, subtype = null; + if (!link.TryGetValue("name", ref moduleID)) + Debug.LogError($"*RFMEC* Config `{_module.configurationDisplay}` of {_module.part} has a LinkB9PSModule specification without a name key!"); + if (!link.TryGetValue("subtype", ref subtype)) + Debug.LogError($"*RFMEC* Config `{_module.configurationDisplay}` of {_module.part} has a LinkB9PSModule specification without a subtype key!"); + if (moduleID != null && subtype != null) + RequestedB9PSVariants[moduleID] = subtype; + } + } + _module.StartCoroutine(ApplyRequestedB9PSVariants_Coroutine()); + } + + /// + /// Coroutine that applies requested B9PS variant changes after a frame delay. + /// + protected IEnumerator ApplyRequestedB9PSVariants_Coroutine() + { + yield return new WaitForEndOfFrame(); + + if (RequestedB9PSVariants.Count == 0) yield break; + + foreach (var entry in B9PSModules) + { + string moduleID = entry.Key; + PartModule module = entry.Value; + + if (HighLogic.LoadedSceneIsFlight + && B9PS_switchInFlight != null + && !(bool)B9PS_switchInFlight.GetValue(module)) continue; + + if (!RequestedB9PSVariants.TryGetValue(moduleID, out string subtypeName)) + { + Debug.LogError($"*RFMEC* Config {_module.configurationDisplay} of {_module.part} does not specify a subtype for linked B9PS module with ID {moduleID}; defaulting to `{_module.configuration}`."); + subtypeName = _module.configuration; + } + + B9PS_SwitchSubtype?.Invoke(module, new object[] { subtypeName }); + if (HighLogic.LoadedSceneIsFlight) _module.StartCoroutine(HideB9PSInFlightSelector_Coroutine(module)); + } + + RequestedB9PSVariants.Clear(); + // Clear symmetry counterparts' queues since B9PS already handles symmetry. + _module.DoForEachSymmetryCounterpart(mec => mec.Integrations.ClearRequestedB9PSVariants()); + } + + /// + /// Clears the requested B9PS variants queue. + /// + public void ClearRequestedB9PSVariants() + { + RequestedB9PSVariants.Clear(); + } + + /// + /// Updates B9PS variants based on current config. + /// + public void UpdateB9PSVariants() => RequestB9PSVariantsForConfig(_module.config); + + #endregion + } +} diff --git a/Source/Engines/EngineConfigPropellants.cs b/Source/Engines/EngineConfigPropellants.cs new file mode 100644 index 00000000..f85681e6 --- /dev/null +++ b/Source/Engines/EngineConfigPropellants.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; +using KSP.UI.Screens; + +namespace RealFuels +{ + /// + /// Static utility class for propellant and resource operations. + /// Contains pure functions for clearing float curves, propellant gauges, and RCS propellants. + /// + public static class EngineConfigPropellants + { + private static readonly FieldInfo MRCSConsumedResources = typeof(ModuleRCS).GetField("consumedResources", BindingFlags.NonPublic | BindingFlags.Instance); + + /// + /// Clears all FloatCurves that need to be cleared based on config node or tech level. + /// + public static void ClearFloatCurves(Type mType, PartModule pm, ConfigNode cfg, int techLevel) + { + // clear all FloatCurves we need to clear (i.e. if our config has one, or techlevels are enabled) + bool delAtmo = cfg.HasNode("atmosphereCurve") || techLevel >= 0; + bool delDens = cfg.HasNode("atmCurve") || techLevel >= 0; + bool delVel = cfg.HasNode("velCurve") || techLevel >= 0; + foreach (FieldInfo field in mType.GetFields()) + { + if (field.FieldType == typeof(FloatCurve) && + ((field.Name.Equals("atmosphereCurve") && delAtmo) + || (field.Name.Equals("atmCurve") && delDens) + || (field.Name.Equals("velCurve") && delVel))) + { + field.SetValue(pm, new FloatCurve()); + } + } + } + + /// + /// Clears propellant gauges from the staging icon. + /// + public static void ClearPropellantGauges(Type mType, PartModule pm) + { + foreach (FieldInfo field in mType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (field.FieldType == typeof(Dictionary) && + field.GetValue(pm) is Dictionary boxes) + { + foreach (ProtoStageIconInfo v in boxes.Values) + { + try + { + if (v is ProtoStageIconInfo) + pm.part.stackIcon.RemoveInfo(v); + } + catch (Exception e) + { + Debug.LogError("*RFMEC* Trying to remove info box: " + e.Message); + } + } + boxes.Clear(); + } + } + } + + /// + /// Clears RCS propellants and reloads them from config. + /// + /// The part containing RCS modules + /// The configuration node to load propellants from + /// Action to execute DoConfig on the configuration + public static void ClearRCSPropellants(Part part, ConfigNode cfg, Action doConfigAction) + { + List RCSModules = part.Modules.OfType().ToList(); + if (RCSModules.Count > 0) + { + doConfigAction(cfg); + foreach (var rcsModule in RCSModules) + { + if (cfg.HasNode("PROPELLANT")) + rcsModule.propellants.Clear(); + rcsModule.Load(cfg); + List res = MRCSConsumedResources.GetValue(rcsModule) as List; + res.Clear(); + foreach (Propellant p in rcsModule.propellants) + res.Add(p.resourceDef); + } + } + } + } +} diff --git a/Source/Engines/EngineConfigStyles.cs b/Source/Engines/EngineConfigStyles.cs new file mode 100644 index 00000000..9e43c643 --- /dev/null +++ b/Source/Engines/EngineConfigStyles.cs @@ -0,0 +1,245 @@ +using System; +using UnityEngine; + +namespace RealFuels +{ + /// + /// Cached GUIStyle objects to prevent allocation every frame. + /// Styles are initialized once and reused across all GUI rendering. + /// + public static class EngineConfigStyles + { + private static bool _initialized = false; + + // Header styles + public static GUIStyle HeaderCell { get; private set; } + public static GUIStyle HeaderCellHover { get; private set; } + + // Row styles + public static GUIStyle RowPrimary { get; private set; } + public static GUIStyle RowPrimaryHover { get; private set; } + public static GUIStyle RowPrimaryLocked { get; private set; } + public static GUIStyle RowSecondary { get; private set; } + + // Button styles + public static GUIStyle SmallButton { get; private set; } + public static GUIStyle CompactButton { get; private set; } + + // Label styles + public static GUIStyle TimeLabel { get; private set; } + public static GUIStyle GridLabel { get; private set; } + public static GUIStyle ChartTitle { get; private set; } + public static GUIStyle Legend { get; private set; } + + // Info panel styles + public static GUIStyle InfoText { get; private set; } + public static GUIStyle InfoHeader { get; private set; } + public static GUIStyle InfoSection { get; private set; } + public static GUIStyle Bullet { get; private set; } + public static GUIStyle IndentedBullet { get; private set; } + public static GUIStyle Footer { get; private set; } + public static GUIStyle Control { get; private set; } + public static GUIStyle FailureRate { get; private set; } + + // Tooltip style + public static GUIStyle ChartTooltip { get; private set; } + + // Menu styles + public static GUIStyle MenuHeader { get; private set; } + public static GUIStyle MenuLabel { get; private set; } + + /// + /// Initialize all cached styles. Called once on first use. + /// + public static void Initialize() + { + if (_initialized) return; + + // Header styles + HeaderCell = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, + alignment = TextAnchor.LowerLeft, + richText = true + }; + + HeaderCellHover = new GUIStyle(HeaderCell) + { + fontSize = 15 + }; + + // Row styles + RowPrimary = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.85f, 0.85f, 0.85f) }, + alignment = TextAnchor.MiddleLeft, + richText = true, + padding = new RectOffset(5, 0, 0, 0) + }; + + RowPrimaryHover = new GUIStyle(RowPrimary) + { + fontSize = 15 + }; + + RowPrimaryLocked = new GUIStyle(RowPrimary) + { + normal = { textColor = new Color(1f, 0.65f, 0.3f) } + }; + + RowSecondary = new GUIStyle(RowPrimary) + { + normal = { textColor = new Color(0.7f, 0.7f, 0.7f) } + }; + + // Button styles + SmallButton = new GUIStyle(HighLogic.Skin.button) + { + fontSize = 11, + padding = new RectOffset(2, 2, 2, 2) + }; + + CompactButton = new GUIStyle(GUI.skin.button) + { + fontSize = 12, + fontStyle = FontStyle.Bold + }; + + // Label styles + TimeLabel = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + normal = { textColor = Color.grey }, + alignment = TextAnchor.UpperCenter + }; + + GridLabel = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + normal = { textColor = Color.grey } + }; + + ChartTitle = new GUIStyle(GUI.skin.label) + { + fontSize = 16, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white }, + alignment = TextAnchor.MiddleCenter + }; + + Legend = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + normal = { textColor = Color.white }, + alignment = TextAnchor.UpperLeft + }; + + // Info panel styles + InfoText = new GUIStyle(GUI.skin.label) + { + fontSize = 15, + normal = { textColor = Color.white }, + wordWrap = true, + richText = true, + padding = new RectOffset(8, 8, 2, 2) + }; + + InfoHeader = new GUIStyle(GUI.skin.label) + { + fontSize = 17, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, + wordWrap = true, + richText = true, + padding = new RectOffset(8, 8, 0, 4) + }; + + InfoSection = new GUIStyle(GUI.skin.label) + { + fontSize = 16, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white }, + wordWrap = true, + richText = true, + padding = new RectOffset(8, 8, 4, 2) + }; + + Bullet = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + normal = { textColor = Color.white }, + wordWrap = false, + richText = true, + padding = new RectOffset(8, 8, 1, 1) + }; + + IndentedBullet = new GUIStyle(Bullet) + { + padding = new RectOffset(24, 8, 1, 1) + }; + + Footer = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + normal = { textColor = new Color(0.6f, 0.6f, 0.6f) }, + padding = new RectOffset(8, 8, 1, 1) + }; + + Control = new GUIStyle(GUI.skin.label) + { + fontSize = 12, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.8f, 0.8f, 0.8f) }, + padding = new RectOffset(8, 8, 2, 2) + }; + + FailureRate = new GUIStyle(GUI.skin.label) + { + fontSize = 18, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white }, + alignment = TextAnchor.MiddleCenter, + richText = true + }; + + // Tooltip style + ChartTooltip = new GUIStyle(GUI.skin.box) + { + fontSize = 15, + normal = { textColor = Color.white }, + padding = new RectOffset(8, 8, 6, 6), + alignment = TextAnchor.MiddleLeft, + wordWrap = false, + richText = true + }; + + // Menu styles + MenuHeader = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white } + }; + + MenuLabel = new GUIStyle(GUI.skin.label) + { + fontSize = 12, + normal = { textColor = Color.white } + }; + + _initialized = true; + } + + /// + /// Reset all styles. Call this if you need to reinitialize (e.g., after skin change). + /// + public static void Reset() + { + _initialized = false; + } + } +} diff --git a/Source/Engines/EngineConfigTechLevels.cs b/Source/Engines/EngineConfigTechLevels.cs new file mode 100644 index 00000000..63a28212 --- /dev/null +++ b/Source/Engines/EngineConfigTechLevels.cs @@ -0,0 +1,248 @@ +using System; +using UnityEngine; +using RealFuels.TechLevels; +using KSP.Localization; + +namespace RealFuels +{ + /// + /// Handles tech level calculations, validation, and UI for ModuleEngineConfigs. + /// Extracted to separate concerns and improve maintainability. + /// + public class EngineConfigTechLevels + { + private readonly ModuleEngineConfigsBase _module; + + public EngineConfigTechLevels(ModuleEngineConfigsBase module) + { + _module = module; + } + + #region Tech Level Validation + + /// + /// Checks if a configuration is unlocked (entry cost paid). + /// + public static bool UnlockedConfig(ConfigNode config, Part p) + { + if (config == null) + return false; + if (!config.HasValue("name")) + return false; + if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX) + return EntryCostManager.Instance.ConfigUnlocked((RFSettings.Instance.usePartNameInConfigUnlock ? Utilities.GetPartName(p) : string.Empty) + config.GetValue("name")); + return true; + } + + /// + /// Checks if a configuration can be used (tech requirement met). + /// + public static bool CanConfig(ConfigNode config) + { + if (config == null) + return false; + if (!config.HasValue("techRequired") || HighLogic.CurrentGame == null) + return true; + if (HighLogic.CurrentGame.Mode == Game.Modes.SANDBOX || ResearchAndDevelopment.GetTechnologyState(config.GetValue("techRequired")) == RDTech.State.Available) + return true; + return false; + } + + /// + /// Checks if a tech level is unlocked (entry cost paid). + /// + public static bool UnlockedTL(string tlName, int newTL) + { + if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX) + return EntryCostManager.Instance.TLUnlocked(tlName) >= newTL; + return true; + } + + #endregion + + #region Tech Level Calculations + + /// + /// Calculates thrust multiplier based on tech level difference. + /// + public double ThrustTL(ConfigNode cfg = null) + { + if (_module.techLevel != -1 && !_module.engineType.Contains("S")) + { + TechLevel oldTL = new TechLevel(), newTL = new TechLevel(); + if (oldTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.origTechLevel) && + newTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.techLevel)) + return newTL.Thrust(oldTL); + } + return 1; + } + + /// + /// Applies tech level thrust multiplier to a float value. + /// + public float ThrustTL(float thrust, ConfigNode cfg = null) + { + return (float)Math.Round(thrust * ThrustTL(cfg), 6); + } + + /// + /// Applies tech level thrust multiplier to a string value. + /// + public float ThrustTL(string thrust, ConfigNode cfg = null) + { + float.TryParse(thrust, out float tmp); + return ThrustTL(tmp, cfg); + } + + /// + /// Calculates mass multiplier based on tech level difference. + /// + public double MassTL(ConfigNode cfg = null) + { + if (_module.techLevel != -1) + { + TechLevel oldTL = new TechLevel(), newTL = new TechLevel(); + if (oldTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.origTechLevel) && + newTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.techLevel)) + return newTL.Mass(oldTL, _module.engineType.Contains("S")); + } + return 1; + } + + /// + /// Applies tech level mass multiplier to a float value. + /// + public float MassTL(float mass) + { + return (float)Math.Round(mass * MassTL(), 6); + } + + /// + /// Calculates cost adjusted for tech level. + /// + public float CostTL(float cost, ConfigNode cfg = null) + { + TechLevel cTL = new TechLevel(); + TechLevel oTL = new TechLevel(); + if (cTL.Load(cfg, _module.techNodes, _module.engineType, _module.techLevel) && + oTL.Load(cfg, _module.techNodes, _module.engineType, _module.origTechLevel) && + _module.part.partInfo != null) + { + // Bit of a dance: we have to figure out the total cost of the part, but doing so + // also depends on us. So we zero out our contribution first + // and then restore configCost. + float oldCC = _module.configCost; + _module.configCost = 0f; + float totalCost = _module.part.partInfo.cost + _module.part.GetModuleCosts(_module.part.partInfo.cost); + _module.configCost = oldCC; + cost = (totalCost + cost) * (cTL.CostMult / oTL.CostMult) - totalCost; + } + + return cost; + } + + /// + /// Resolves ignition count based on tech level (supports negative values like -1 for TL-based). + /// + public int ConfigIgnitions(int ignitions) + { + if (ignitions < 0) + { + ignitions = _module.techLevel + ignitions; + if (ignitions < 1) + ignitions = 1; + } + else if (ignitions == 0 && !_module.literalZeroIgnitions) + ignitions = -1; + return ignitions; + } + + #endregion + + #region Tech Level UI + + /// + /// Draws the tech level selector UI with +/- buttons. + /// + public void DrawTechLevelSelector() + { + // NK Tech Level + if (_module.techLevel != -1) + { + GUILayout.BeginHorizontal(); + + GUILayout.Label($"{Localizer.GetStringByTag("#RF_Engine_TechLevel")}: "); // Tech Level + string minusStr = "X"; + bool canMinus = false; + if (TechLevel.CanTL(_module.config, _module.techNodes, _module.engineType, _module.techLevel - 1) && _module.techLevel > _module.minTechLevel) + { + minusStr = "-"; + canMinus = true; + } + if (GUILayout.Button(minusStr) && canMinus) + { + _module.techLevel--; + _module.SetConfiguration(); + _module.UpdateSymmetryCounterparts(); + _module.MarkWindowDirty(); + } + GUILayout.Label(_module.techLevel.ToString()); + string plusStr = "X"; + bool canPlus = false; + bool canBuy = false; + string tlName = Utilities.GetPartName(_module.part) + _module.configuration; + double tlIncrMult = (double)(_module.techLevel + 1 - _module.origTechLevel); + if (TechLevel.CanTL(_module.config, _module.techNodes, _module.engineType, _module.techLevel + 1) && _module.techLevel < _module.maxTechLevel) + { + if (UnlockedTL(tlName, _module.techLevel + 1)) + { + plusStr = "+"; + canPlus = true; + } + else + { + double cost = EntryCostManager.Instance.TLEntryCost(tlName) * tlIncrMult; + double sciCost = EntryCostManager.Instance.TLSciEntryCost(tlName) * tlIncrMult; + bool autobuy = true; + plusStr = string.Empty; + if (cost > 0d) + { + plusStr += cost.ToString("N0") + "√"; + autobuy = false; + canBuy = true; + } + if (sciCost > 0d) + { + if (cost > 0d) + plusStr += "/"; + autobuy = false; + canBuy = true; + plusStr += sciCost.ToString("N1") + "s"; + } + if (autobuy) + { + // auto-upgrade + EntryCostManager.Instance.SetTLUnlocked(tlName, _module.techLevel + 1); + plusStr = "+"; + canPlus = true; + canBuy = false; + } + } + } + if (GUILayout.Button(plusStr) && (canPlus || canBuy)) + { + if (!canBuy || EntryCostManager.Instance.PurchaseTL(tlName, _module.techLevel + 1, tlIncrMult)) + { + _module.techLevel++; + _module.SetConfiguration(); + _module.UpdateSymmetryCounterparts(); + _module.MarkWindowDirty(); + } + } + GUILayout.EndHorizontal(); + } + } + + #endregion + } +} diff --git a/Source/Engines/EngineConfigTextures.cs b/Source/Engines/EngineConfigTextures.cs new file mode 100644 index 00000000..687a5c5f --- /dev/null +++ b/Source/Engines/EngineConfigTextures.cs @@ -0,0 +1,185 @@ +using System; +using UnityEngine; + +namespace RealFuels +{ + /// + /// Manages texture lifecycle for engine config GUI. + /// Ensures textures are created once and properly destroyed to prevent memory leaks. + /// + public class EngineConfigTextures : IDisposable + { + private static EngineConfigTextures _instance; + public static EngineConfigTextures Instance + { + get + { + if (_instance == null) + _instance = new EngineConfigTextures(); + return _instance; + } + } + + // Table textures + public Texture2D RowHover { get; private set; } + public Texture2D RowCurrent { get; private set; } + public Texture2D RowLocked { get; private set; } + public Texture2D ZebraStripe { get; private set; } + public Texture2D ColumnSeparator { get; private set; } + + // Chart textures + public Texture2D ChartBg { get; private set; } + public Texture2D ChartGridMajor { get; private set; } + public Texture2D ChartGridMinor { get; private set; } + public Texture2D ChartGreenZone { get; private set; } + public Texture2D ChartYellowZone { get; private set; } + public Texture2D ChartRedZone { get; private set; } + public Texture2D ChartDarkRedZone { get; private set; } + public Texture2D ChartStartupZone { get; private set; } + public Texture2D ChartLine { get; private set; } + public Texture2D ChartMarkerBlue { get; private set; } + public Texture2D ChartMarkerGreen { get; private set; } + public Texture2D ChartMarkerYellow { get; private set; } + public Texture2D ChartMarkerOrange { get; private set; } + public Texture2D ChartMarkerDarkRed { get; private set; } + public Texture2D ChartSeparator { get; private set; } + public Texture2D ChartHoverLine { get; private set; } + public Texture2D ChartOrangeLine { get; private set; } + public Texture2D ChartGreenLine { get; private set; } + public Texture2D ChartBlueLine { get; private set; } + public Texture2D ChartTooltipBg { get; private set; } + public Texture2D InfoPanelBg { get; private set; } + + private bool _initialized = false; + private bool _disposed = false; + + private EngineConfigTextures() + { + Initialize(); + } + + private void Initialize() + { + if (_initialized) return; + + // Table textures + RowHover = CreateColorPixel(new Color(1f, 1f, 1f, 0.05f)); + RowCurrent = CreateColorPixel(new Color(0.3f, 0.6f, 1.0f, 0.20f)); + RowLocked = CreateColorPixel(new Color(1f, 0.5f, 0.3f, 0.15f)); + ZebraStripe = CreateColorPixel(new Color(0.05f, 0.05f, 0.05f, 0.3f)); + ColumnSeparator = CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.9f)); + + // Chart textures + ChartBg = CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.8f)); + ChartGridMajor = CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.4f)); + ChartGridMinor = CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.2f)); + ChartGreenZone = CreateColorPixel(new Color(0.2f, 0.5f, 0.2f, 0.15f)); + ChartYellowZone = CreateColorPixel(new Color(0.5f, 0.5f, 0.2f, 0.15f)); + ChartRedZone = CreateColorPixel(new Color(0.5f, 0.2f, 0.2f, 0.15f)); + ChartDarkRedZone = CreateColorPixel(new Color(0.4f, 0.1f, 0.1f, 0.25f)); + ChartStartupZone = CreateColorPixel(new Color(0.15f, 0.3f, 0.5f, 0.3f)); + ChartLine = CreateColorPixel(new Color(0.8f, 0.4f, 0.4f, 1f)); + ChartMarkerBlue = CreateColorPixel(new Color(0.4f, 0.6f, 0.9f, 0.5f)); + ChartMarkerGreen = CreateColorPixel(new Color(0.3f, 0.8f, 0.3f, 0.5f)); + ChartMarkerYellow = CreateColorPixel(new Color(0.9f, 0.9f, 0.3f, 0.5f)); + ChartMarkerOrange = CreateColorPixel(new Color(1f, 0.65f, 0f, 0.5f)); + ChartMarkerDarkRed = CreateColorPixel(new Color(0.8f, 0.1f, 0.1f, 0.5f)); + ChartSeparator = CreateColorPixel(new Color(0.6f, 0.6f, 0.6f, 0.5f)); + ChartHoverLine = CreateColorPixel(new Color(1f, 1f, 1f, 0.4f)); + ChartOrangeLine = CreateColorPixel(new Color(1f, 0.5f, 0.2f, 1f)); + ChartGreenLine = CreateColorPixel(new Color(0.3f, 0.9f, 0.3f, 1f)); + ChartBlueLine = CreateColorPixel(new Color(0.5f, 0.85f, 1.0f, 1f)); + ChartTooltipBg = CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.95f)); + InfoPanelBg = CreateColorPixel(new Color(0.12f, 0.12f, 0.12f, 0.9f)); + + _initialized = true; + } + + /// + /// Ensures all textures are valid. Call this before rendering. + /// Handles Unity texture destruction on scene changes. + /// + public void EnsureInitialized() + { + // Check if any texture was destroyed (Unity does this on scene changes) + if (!RowHover || !ChartBg) + { + Dispose(); + _initialized = false; + Initialize(); + } + } + + private static Texture2D CreateColorPixel(Color color) + { + Texture2D texture = new Texture2D(1, 1, TextureFormat.RGBA32, false); + texture.SetPixel(0, 0, color); + texture.Apply(); + return texture; + } + + public void Dispose() + { + if (_disposed) return; + + // Destroy all textures + DestroyTexture(RowHover); + DestroyTexture(RowCurrent); + DestroyTexture(RowLocked); + DestroyTexture(ZebraStripe); + DestroyTexture(ColumnSeparator); + + DestroyTexture(ChartBg); + DestroyTexture(ChartGridMajor); + DestroyTexture(ChartGridMinor); + DestroyTexture(ChartGreenZone); + DestroyTexture(ChartYellowZone); + DestroyTexture(ChartRedZone); + DestroyTexture(ChartDarkRedZone); + DestroyTexture(ChartStartupZone); + DestroyTexture(ChartLine); + DestroyTexture(ChartMarkerBlue); + DestroyTexture(ChartMarkerGreen); + DestroyTexture(ChartMarkerYellow); + DestroyTexture(ChartMarkerOrange); + DestroyTexture(ChartMarkerDarkRed); + DestroyTexture(ChartSeparator); + DestroyTexture(ChartHoverLine); + DestroyTexture(ChartOrangeLine); + DestroyTexture(ChartGreenLine); + DestroyTexture(ChartBlueLine); + DestroyTexture(ChartTooltipBg); + DestroyTexture(InfoPanelBg); + + // Null them out + RowHover = RowCurrent = RowLocked = ZebraStripe = ColumnSeparator = null; + ChartBg = ChartGridMajor = ChartGridMinor = ChartGreenZone = ChartYellowZone = null; + ChartRedZone = ChartDarkRedZone = ChartStartupZone = ChartLine = null; + ChartMarkerBlue = ChartMarkerGreen = ChartMarkerYellow = ChartMarkerOrange = ChartMarkerDarkRed = null; + ChartSeparator = ChartHoverLine = ChartOrangeLine = ChartGreenLine = ChartBlueLine = null; + ChartTooltipBg = InfoPanelBg = null; + + _disposed = true; + } + + private void DestroyTexture(Texture2D texture) + { + if (texture != null) + { + UnityEngine.Object.Destroy(texture); + } + } + + /// + /// Call this when the game is shutting down or when you want to force cleanup. + /// + public static void Cleanup() + { + if (_instance != null) + { + _instance.Dispose(); + _instance = null; + } + } + } +} diff --git a/Source/Engines/ModuleBimodalEngineConfigs.cs b/Source/Engines/ModuleBimodalEngineConfigs.cs index be2884b8..a4eeb88c 100644 --- a/Source/Engines/ModuleBimodalEngineConfigs.cs +++ b/Source/Engines/ModuleBimodalEngineConfigs.cs @@ -301,9 +301,9 @@ private IEnumerator DriveAnimation(bool forward) if (animationStates == null) yield break; bool b9psNeedsReset = false; - if (B9PSModules != null && B9PSModules.Count != 0 && switchB9PSAtAnimationTime >= 0f) + if (Integrations.B9PSModules != null && Integrations.B9PSModules.Count != 0 && switchB9PSAtAnimationTime >= 0f) { - RequestB9PSVariantsForConfig(IsPrimaryMode ? SecondaryConfig(config) : GetConfigByName(configuration)); + Integrations.RequestB9PSVariantsForConfig(IsPrimaryMode ? SecondaryConfig(config) : GetConfigByName(configuration)); b9psNeedsReset = true; } @@ -321,7 +321,7 @@ private IEnumerator DriveAnimation(bool forward) if (forward && animState.normalizedTime >= switchB9PSAtAnimationTime || !forward && animState.normalizedTime <= switchB9PSAtAnimationTime) { - UpdateB9PSVariants(); + Integrations.UpdateB9PSVariants(); b9psNeedsReset = false; } } @@ -329,7 +329,7 @@ private IEnumerator DriveAnimation(bool forward) } SetAnimationSpeed(0f); - if (b9psNeedsReset) UpdateB9PSVariants(); + if (b9psNeedsReset) Integrations.UpdateB9PSVariants(); } } } diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index 72d7d823..1ec21cf9 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -206,8 +206,6 @@ protected override void DrawConfigSelectors(IEnumerable availableCon public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassModifier { - private static FieldInfo MRCSConsumedResources = typeof(ModuleRCS).GetField("consumedResources", BindingFlags.NonPublic | BindingFlags.Instance); - //protected const string groupName = "ModuleEngineConfigs"; public const string groupName = ModuleEnginesRF.groupName; public const string groupDisplayName = "#RF_Engine_EngineConfigs"; // "Engine Configs" @@ -311,130 +309,6 @@ public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassM public float scale = 1f; #endregion - #region TestFlight - - public void UpdateTFInterops() - { - TestFlightWrapper.AddInteropValue(part, isMaster ? "engineConfig" : "vernierConfig", configuration, "RealFuels"); - } - #endregion - - #region B9PartSwitch - protected static bool _b9psReflectionInitialized = false; - protected static FieldInfo B9PS_moduleID; - protected static MethodInfo B9PS_SwitchSubtype; - protected static FieldInfo B9PS_switchInFlight; - public Dictionary B9PSModules; - protected Dictionary RequestedB9PSVariants = new Dictionary(); - - private void InitializeB9PSReflection() - { - if (_b9psReflectionInitialized || !Utilities.B9PSFound) return; - B9PS_moduleID = Type.GetType("B9PartSwitch.CustomPartModule, B9PartSwitch")?.GetField("moduleID"); - B9PS_SwitchSubtype = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetMethod("SwitchSubtype"); - B9PS_switchInFlight = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetField("switchInFlight"); - _b9psReflectionInitialized = true; - } - - private void LoadB9PSModules() - { - IEnumerable b9psModuleIDs = configs - .Where(cfg => cfg.HasNode("LinkB9PSModule")) - .SelectMany(cfg => cfg.GetNodes("LinkB9PSModule")) - .Select(link => link?.GetValue("name")) - .Where(moduleID => moduleID != null) - .Distinct(); - - B9PSModules = new Dictionary(b9psModuleIDs.Count()); - - foreach (string moduleID in b9psModuleIDs) - { - var module = GetSpecifiedModules(part, string.Empty, -1, "ModuleB9PartSwitch", false) - .FirstOrDefault(m => (string)B9PS_moduleID?.GetValue(m) == moduleID); - if (module == null) - Debug.LogError($"*RFMEC* B9PartSwitch module with ID {moduleID} was not found for {part}!"); - else - B9PSModules[moduleID] = module; - } - } - - /// - /// Hide the GUI for all `ModuleB9PartSwitch`s managed by RF. - /// This is somewhat of a hack-ish approach... - /// - private void HideB9PSVariantSelectors() - { - if (B9PSModules == null) return; - foreach (var module in B9PSModules.Values) - { - module.Fields["currentSubtypeTitle"].guiActive = false; - module.Fields["currentSubtypeTitle"].guiActiveEditor = false; - module.Fields["currentSubtypeIndex"].guiActive = false; - module.Fields["currentSubtypeIndex"].guiActiveEditor = false; - module.Events["ShowSubtypesWindow"].guiActive = false; - module.Events["ShowSubtypesWindow"].guiActiveEditor = false; - } - } - - private IEnumerator HideB9PSInFlightSelector_Coroutine(PartModule module) - { - yield return null; - module.Events["ShowSubtypesWindow"].guiActive = false; - } - - protected void RequestB9PSVariantsForConfig(ConfigNode node) - { - if (B9PSModules == null || B9PSModules.Count == 0) return; - RequestedB9PSVariants.Clear(); - if (node.GetNodes("LinkB9PSModule") is ConfigNode[] links) - { - foreach (ConfigNode link in links) - { - string moduleID = null, subtype = null; - if (!link.TryGetValue("name", ref moduleID)) - Debug.LogError($"*RFMEC* Config `{configurationDisplay}` of {part} has a LinkB9PSModule specification without a name key!"); - if (!link.TryGetValue("subtype", ref subtype)) - Debug.LogError($"*RFMEC* Config `{configurationDisplay}` of {part} has a LinkB9PSModule specification without a subtype key!"); - if (moduleID != null && subtype != null) - RequestedB9PSVariants[moduleID] = subtype; - } - } - StartCoroutine(ApplyRequestedB9PSVariants_Coroutine()); - } - - protected IEnumerator ApplyRequestedB9PSVariants_Coroutine() - { - yield return new WaitForEndOfFrame(); - - if (RequestedB9PSVariants.Count == 0) yield break; - - foreach (var entry in B9PSModules) - { - string moduleID = entry.Key; - PartModule module = entry.Value; - - if (HighLogic.LoadedSceneIsFlight - && B9PS_switchInFlight != null - && !(bool)B9PS_switchInFlight.GetValue(module)) continue; - - if (!RequestedB9PSVariants.TryGetValue(moduleID, out string subtypeName)) - { - Debug.LogError($"*RFMEC* Config {configurationDisplay} of {part} does not specify a subtype for linked B9PS module with ID {moduleID}; defaulting to `{configuration}`."); - subtypeName = configuration; - } - - B9PS_SwitchSubtype?.Invoke(module, new object[] { subtypeName }); - if (HighLogic.LoadedSceneIsFlight) StartCoroutine(HideB9PSInFlightSelector_Coroutine(module)); - } - - RequestedB9PSVariants.Clear(); - // Clear symmetry counterparts' queues since B9PS already handles symmetry. - DoForEachSymmetryCounterpart(mec => mec.RequestedB9PSVariants.Clear()); - } - - public void UpdateB9PSVariants() => RequestB9PSVariantsForConfig(config); - #endregion - #region Callbacks public float GetModuleCost(float stdCost, ModifierStagingSituation sit) => configCost; public ModifierChangeWhen GetModuleCostChangeWhen() => ModifierChangeWhen.FIXED; @@ -501,7 +375,6 @@ public override void OnAwake() { techNodes = new ConfigNode(); configs = new List(); - InitializeB9PSReflection(); } public override void OnLoad(ConfigNode node) @@ -560,7 +433,7 @@ public override void OnStart(StartState state) ConfigSaveLoad(); - LoadB9PSModules(); + Integrations.LoadB9PSModules(); LoadDefaultGimbals(); @@ -574,7 +447,7 @@ public override void OnStart(StartState state) public override void OnStartFinished(StartState state) { - HideB9PSVariantSelectors(); + Integrations.HideB9PSVariantSelectors(); if (pModule is ModuleRCS mrcs) RelocateRCSPawItems(mrcs); } #endregion @@ -650,7 +523,7 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c if (config.HasValue(thrustRating)) { - info.Append($" {Utilities.FormatThrust(scale * ThrustTL(config.GetValue(thrustRating), config))}"); + info.Append($" {Utilities.FormatThrust(scale * TechLevels.ThrustTL(config.GetValue(thrustRating), config))}"); // add throttling info if present if (config.HasValue("minThrust")) info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("minThrust"), CultureInfo.InvariantCulture) / float.Parse(config.GetValue(thrustRating), CultureInfo.InvariantCulture):P0}"); //min @@ -848,11 +721,11 @@ protected void SetConfiguration(ConfigNode newConfig, bool resetTechLevels) Type mType = pModule.GetType(); config.SetValue("name", mType.Name); - ClearFloatCurves(mType, pModule, config, techLevel); - ClearPropellantGauges(mType, pModule); + EngineConfigPropellants.ClearFloatCurves(mType, pModule, config, techLevel); + EngineConfigPropellants.ClearPropellantGauges(mType, pModule); if (type.Equals("ModuleRCS") || type.Equals("ModuleRCSFX")) - ClearRCSPropellants(config); + EngineConfigPropellants.ClearRCSPropellants(part, config, DoConfig); else { // is an ENGINE if (pModule is ModuleEngines mE && config.HasNode("PROPELLANT")) @@ -867,7 +740,7 @@ protected void SetConfiguration(ConfigNode newConfig, bool resetTechLevels) { if (int.TryParse(config.GetValue("ignitions"), out int tmpIgnitions)) { - Ignitions = ConfigIgnitions(tmpIgnitions); + Ignitions = TechLevels.ConfigIgnitions(tmpIgnitions); config.SetValue("ignitions", Ignitions.Value); } @@ -910,9 +783,9 @@ protected void SetConfiguration(ConfigNode newConfig, bool resetTechLevels) SetupFX(); - UpdateB9PSVariants(); + Integrations.UpdateB9PSVariants(); - UpdateTFInterops(); // update TestFlight if it's installed + Integrations.UpdateTFInterops(); // update TestFlight if it's installed StopFX(); } @@ -947,81 +820,7 @@ virtual public void SetConfiguration(string newConfiguration = null, bool resetT SetConfiguration(newConfig, resetTechLevels); } - virtual protected int ConfigIgnitions(int ignitions) - { - if (ignitions < 0) - { - ignitions = techLevel + ignitions; - if (ignitions < 1) - ignitions = 1; - } - else if (ignitions == 0 && !literalZeroIgnitions) - ignitions = -1; - return ignitions; - } - #region SetConfiguration Tools - private void ClearFloatCurves(Type mType, PartModule pm, ConfigNode cfg, int techLevel) - { - // clear all FloatCurves we need to clear (i.e. if our config has one, or techlevels are enabled) - bool delAtmo = cfg.HasNode("atmosphereCurve") || techLevel >= 0; - bool delDens = cfg.HasNode("atmCurve") || techLevel >= 0; - bool delVel = cfg.HasNode("velCurve") || techLevel >= 0; - foreach (FieldInfo field in mType.GetFields()) - { - if (field.FieldType == typeof(FloatCurve) && - ((field.Name.Equals("atmosphereCurve") && delAtmo) - || (field.Name.Equals("atmCurve") && delDens) - || (field.Name.Equals("velCurve") && delVel))) - { - field.SetValue(pm, new FloatCurve()); - } - } - } - - private void ClearPropellantGauges(Type mType, PartModule pm) - { - foreach (FieldInfo field in mType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (field.FieldType == typeof(Dictionary) && - field.GetValue(pm) is Dictionary boxes) - { - foreach (ProtoStageIconInfo v in boxes.Values) - { - try - { - if (v is ProtoStageIconInfo) - pm.part.stackIcon.RemoveInfo(v); - } - catch (Exception e) - { - Debug.LogError("*RFMEC* Trying to remove info box: " + e.Message); - } - } - boxes.Clear(); - } - } - } - - private void ClearRCSPropellants(ConfigNode cfg) - { - List RCSModules = part.Modules.OfType().ToList(); - if (RCSModules.Count > 0) - { - DoConfig(cfg); - foreach (var rcsModule in RCSModules) - { - if (cfg.HasNode("PROPELLANT")) - rcsModule.propellants.Clear(); - rcsModule.Load(cfg); - List res = MRCSConsumedResources.GetValue(rcsModule) as List; - res.Clear(); - foreach (Propellant p in rcsModule.propellants) - res.Add(p.resourceDef); - } - } - } - private Dictionary ExtractGimbals(ConfigNode cfg) { Gimbal ExtractGimbalKeys(ConfigNode c) @@ -1104,7 +903,7 @@ private void HandleEngineIgnitor(ConfigNode cfg) if (eiNode.HasValue("ignitionsAvailable") && int.TryParse(eiNode.GetValue("ignitionsAvailable"), out int ignitions)) { - ignitions = ConfigIgnitions(ignitions); + ignitions = TechLevels.ConfigIgnitions(ignitions); eiNode.SetValue("ignitionsAvailable", ignitions); eiNode.SetValue("ignitionsRemained", ignitions, true); } @@ -1189,14 +988,14 @@ virtual public void DoConfig(ConfigNode cfg) // set heatProduction if (configHeat > 0) - configHeat = MassTL(configHeat); + configHeat = TechLevels.MassTL(configHeat); // set thrust and throttle if (configMaxThrust >= 0) { - configMaxThrust = ThrustTL(configMaxThrust); + configMaxThrust = TechLevels.ThrustTL(configMaxThrust); if (configMinThrust >= 0) - configMinThrust = ThrustTL(configMinThrust); + configMinThrust = TechLevels.ThrustTL(configMinThrust); else if (thrustRating.Equals("thrusterPower")) configMinThrust = configMaxThrust * 0.5f; else @@ -1212,7 +1011,7 @@ virtual public void DoConfig(ConfigNode cfg) } configThrottle = configMinThrust / configMaxThrust; if (origMass > 0) - TLMassMult = MassTL(1.0f); + TLMassMult = TechLevels.MassTL(1.0f); } // Don't want to change gimbals on TL-enabled engines willy-nilly // So we don't unless either a transform is specified, or we override. @@ -1227,7 +1026,7 @@ virtual public void DoConfig(ConfigNode cfg) } // Cost (multiplier will be 1.0 if unspecified) - cost = scale * CostTL(cost, cfg); + cost = scale * TechLevels.CostTL(cost, cfg); } else { @@ -1297,97 +1096,6 @@ public void ChangeEngineType(string newEngineType) SetConfiguration(configuration); } - #region TechLevel and Required - /// - /// Is this config unlocked? Note: Is the same as CanConfig when not CAREER and no upgrade manager instance. - /// - /// - /// - public static bool UnlockedConfig(ConfigNode config, Part p) - { - if (config == null) - return false; - if (!config.HasValue("name")) - return false; - if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX) - return EntryCostManager.Instance.ConfigUnlocked((RFSettings.Instance.usePartNameInConfigUnlock ? Utilities.GetPartName(p) : string.Empty) + config.GetValue("name")); - return true; - } - public static bool CanConfig(ConfigNode config) - { - if (config == null) - return false; - if (!config.HasValue("techRequired") || HighLogic.CurrentGame == null) - return true; - if (HighLogic.CurrentGame.Mode == Game.Modes.SANDBOX || ResearchAndDevelopment.GetTechnologyState(config.GetValue("techRequired")) == RDTech.State.Available) - return true; - return false; - } - public static bool UnlockedTL(string tlName, int newTL) - { - if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX) - return EntryCostManager.Instance.TLUnlocked(tlName) >= newTL; - return true; - } - - private double ThrustTL(ConfigNode cfg = null) - { - if (techLevel != -1 && !engineType.Contains("S")) - { - TechLevel oldTL = new TechLevel(), newTL = new TechLevel(); - if (oldTL.Load(cfg ?? config, techNodes, engineType, origTechLevel) && - newTL.Load(cfg ?? config, techNodes, engineType, techLevel)) - return newTL.Thrust(oldTL); - } - return 1; - } - - private float ThrustTL(float thrust, ConfigNode cfg = null) - { - return (float)Math.Round(thrust * ThrustTL(cfg), 6); - } - - private float ThrustTL(string thrust, ConfigNode cfg = null) - { - float.TryParse(thrust, out float tmp); - return ThrustTL(tmp, cfg); - } - - private double MassTL(ConfigNode cfg = null) - { - if (techLevel != -1) - { - TechLevel oldTL = new TechLevel(), newTL = new TechLevel(); - if (oldTL.Load(cfg ?? config, techNodes, engineType, origTechLevel) && - newTL.Load(cfg ?? config, techNodes, engineType, techLevel)) - return newTL.Mass(oldTL, engineType.Contains("S")); - } - return 1; - } - - private float MassTL(float mass) - { - return (float)Math.Round(mass * MassTL(), 6); - } - private float CostTL(float cost, ConfigNode cfg = null) - { - TechLevel cTL = new TechLevel(); - TechLevel oTL = new TechLevel(); - if (cTL.Load(cfg, techNodes, engineType, techLevel) && oTL.Load(cfg, techNodes, engineType, origTechLevel) && part.partInfo != null) - { - // Bit of a dance: we have to figure out the total cost of the part, but doing so - // also depends on us. So we zero out our contribution first - // and then restore configCost. - float oldCC = configCost; - configCost = 0f; - float totalCost = part.partInfo.cost + part.GetModuleCosts(part.partInfo.cost); - configCost = oldCC; - cost = (totalCost + cost) * (cTL.CostMult / oTL.CostMult) - totalCost; - } - - return cost; - } - #endregion #endregion #region GUI @@ -1425,6 +1133,9 @@ public void OnDestroy() { GameEvents.onPartActionUIDismiss.Remove(OnPartActionGuiDismiss); GameEvents.onPartActionUIShown.Remove(OnPartActionUIShown); + + // Note: We don't call EngineConfigTextures.Cleanup() here because textures + // are shared across all instances. They'll be cleaned up when Unity unloads the scene. } private static Vector3 mousePos = Vector3.zero; @@ -1457,48 +1168,56 @@ public void OnDestroy() private string clusterSizeInput = "1"; // Text input for cluster size private string dataValueInput = "0"; // Text input for data value in du - // Chart axis limit controls - private bool useCustomAxisLimits = false; // Whether to use custom axis limits - private float customMaxTime = 0f; // Custom max time for X axis - private float customMinSurvivalProb = 0f; // Custom min survival probability for Y axis (0-1 scale) - private float autoMaxTime = 0f; // Auto-calculated max time (for reset) - private float autoMinSurvivalProb = 0f; // Auto-calculated min survival prob (for reset) - private string maxTimeInput = "0"; // Text input for max time - private string minSurvivalProbInput = "100"; // Text input for min survival prob - private const int ConfigRowHeight = 22; private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) // Dynamic column widths - calculated based on content private float[] ConfigColumnWidths = new float[18]; - private static Texture2D rowHoverTex; - private static Texture2D rowCurrentTex; - private static Texture2D rowLockedTex; - private static Texture2D zebraStripeTex; - private static Texture2D columnSeparatorTex; - - // Chart textures - cached to prevent loss on focus change - private static Texture2D chartBgTex; - private static Texture2D chartGridMajorTex; - private static Texture2D chartGridMinorTex; - private static Texture2D chartGreenZoneTex; - private static Texture2D chartYellowZoneTex; - private static Texture2D chartRedZoneTex; - private static Texture2D chartDarkRedZoneTex; - private static Texture2D chartStartupZoneTex; - private static Texture2D chartLineTex; - private static Texture2D chartMarkerBlueTex; - private static Texture2D chartMarkerGreenTex; - private static Texture2D chartMarkerYellowTex; - private static Texture2D chartMarkerOrangeTex; - private static Texture2D chartMarkerDarkRedTex; - private static Texture2D chartSeparatorTex; - private static Texture2D chartHoverLineTex; - private static Texture2D chartOrangeLineTex; - private static Texture2D chartGreenLineTex; - private static Texture2D chartBlueLineTex; - private static Texture2D chartTooltipBgTex; - private static Texture2D infoPanelBgTex; + // Texture and style management - using singletons to prevent memory leaks + private EngineConfigTextures Textures => EngineConfigTextures.Instance; + + // Tech level management - lazy initialization + private EngineConfigTechLevels _techLevels; + protected EngineConfigTechLevels TechLevels + { + get + { + if (_techLevels == null) + _techLevels = new EngineConfigTechLevels(this); + return _techLevels; + } + } + + // Integration with B9PartSwitch and TestFlight - lazy initialization + private EngineConfigIntegrations _integrations; + internal EngineConfigIntegrations Integrations + { + get + { + if (_integrations == null) + _integrations = new EngineConfigIntegrations(this); + return _integrations; + } + } + + // Chart rendering - lazy initialization + private EngineConfigChart _chart; + private EngineConfigChart Chart + { + get + { + if (_chart == null) + { + _chart = new EngineConfigChart(this); + _chart.UseLogScaleX = useLogScaleX; + _chart.UseLogScaleY = useLogScaleY; + _chart.UseSimulatedData = useSimulatedData; + _chart.SimulatedDataValue = simulatedDataValue; + _chart.ClusterSize = clusterSize; + } + return _chart; + } + } private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); @@ -1606,7 +1325,7 @@ protected string GetCostString(ConfigNode node) if (techLevel != -1) { - curCost = CostTL(curCost, node) - CostTL(0f, node); // get purely the config cost difference + curCost = TechLevels.CostTL(curCost, node) - TechLevels.CostTL(0f, node); // get purely the config cost difference } costString = $" ({((curCost < 0) ? string.Empty : "+")}{curCost:N0}√)"; } @@ -1643,7 +1362,7 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action else if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to apply(nName); - if (!UnlockedConfig(node, part)) + if (!EngineConfigTechLevels.UnlockedConfig(node, part)) { double upgradeCost = EntryCostManager.Instance.ConfigEntryCost(nName); string techRequired = node.GetValue("techRequired"); @@ -1653,7 +1372,7 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action EntryCostManager.Instance.PurchaseConfig(nName, techRequired); } - bool isConfigAvailable = CanConfig(node); + bool isConfigAvailable = EngineConfigTechLevels.CanConfig(node); string tooltip = string.Empty; if (!isConfigAvailable && techNameToTitle.TryGetValue(techRequired, out string techStr)) { @@ -1679,7 +1398,7 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action } // Locked. - if (!CanConfig(node)) + if (!EngineConfigTechLevels.CanConfig(node)) { if (techNameToTitle.TryGetValue(node.GetValue("techRequired"), out string techStr)) techStr = $"\n{Localizer.GetStringByTag("#RF_Engine_Requires")}: " + techStr; // Requires @@ -1688,7 +1407,7 @@ protected void DrawSelectButton(ConfigNode node, bool isSelected, Action } // Available. - if (UnlockedConfig(node, part)) + if (EngineConfigTechLevels.UnlockedConfig(node, part)) { if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to apply(nName); @@ -1810,7 +1529,7 @@ protected void CalculateColumnWidths(List rows) protected void DrawConfigTable(IEnumerable rows) { - EnsureTableTextures(); + EnsureTexturesAndStyles(); var rowList = rows.ToList(); @@ -1854,21 +1573,21 @@ protected void DrawConfigTable(IEnumerable rows) Rect tableRowRect = new Rect(rowStartX, rowRect.y, totalWidth, rowRect.height); bool isHovered = tableRowRect.Contains(Event.current.mousePosition); - bool isLocked = !CanConfig(row.Node); + bool isLocked = !EngineConfigTechLevels.CanConfig(row.Node); if (Event.current.type == EventType.Repaint) { // Draw alternating row background first if (!row.IsSelected && !isLocked && !isHovered && rowIndex % 2 == 1) { - GUI.DrawTexture(tableRowRect, zebraStripeTex); + GUI.DrawTexture(tableRowRect, Textures.ZebraStripe); } if (row.IsSelected) - GUI.DrawTexture(tableRowRect, rowCurrentTex); + GUI.DrawTexture(tableRowRect, Textures.RowCurrent); else if (isLocked) - GUI.DrawTexture(tableRowRect, rowLockedTex); + GUI.DrawTexture(tableRowRect, Textures.RowLocked); else if (isHovered) - GUI.DrawTexture(tableRowRect, rowHoverTex); + GUI.DrawTexture(tableRowRect, Textures.RowHover); } string tooltip = GetRowTooltip(row.Node); @@ -1908,18 +1627,9 @@ private void DrawColumnMenu(Rect menuRect) float leftX = menuRect.x + 10; float rightX = menuRect.x + menuRect.width / 2 + 5; - var headerStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 13, - fontStyle = FontStyle.Bold, - normal = { textColor = Color.white } - }; - - var labelStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 12, - normal = { textColor = Color.white } - }; + // Use cached menu styles + GUIStyle headerStyle = EngineConfigStyles.MenuHeader; + GUIStyle labelStyle = EngineConfigStyles.MenuLabel; // Title GUI.Label(new Rect(leftX, yPos, menuRect.width - 20, 20), "Column Visibility", headerStyle); @@ -2079,7 +1789,7 @@ private void DrawColumnSeparators(Rect rowRect) { currentX += ConfigColumnWidths[i]; Rect separatorRect = new Rect(currentX, rowRect.y, 1, rowRect.height); - GUI.DrawTexture(separatorRect, columnSeparatorTex); + GUI.DrawTexture(separatorRect, Textures.ColumnSeparator); } } } @@ -2087,14 +1797,8 @@ private void DrawColumnSeparators(Rect rowRect) private void DrawHeaderCell(Rect rect, string text, string tooltip) { bool hover = rect.Contains(Event.current.mousePosition); - var headerStyle = new GUIStyle(GUI.skin.label) - { - fontSize = hover ? 15 : 14, - fontStyle = FontStyle.Bold, - normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, - alignment = TextAnchor.LowerLeft, - richText = true - }; + GUIStyle headerStyle = hover ? EngineConfigStyles.HeaderCellHover : EngineConfigStyles.HeaderCell; + if (configGuiContent == null) configGuiContent = new GUIContent(); configGuiContent.text = text; @@ -2110,19 +1814,16 @@ private void DrawHeaderCell(Rect rect, string text, string tooltip) private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered, bool isLocked) { - var primaryStyle = new GUIStyle(GUI.skin.label) - { - fontSize = isHovered ? 15 : 14, - fontStyle = FontStyle.Bold, - normal = { textColor = isLocked ? new Color(1f, 0.65f, 0.3f) : new Color(0.85f, 0.85f, 0.85f) }, - alignment = TextAnchor.MiddleLeft, - richText = true, - padding = new RectOffset(5, 0, 0, 0) // Add left padding - }; - var secondaryStyle = new GUIStyle(primaryStyle) - { - normal = { textColor = new Color(0.7f, 0.7f, 0.7f) } - }; + // Use cached styles instead of creating new ones every frame + GUIStyle primaryStyle; + if (isLocked) + primaryStyle = EngineConfigStyles.RowPrimaryLocked; + else if (isHovered) + primaryStyle = EngineConfigStyles.RowPrimaryHover; + else + primaryStyle = EngineConfigStyles.RowPrimary; + + GUIStyle secondaryStyle = EngineConfigStyles.RowSecondary; float currentX = rowRect.x; string nameText = row.DisplayName; @@ -2220,16 +1921,11 @@ private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action apply) { - var buttonStyle = HighLogic.Skin.button; - var smallButtonStyle = new GUIStyle(buttonStyle) - { - fontSize = 11, - padding = new RectOffset(2, 2, 2, 2) - }; + GUIStyle smallButtonStyle = EngineConfigStyles.SmallButton; string configName = node.GetValue("name"); - bool canUse = CanConfig(node); - bool unlocked = UnlockedConfig(node, part); + bool canUse = EngineConfigTechLevels.CanConfig(node); + bool unlocked = EngineConfigTechLevels.UnlockedConfig(node, part); double cost = EntryCostManager.Instance.ConfigEntryCost(configName); // Auto-purchase free configs @@ -2268,19 +1964,19 @@ private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action GUI.enabled = true; } - private string GetThrustString(ConfigNode node) + internal string GetThrustString(ConfigNode node) { if (!node.HasValue(thrustRating)) return "-"; - float thrust = scale * ThrustTL(node.GetValue(thrustRating), node); + float thrust = scale * TechLevels.ThrustTL(node.GetValue(thrustRating), node); // Remove decimals for large thrust values if (thrust >= 100f) return $"{thrust:N0} kN"; return $"{thrust:N2} kN"; } - private string GetMinThrottleString(ConfigNode node) + internal string GetMinThrottleString(ConfigNode node) { float value = -1f; if (node.HasValue("minThrust") && node.HasValue(thrustRating)) @@ -2300,7 +1996,7 @@ private string GetMinThrottleString(ConfigNode node) return value.ToString("P0"); } - private string GetIspString(ConfigNode node) + internal string GetIspString(ConfigNode node) { if (node.HasNode("atmosphereCurve")) { @@ -2330,7 +2026,7 @@ private string GetIspString(ConfigNode node) return "-"; } - private string GetMassString(ConfigNode node) + internal string GetMassString(ConfigNode node) { if (origMass <= 0f) return "-"; @@ -2342,7 +2038,7 @@ private string GetMassString(ConfigNode node) return $"{cMass:N3}t"; } - private string GetGimbalString(ConfigNode node) + internal string GetGimbalString(ConfigNode node) { if (!part.HasModuleImplementing()) return "✗"; @@ -2395,7 +2091,7 @@ private string GetGimbalString(ConfigNode node) return string.Join(", ", uniqueInfos); } - private string GetIgnitionsString(ConfigNode node) + internal string GetIgnitionsString(ConfigNode node) { if (!node.HasValue("ignitions")) return "-"; @@ -2403,7 +2099,7 @@ private string GetIgnitionsString(ConfigNode node) if (!int.TryParse(node.GetValue("ignitions"), out int ignitions)) return "∞"; - int resolved = ConfigIgnitions(ignitions); + int resolved = TechLevels.ConfigIgnitions(ignitions); if (resolved == -1) return "∞"; if (resolved == 0 && literalZeroIgnitions) @@ -2411,7 +2107,7 @@ private string GetIgnitionsString(ConfigNode node) return resolved.ToString(); } - private string GetBoolSymbol(ConfigNode node, string key) + internal string GetBoolSymbol(ConfigNode node, string key) { if (!node.HasValue(key)) return "✗"; // Treat missing as false - gray (no restriction) @@ -2450,7 +2146,7 @@ private bool IsColumnVisible(int columnIndex) return compactView ? columnsVisibleCompact[columnIndex] : columnsVisibleFull[columnIndex]; } - private string GetRatedBurnTimeString(ConfigNode node) + internal string GetRatedBurnTimeString(ConfigNode node) { bool hasRatedBurnTime = node.HasValue("ratedBurnTime"); bool hasRatedContinuousBurnTime = node.HasValue("ratedContinuousBurnTime"); @@ -2470,7 +2166,7 @@ private string GetRatedBurnTimeString(ConfigNode node) return hasRatedBurnTime ? node.GetValue("ratedBurnTime") : node.GetValue("ratedContinuousBurnTime"); } - private string GetTestedBurnTimeString(ConfigNode node) + internal string GetTestedBurnTimeString(ConfigNode node) { // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("testedBurnTime")) @@ -2483,7 +2179,7 @@ private string GetTestedBurnTimeString(ConfigNode node) return "-"; } - private string GetIgnitionReliabilityStartString(ConfigNode node) + internal string GetIgnitionReliabilityStartString(ConfigNode node) { // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("ignitionReliabilityStart")) @@ -2493,7 +2189,7 @@ private string GetIgnitionReliabilityStartString(ConfigNode node) return "-"; } - private string GetIgnitionReliabilityEndString(ConfigNode node) + internal string GetIgnitionReliabilityEndString(ConfigNode node) { // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("ignitionReliabilityEnd")) @@ -2503,7 +2199,7 @@ private string GetIgnitionReliabilityEndString(ConfigNode node) return "-"; } - private string GetCycleReliabilityStartString(ConfigNode node) + internal string GetCycleReliabilityStartString(ConfigNode node) { // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("cycleReliabilityStart")) @@ -2513,7 +2209,7 @@ private string GetCycleReliabilityStartString(ConfigNode node) return "-"; } - private string GetCycleReliabilityEndString(ConfigNode node) + internal string GetCycleReliabilityEndString(ConfigNode node) { // Values are copied to CONFIG level by ModuleManager patch if (!node.HasValue("cycleReliabilityEnd")) @@ -2535,7 +2231,7 @@ private string GetFlightDataString() return $"{currentData:F0} / {maxData:F0}"; } - private string GetTechString(ConfigNode node) + internal string GetTechString(ConfigNode node) { if (!node.HasValue("techRequired")) return "-"; @@ -2560,14 +2256,14 @@ private string GetTechString(ConfigNode node) return abbreviated; } - private string GetCostDeltaString(ConfigNode node) + internal string GetCostDeltaString(ConfigNode node) { if (!node.HasValue("cost")) return "-"; float curCost = scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture); if (techLevel != -1) - curCost = CostTL(curCost, node) - CostTL(0f, node); + curCost = TechLevels.CostTL(curCost, node) - TechLevels.CostTL(0f, node); if (Mathf.Approximately(curCost, 0f)) return "-"; @@ -2610,7 +2306,7 @@ private string GetRowTooltip(ConfigNode node) float isp = 0f; if (node.HasValue(thrustRating) && float.TryParse(node.GetValue(thrustRating), out float maxThrust)) - thrust = ThrustTL(node.GetValue(thrustRating), node) * scale; + thrust = TechLevels.ThrustTL(node.GetValue(thrustRating), node) * scale; if (node.HasNode("atmosphereCurve")) { @@ -2677,1602 +2373,73 @@ private string GetRowTooltip(ConfigNode node) return tooltipParts.Count > 0 ? string.Join("\n\n", tooltipParts) : string.Empty; } - private void EnsureTableTextures() - { - // Use Unity's implicit bool conversion to properly detect destroyed textures - if (!rowHoverTex) - rowHoverTex = Styles.CreateColorPixel(new Color(1f, 1f, 1f, 0.05f)); - if (!rowCurrentTex) - rowCurrentTex = Styles.CreateColorPixel(new Color(0.3f, 0.6f, 1.0f, 0.20f)); // Subtle blue tint - if (!rowLockedTex) - rowLockedTex = Styles.CreateColorPixel(new Color(1f, 0.5f, 0.3f, 0.15f)); // Subtle orange tint - if (!zebraStripeTex) - zebraStripeTex = Styles.CreateColorPixel(new Color(0.05f, 0.05f, 0.05f, 0.3f)); - if (!columnSeparatorTex) - columnSeparatorTex = Styles.CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.9f)); - } - - private void EnsureChartTextures() - { - // Use Unity's implicit bool conversion to properly detect destroyed textures - if (!chartBgTex) - chartBgTex = Styles.CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.8f)); - if (!chartGridMajorTex) - chartGridMajorTex = Styles.CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.4f)); // Major gridlines at 20% - if (!chartGridMinorTex) - chartGridMinorTex = Styles.CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.2f)); // Minor gridlines at 10%, barely visible - if (!chartGreenZoneTex) - chartGreenZoneTex = Styles.CreateColorPixel(new Color(0.2f, 0.5f, 0.2f, 0.15f)); - if (!chartYellowZoneTex) - chartYellowZoneTex = Styles.CreateColorPixel(new Color(0.5f, 0.5f, 0.2f, 0.15f)); - if (!chartRedZoneTex) - chartRedZoneTex = Styles.CreateColorPixel(new Color(0.5f, 0.2f, 0.2f, 0.15f)); - if (!chartDarkRedZoneTex) - chartDarkRedZoneTex = Styles.CreateColorPixel(new Color(0.4f, 0.1f, 0.1f, 0.25f)); // Darker red for 100× zone - if (!chartStartupZoneTex) - chartStartupZoneTex = Styles.CreateColorPixel(new Color(0.15f, 0.3f, 0.5f, 0.3f)); - if (!chartLineTex) - chartLineTex = Styles.CreateColorPixel(new Color(0.8f, 0.4f, 0.4f, 1f)); - if (!chartMarkerBlueTex) - chartMarkerBlueTex = Styles.CreateColorPixel(new Color(0.4f, 0.6f, 0.9f, 0.5f)); // Blue for startup zone end - if (!chartMarkerGreenTex) - chartMarkerGreenTex = Styles.CreateColorPixel(new Color(0.3f, 0.8f, 0.3f, 0.5f)); // Less prominent - if (!chartMarkerYellowTex) - chartMarkerYellowTex = Styles.CreateColorPixel(new Color(0.9f, 0.9f, 0.3f, 0.5f)); // Less prominent - if (!chartMarkerOrangeTex) - chartMarkerOrangeTex = Styles.CreateColorPixel(new Color(1f, 0.65f, 0f, 0.5f)); // Less prominent - if (!chartMarkerDarkRedTex) - chartMarkerDarkRedTex = Styles.CreateColorPixel(new Color(0.8f, 0.1f, 0.1f, 0.5f)); // Less prominent - if (!chartSeparatorTex) - chartSeparatorTex = Styles.CreateColorPixel(new Color(0.6f, 0.6f, 0.6f, 0.5f)); // Less prominent - if (!chartHoverLineTex) - chartHoverLineTex = Styles.CreateColorPixel(new Color(1f, 1f, 1f, 0.4f)); - if (!chartOrangeLineTex) - chartOrangeLineTex = Styles.CreateColorPixel(new Color(1f, 0.5f, 0.2f, 1f)); - if (!chartGreenLineTex) - chartGreenLineTex = Styles.CreateColorPixel(new Color(0.3f, 0.9f, 0.3f, 1f)); - if (!chartBlueLineTex) - chartBlueLineTex = Styles.CreateColorPixel(new Color(0.5f, 0.85f, 1.0f, 1f)); // Lighter blue for current data - if (!chartTooltipBgTex) - chartTooltipBgTex = Styles.CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.95f)); - if (!infoPanelBgTex) - infoPanelBgTex = Styles.CreateColorPixel(new Color(0.12f, 0.12f, 0.12f, 0.9f)); - } - - /// - /// Format MTBF (mean time between failures) in human-readable units. - /// - private string FormatMTBF(float mtbfSeconds) - { - if (float.IsInfinity(mtbfSeconds) || float.IsNaN(mtbfSeconds)) - return "∞"; - - if (mtbfSeconds < 60f) - return $"{mtbfSeconds:F1}s"; - if (mtbfSeconds < 3600f) - return $"{mtbfSeconds / 60f:F1}m"; - if (mtbfSeconds < 86400f) - return $"{mtbfSeconds / 3600f:F1}h"; - if (mtbfSeconds < 31536000f) - return $"{mtbfSeconds / 86400f:F1}d"; - return $"{mtbfSeconds / 31536000f:F1}y"; - } - /// - /// Numerically integrate the cycle curve from t1 to t2 using trapezoidal rule. - /// Returns the integral of the cycle modifier over the time interval. + /// Ensures textures are initialized. Handles Unity texture destruction on scene changes. /// - private float IntegrateCycleCurve(FloatCurve curve, float t1, float t2, int steps) + private void EnsureTexturesAndStyles() { - if (t2 <= t1) return 0f; - - float dt = (t2 - t1) / steps; - float sum = 0f; - - // Trapezoidal rule: integrate by averaging adjacent points - for (int i = 0; i < steps; i++) - { - float tStart = t1 + i * dt; - float tEnd = tStart + dt; - float valueStart = curve.Evaluate(tStart); - float valueEnd = curve.Evaluate(tEnd); - sum += (valueStart + valueEnd) * 0.5f * dt; - } - - return sum; + Textures.EnsureInitialized(); + EngineConfigStyles.Initialize(); } - /// - /// Build the TestFlight cycle curve exactly as TestFlight_Generic_Engines.cfg does. - /// This matches the ModuleManager patch logic from RealismOverhaul. - /// - private FloatCurve BuildTestFlightCycleCurve(float ratedBurnTime, float testedBurnTime, float overburnPenalty, bool hasTestedBurnTime) + virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) { - FloatCurve curve = new FloatCurve(); - - // Key 1: Early burn high penalty - curve.Add(0.00f, 10.00f); - - // Key 2: Stabilize at 5 seconds - curve.Add(5.00f, 1.00f, -0.8f, 0f); - - // Key 3: Maintain 1.0 until rated burn time (+ 5 second cushion) - float rbtCushioned = ratedBurnTime + 5f; - curve.Add(rbtCushioned, 1f, 0f, 0f); - - if (hasTestedBurnTime) - { - // Key 4: Tested burn time with smooth transition - float ratedToTestedInterval = testedBurnTime - rbtCushioned; - float tbtTransitionSlope = 3.135f / ratedToTestedInterval; - float tbtTransitionSlopeMult = overburnPenalty - 1.0f; - tbtTransitionSlope *= tbtTransitionSlopeMult; - curve.Add(testedBurnTime, overburnPenalty, tbtTransitionSlope, tbtTransitionSlope); - - // Key 5: Complete failure at 2.5x tested burn time - float failTime = testedBurnTime * 2.5f; - float tbtToFailInterval = failTime - testedBurnTime; - float failInSlope = 1.989f / tbtToFailInterval; - float failInSlopeMult = 100f - overburnPenalty; - failInSlope *= failInSlopeMult; - curve.Add(failTime, 100f, failInSlope, 0f); - } - else - { - // Key 4: Complete failure at 2.5x rated burn time (standard overburn) - float failTime = ratedBurnTime * 2.5f; - float rbtToFailInterval = failTime - rbtCushioned; - float failInSlope = 292.8f / rbtToFailInterval; - curve.Add(failTime, 100f, failInSlope, 0f); - } - - return curve; + DrawConfigTable(BuildConfigRows()); } - /// - /// Convert time to x-position on the chart, using either linear or logarithmic scale. - /// - private float TimeToXPosition(float time, float maxTime, float plotX, float plotWidth, bool useLogScale) + private void EngineManagerGUI(int WindowID) { - if (useLogScale) - { - // Logarithmic scale: use log10(time + 1) to handle t=0 - // Map from log10(1) to log10(maxTime + 1) - float logTime = Mathf.Log10(time + 1f); - float logMax = Mathf.Log10(maxTime + 1f); - return plotX + (logTime / logMax) * plotWidth; - } - else - { - // Linear scale - return plotX + (time / maxTime) * plotWidth; - } - } + // Use BeginVertical with GUILayout.ExpandHeight(false) to prevent extra vertical space + GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); - /// - /// Convert x-position back to time, using either linear or logarithmic scale. - /// - private float XPositionToTime(float xPos, float maxTime, float plotX, float plotWidth, bool useLogScale) - { - float normalizedX = (xPos - plotX) / plotWidth; - normalizedX = Mathf.Clamp01(normalizedX); + GUILayout.Space(4); // Minimal top padding - if (useLogScale) + GUILayout.BeginHorizontal(); + GUILayout.Label(EditorDescription); + GUILayout.FlexibleSpace(); + if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) { - // Inverse of log scale: 10^(normalizedX * log10(maxTime + 1)) - 1 - float logMax = Mathf.Log10(maxTime + 1f); - return Mathf.Pow(10f, normalizedX * logMax) - 1f; + compactView = !compactView; } - else + if (GUILayout.Button("Settings", GUILayout.Width(70))) { - // Linear scale - return normalizedX * maxTime; + showColumnMenu = !showColumnMenu; } - } + GUILayout.EndHorizontal(); - /// - /// Formats time in seconds to a human-readable string in the format: xd xh xm xs - /// Omits zero values (e.g., "1h 30m" instead of "0d 1h 30m 0s") - /// - private string FormatTime(float timeInSeconds) - { - if (timeInSeconds < 0.1f) - return "0s"; - - int totalSeconds = Mathf.RoundToInt(timeInSeconds); - int days = totalSeconds / 86400; - int hours = (totalSeconds % 86400) / 3600; - int minutes = (totalSeconds % 3600) / 60; - int seconds = totalSeconds % 60; - - string result = ""; - if (days > 0) result += $"{days}d "; - if (hours > 0) result += $"{hours}h "; - if (minutes > 0) result += $"{minutes}m "; - if (seconds > 0 || string.IsNullOrEmpty(result)) result += $"{seconds}s"; - - return result.TrimEnd(); - } + GUILayout.Space(4); // Minimal space before table + DrawConfigSelectors(FilteredDisplayConfigs(false)); - /// - /// Convert survival probability to y-position on the chart, using either linear or logarithmic scale. - /// Higher survival appears higher on the chart (100% at top, 0% at bottom). - /// - private float SurvivalProbToYPosition(float survivalProb, float yAxisMin, float plotY, float plotHeight, bool useLogScale) - { - if (useLogScale) - { - // Logarithmic scale: use log10(prob + 0.0001) to handle near-zero values - float logProb = Mathf.Log10(survivalProb + 0.0001f); - float logMax = Mathf.Log10(1f + 0.0001f); // Max is 100% survival - float logMin = Mathf.Log10(yAxisMin + 0.0001f); - float normalizedLog = (logProb - logMin) / (logMax - logMin); - return plotY + plotHeight - (normalizedLog * plotHeight); - } - else + // Draw failure probability chart for current config + if (config != null && config.HasValue("cycleReliabilityStart")) { - // Linear scale: map from yAxisMin (bottom) to 1.0 (top) - float normalizedSurvival = (survivalProb - yAxisMin) / (1f - yAxisMin); - return plotY + plotHeight - (normalizedSurvival * plotHeight); - } - } - - /// - /// Creates a TestFlight-style non-linear reliability curve that maps data units to reliability. - /// Uses the same formula as TestFlight_Generic_Engines.cfg with default parameters: - /// - reliabilityMidV = 0.75 (achieve 75% of improvement at the kink) - /// - reliabilityMidH = 0.4 (kink occurs at 30% of max data, i.e., 3000 du) - /// - reliabilityMidTangentWeight = 0.5 - /// The curve has 3 keys: (0, start), (3000, interpolated), (10000, end) - /// - private FloatCurve CreateReliabilityCurve(float reliabilityStart, float reliabilityEnd) - { - FloatCurve curve = new FloatCurve(); - - // TestFlight works with failure chance, not reliability - float failChanceStart = 1f - reliabilityStart; - float failChanceEnd = 1f - reliabilityEnd; - - // Default TestFlight parameters - const float reliabilityMidV = 0.75f; // 75% of improvement at kink - const float reliabilityMidH = 0.4f; // Kink at 40% through the range - const float reliabilityMidTangentWeight = 0.5f; - const float maxData = 10000f; - - // Key 0: (0 du, failChanceStart) - curve.Add(0f, failChanceStart); - - // Key 1: The "kink" point where you've achieved 75% of the reliability improvement - float key1X = reliabilityMidH * 5000f + 1000f; // = 3000 du - float key1Y = failChanceStart + reliabilityMidV * (failChanceEnd - failChanceStart); - - // Calculate tangent at key 1 (weighted average of two tangents) - float tangentPart1 = (failChanceEnd - failChanceStart) * 0.0001f * reliabilityMidTangentWeight; - float linearTangent = (failChanceEnd - key1Y) / (maxData - key1X); - float tangentPart2 = linearTangent * (1f - reliabilityMidTangentWeight); - float key1Tangent = tangentPart1 + tangentPart2; - - curve.Add(key1X, key1Y, key1Tangent, key1Tangent); - - // Key 2: (10000 du, failChanceEnd) with flat tangent - curve.Add(maxData, failChanceEnd, 0f, 0f); - - return curve; - } - - /// - /// Evaluates reliability at a given data value using TestFlight's non-linear curve. - /// - private float EvaluateReliabilityAtData(float dataUnits, float reliabilityStart, float reliabilityEnd) - { - FloatCurve curve = CreateReliabilityCurve(reliabilityStart, reliabilityEnd); - float failChance = curve.Evaluate(dataUnits); - return 1f - failChance; // Convert back to reliability - } - - private void DrawFailureProbabilityChart(ConfigNode configNode, float width, float height) - { - // Ensure textures are cached to prevent loss on window focus change - EnsureChartTextures(); - - // Values are copied to CONFIG level by ModuleManager patch - // Get TestFlight data for both start and end (we plot both) - if (!configNode.HasValue("cycleReliabilityStart")) return; - if (!configNode.HasValue("cycleReliabilityEnd")) return; - if (!float.TryParse(configNode.GetValue("cycleReliabilityStart"), out float cycleReliabilityStart)) return; - if (!float.TryParse(configNode.GetValue("cycleReliabilityEnd"), out float cycleReliabilityEnd)) return; + GUILayout.Space(6); - // Validate reliability is in valid range - if (cycleReliabilityStart <= 0f || cycleReliabilityStart > 1f) return; - if (cycleReliabilityEnd <= 0f || cycleReliabilityEnd > 1f) return; + // Update chart settings from instance fields + Chart.UseLogScaleX = useLogScaleX; + Chart.UseLogScaleY = useLogScaleY; + Chart.UseSimulatedData = useSimulatedData; + Chart.SimulatedDataValue = simulatedDataValue; + Chart.ClusterSize = clusterSize; + Chart.ClusterSizeInput = clusterSizeInput; + Chart.DataValueInput = dataValueInput; + + // Draw the chart + Chart.Draw(config, guiWindowRect.width - 10, 360); + + // Update instance fields from chart (for UI controls) + useLogScaleX = Chart.UseLogScaleX; + useLogScaleY = Chart.UseLogScaleY; + useSimulatedData = Chart.UseSimulatedData; + simulatedDataValue = Chart.SimulatedDataValue; + clusterSize = Chart.ClusterSize; + clusterSizeInput = Chart.ClusterSizeInput; + dataValueInput = Chart.DataValueInput; - float ratedBurnTime = 0; - if (!configNode.TryGetValue("ratedBurnTime", ref ratedBurnTime) || ratedBurnTime <= 0) return; - - float ratedContinuousBurnTime = ratedBurnTime; - configNode.TryGetValue("ratedContinuousBurnTime", ref ratedContinuousBurnTime); - - // Skip chart if this is a cumulative-limited engine (continuous << total) - if (ratedContinuousBurnTime < ratedBurnTime * 0.9f) return; - - // Read testedBurnTime to match TestFlight's exact behavior - float testedBurnTime = 0f; - bool hasTestedBurnTime = configNode.TryGetValue("testedBurnTime", ref testedBurnTime) && testedBurnTime > ratedBurnTime; - - // Split the area: chart on left (60%), info on right (40%) - float chartWidth = width * 0.58f; - float infoWidth = width * 0.42f; - - float overburnPenalty = 2.0f; // Default from TestFlight_Generic_Engines.cfg - configNode.TryGetValue("overburnPenalty", ref overburnPenalty); - - // Build the actual TestFlight cycle curve - FloatCurve cycleCurve = BuildTestFlightCycleCurve(ratedBurnTime, testedBurnTime, overburnPenalty, hasTestedBurnTime); - - // Main container - Rect containerRect = GUILayoutUtility.GetRect(width, height); - - // Chart area (left side) - const float padding = 38f; - float plotWidth = chartWidth - padding * 2; - float plotHeight = height - padding * 2; - - // Extend max time to show the full cycle curve beyond where it reaches 100× modifier - // The cycle curve reaches maximum at 2.5× (rated or tested), extend to 3.5× to see asymptotic behavior - float maxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f; - - Rect chartRect = new Rect(containerRect.x, containerRect.y, chartWidth, height); - Rect plotArea = new Rect(chartRect.x + padding, chartRect.y + padding, plotWidth, plotHeight); - - // Info panel area (right side) - Rect infoRect = new Rect(containerRect.x + chartWidth, containerRect.y, infoWidth, height); - - // Draw background - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(chartRect, chartBgTex); - } - - // Calculate survival probabilities for both curves to determine Y-axis scale - const int curvePoints = 100; - float[] survivalProbsStart = new float[curvePoints]; - float[] survivalProbsEnd = new float[curvePoints]; - float minSurvivalProb = 1f; - - // Base failure rates (from reliability at rated burn time) - float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; - float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; - - for (int i = 0; i < curvePoints; i++) - { - float t = (i / (float)(curvePoints - 1)) * maxTime; - - // Calculate survival using TestFlight's cycle curve - // For t <= ratedBurnTime: standard exponential reliability - // For t > ratedBurnTime: integrate the cycle modifier to account for varying failure rate - - // Calculate for start (0 data) - float survivalProbStart = 0f; - if (t <= ratedBurnTime) - { - survivalProbStart = Mathf.Pow(cycleReliabilityStart, t / ratedBurnTime); - } - else - { - // Base survival up to rated time - float survivalToRated = cycleReliabilityStart; - - // Integrate cycle modifier from ratedBurnTime to t using numerical integration - float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); - - // Additional failure rate scaled by integrated modifier - float additionalFailRate = baseRateStart * integratedModifier; - - // Total survival = survive to rated * survive additional time - survivalProbStart = Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); - } - survivalProbsStart[i] = survivalProbStart; - minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbStart); - - // Calculate for end (max data) - float survivalProbEnd = 0f; - if (t <= ratedBurnTime) - { - survivalProbEnd = Mathf.Pow(cycleReliabilityEnd, t / ratedBurnTime); - } - else - { - // Base survival up to rated time - float survivalToRated = cycleReliabilityEnd; - - // Integrate cycle modifier from ratedBurnTime to t - float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); - - // Additional failure rate scaled by integrated modifier - float additionalFailRate = baseRateEnd * integratedModifier; - - // Total survival = survive to rated * survive additional time - survivalProbEnd = Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); - } - survivalProbsEnd[i] = survivalProbEnd; - minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbEnd); - } - - // Get current TestFlight data (or use simulated value) - float realCurrentData = TestFlightWrapper.GetCurrentFlightData(part); - float realMaxData = TestFlightWrapper.GetMaximumData(part); - float realDataPercentage = (realMaxData > 0f) ? (realCurrentData / realMaxData) : -1f; - - // Calculate data percentage from simulated or real values - float currentDataValue = useSimulatedData ? simulatedDataValue : realCurrentData; - float maxDataValue = realMaxData > 0f ? realMaxData : 10000f; // Default to 10000 if no TestFlight data - float dataPercentage = (maxDataValue > 0f) ? Mathf.Clamp01(currentDataValue / maxDataValue) : 0f; - bool hasCurrentData = (useSimulatedData && currentDataValue >= 0f) || (realCurrentData >= 0f && realMaxData > 0f); - float[] survivalProbsCurrent = null; - float cycleReliabilityCurrent = 0f; - float baseRateCurrent = 0f; - - if (hasCurrentData) - { - // Use TestFlight's non-linear reliability curve to get current reliability - cycleReliabilityCurrent = EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); - baseRateCurrent = -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime; - survivalProbsCurrent = new float[curvePoints]; - - for (int i = 0; i < curvePoints; i++) - { - float t = (i / (float)(curvePoints - 1)) * maxTime; - float survivalProbCurrent = 0f; - - if (t <= ratedBurnTime) - { - survivalProbCurrent = Mathf.Pow(cycleReliabilityCurrent, t / ratedBurnTime); - } - else - { - float survivalToRated = cycleReliabilityCurrent; - float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, t, 20); - float additionalFailRate = baseRateCurrent * integratedModifier; - survivalProbCurrent = Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate)); - } - - survivalProbsCurrent[i] = survivalProbCurrent; - minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbCurrent); - } - } - - // Apply cluster math: for N engines, cluster survival = (single survival)^N - if (clusterSize > 1) - { - for (int i = 0; i < curvePoints; i++) - { - // Transform each survival probability for cluster - survivalProbsStart[i] = Mathf.Pow(survivalProbsStart[i], clusterSize); - survivalProbsEnd[i] = Mathf.Pow(survivalProbsEnd[i], clusterSize); - - if (hasCurrentData) - { - survivalProbsCurrent[i] = Mathf.Pow(survivalProbsCurrent[i], clusterSize); - } - - // Update min survival probability after cluster transformation - minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsStart[i]); - minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsEnd[i]); - if (hasCurrentData) - minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsCurrent[i]); - } - } - - // Set Y-axis min to 2% below the minimum survival probability (but don't go below 0) - float yAxisMinRaw = Mathf.Max(0f, minSurvivalProb - 0.02f); - - // Round down to a "nice" number for clean axis labels - float yAxisMin = RoundToNiceNumber(yAxisMinRaw, false); - - // Draw grid lines and labels with dynamic scale - var labelStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.grey } }; - - if (useLogScaleY) - { - // Logarithmic Y-axis labels: 0.01%, 0.1%, 1%, 10%, 100% - float[] logValues = { 0.0001f, 0.001f, 0.01f, 0.1f, 1f }; // As fractions - foreach (float survivalProb in logValues) - { - if (survivalProb < yAxisMin) continue; // Don't show labels below min - - float y = SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, useLogScaleY); - Rect lineRect = new Rect(plotArea.x, y, plotArea.width, 1); - if (Event.current.type == EventType.Repaint) - GUI.DrawTexture(lineRect, chartGridMajorTex); - - float labelValue = survivalProb * 100f; - string label = labelValue < 0.1f ? $"{labelValue:F3}%" : (labelValue < 1f ? $"{labelValue:F2}%" : (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%")); - GUI.Label(new Rect(plotArea.x - 35, y - 10, 30, 20), label, labelStyle); - } - } - else - { - // Linear Y-axis: Major gridlines at 20% intervals, minor at 10% - // Draw all gridlines (major + minor) from yAxisMin to 100% - for (int i = 0; i <= 10; i++) - { - bool isMajor = (i % 2 == 0); // Major gridlines at yAxisMin%, ..., 100% - float survivalProb = yAxisMin + (i / 10f) * (1f - yAxisMin); - float y = SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, useLogScaleY); - Rect lineRect = new Rect(plotArea.x, y, plotArea.width, 1); - - if (Event.current.type == EventType.Repaint) - { - // Use major or minor gridline texture - GUI.DrawTexture(lineRect, isMajor ? chartGridMajorTex : chartGridMinorTex); - } - - // Only show labels on major gridlines - if (isMajor) - { - float labelValue = survivalProb * 100f; - string label = labelValue < 1f ? $"{labelValue:F2}%" : (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%"); - GUI.Label(new Rect(plotArea.x - 35, y - 10, 30, 20), label, labelStyle); - } - } - } - - // Draw zone backgrounds based on TestFlight cycle curve segments - // Zone boundaries match the cycle curve keys - float startupEndX = TimeToXPosition(5f, maxTime, plotArea.x, plotArea.width, useLogScaleX); // End of startup zone (0-5s) - float ratedCushionedX = TimeToXPosition(ratedBurnTime + 5f, maxTime, plotArea.x, plotArea.width, useLogScaleX); // Rated + 5s cushion - float testedX = hasTestedBurnTime ? TimeToXPosition(testedBurnTime, maxTime, plotArea.x, plotArea.width, useLogScaleX) : 0f; - - // Calculate 100× modifier point (at 2.5× the reference burn time) - float referenceBurnTime = hasTestedBurnTime ? testedBurnTime : ratedBurnTime; - float max100xTime = referenceBurnTime * 2.5f; - float max100xX = TimeToXPosition(max100xTime, maxTime, plotArea.x, plotArea.width, useLogScaleX); - - // Clamp all zone boundaries to plot area to prevent drawing outside - float plotAreaRight = plotArea.x + plotArea.width; - startupEndX = Mathf.Clamp(startupEndX, plotArea.x, plotAreaRight); - ratedCushionedX = Mathf.Clamp(ratedCushionedX, plotArea.x, plotAreaRight); - testedX = Mathf.Clamp(testedX, plotArea.x, plotAreaRight); - max100xX = Mathf.Clamp(max100xX, plotArea.x, plotAreaRight); - - if (Event.current.type == EventType.Repaint) - { - // Zone 1: Startup (0-5s) - Dark blue (high initial risk) - float zone1Width = Mathf.Max(0, startupEndX - plotArea.x); - if (zone1Width > 0) - GUI.DrawTexture(new Rect(plotArea.x, plotArea.y, zone1Width, plotArea.height), chartStartupZoneTex); - - // Zone 2: Rated Operation (5s to ratedBurnTime+5) - Green (safe zone) - float zone2Width = Mathf.Max(0, ratedCushionedX - startupEndX); - if (zone2Width > 0) - GUI.DrawTexture(new Rect(startupEndX, plotArea.y, zone2Width, plotArea.height), chartGreenZoneTex); - - if (hasTestedBurnTime) - { - // Zone 3: Tested Overburn (rated+5 to tested) - Yellow (reduced penalty overburn) - float zone3Width = Mathf.Max(0, testedX - ratedCushionedX); - if (zone3Width > 0) - GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, zone3Width, plotArea.height), chartYellowZoneTex); - - // Zone 4: Severe Overburn (tested to 100×) - Red (danger zone) - float zone4Width = Mathf.Max(0, max100xX - testedX); - if (zone4Width > 0) - GUI.DrawTexture(new Rect(testedX, plotArea.y, zone4Width, plotArea.height), chartRedZoneTex); - - // Zone 5: Maximum Overburn (100× to end) - Darker red (nearly linear failure increase) - float zone5Width = Mathf.Max(0, plotAreaRight - max100xX); - if (zone5Width > 0) - GUI.DrawTexture(new Rect(max100xX, plotArea.y, zone5Width, plotArea.height), chartDarkRedZoneTex); - } - else - { - // Zone 3: Overburn (rated+5 to 100×) - Red (danger zone) - float zone3Width = Mathf.Max(0, max100xX - ratedCushionedX); - if (zone3Width > 0) - GUI.DrawTexture(new Rect(ratedCushionedX, plotArea.y, zone3Width, plotArea.height), chartRedZoneTex); - - // Zone 4: Maximum Overburn (100× to end) - Darker red (nearly linear failure increase) - float zone4Width = Mathf.Max(0, plotAreaRight - max100xX); - if (zone4Width > 0) - GUI.DrawTexture(new Rect(max100xX, plotArea.y, zone4Width, plotArea.height), chartDarkRedZoneTex); - } - } - - // Check if mouse is hovering over any vertical separator for thick line rendering - Vector2 mousePos = Event.current.mousePosition; - bool mouseInPlot = plotArea.Contains(mousePos); - bool nearStartupMarker = mouseInPlot && Mathf.Abs(mousePos.x - startupEndX) < 8f; - bool nearRatedMarker = mouseInPlot && Mathf.Abs(mousePos.x - ratedCushionedX) < 8f; - bool nearTestedMarker = mouseInPlot && hasTestedBurnTime && Mathf.Abs(mousePos.x - testedX) < 8f; - bool near100xMarker = mouseInPlot && Mathf.Abs(mousePos.x - max100xX) < 8f; - - // Draw vertical zone separators - only if within plot area, thicker when hovering - if (Event.current.type == EventType.Repaint) - { - // Startup zone end (5s) - Blue - if (startupEndX >= plotArea.x && startupEndX <= plotAreaRight) - { - float lineWidth = nearStartupMarker ? 4f : 1f; - GUI.DrawTexture(new Rect(startupEndX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerBlueTex); - } - - // Rated burn time (+ 5s cushion) - Green - if (ratedCushionedX >= plotArea.x && ratedCushionedX <= plotAreaRight) - { - float lineWidth = nearRatedMarker ? 4f : 1f; - GUI.DrawTexture(new Rect(ratedCushionedX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerGreenTex); - } - - // Tested burn time (if present) - Yellow - if (hasTestedBurnTime && testedX >= plotArea.x && testedX <= plotAreaRight) - { - float lineWidth = nearTestedMarker ? 4f : 1f; - GUI.DrawTexture(new Rect(testedX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerYellowTex); - } - - // 100× modifier point (maximum cycle penalty) - Dark Red - if (max100xX >= plotArea.x && max100xX <= plotAreaRight) - { - float lineWidth = near100xMarker ? 4f : 1f; - GUI.DrawTexture(new Rect(max100xX - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), chartMarkerDarkRedTex); - } - } - - // Now calculate point positions for all curves using the dynamic Y scale - Vector2[] pointsStart = new Vector2[curvePoints]; - Vector2[] pointsEnd = new Vector2[curvePoints]; - Vector2[] pointsCurrent = hasCurrentData ? new Vector2[curvePoints] : null; - - for (int i = 0; i < curvePoints; i++) - { - float t = (i / (float)(curvePoints - 1)) * maxTime; - float x = TimeToXPosition(t, maxTime, plotArea.x, plotArea.width, useLogScaleX); - - // Start curve (0 data) - orange - float yStart = SurvivalProbToYPosition(survivalProbsStart[i], yAxisMin, plotArea.y, plotArea.height, useLogScaleY); - if (float.IsNaN(x) || float.IsNaN(yStart) || float.IsInfinity(x) || float.IsInfinity(yStart)) - { - x = plotArea.x; - yStart = plotArea.y + plotArea.height; - } - pointsStart[i] = new Vector2(x, yStart); - - // End curve (max data) - green - float yEnd = SurvivalProbToYPosition(survivalProbsEnd[i], yAxisMin, plotArea.y, plotArea.height, useLogScaleY); - if (float.IsNaN(x) || float.IsNaN(yEnd) || float.IsInfinity(x) || float.IsInfinity(yEnd)) - { - x = plotArea.x; - yEnd = plotArea.y + plotArea.height; - } - pointsEnd[i] = new Vector2(x, yEnd); - - // Current data curve - light blue - if (hasCurrentData) - { - float yCurrent = SurvivalProbToYPosition(survivalProbsCurrent[i], yAxisMin, plotArea.y, plotArea.height, useLogScaleY); - if (float.IsNaN(x) || float.IsNaN(yCurrent) || float.IsInfinity(x) || float.IsInfinity(yCurrent)) - { - x = plotArea.x; - yCurrent = plotArea.y + plotArea.height; - } - pointsCurrent[i] = new Vector2(x, yCurrent); - } - } - - // Draw all curves (with clipping to plot area) - if (Event.current.type == EventType.Repaint) - { - // Draw start curve (0 data) in orange - for (int i = 0; i < pointsStart.Length - 1; i++) - { - // Skip line segments outside the plot area - if (pointsStart[i].x > plotArea.x + plotArea.width && pointsStart[i + 1].x > plotArea.x + plotArea.width) - continue; - if (pointsStart[i].x < plotArea.x && pointsStart[i + 1].x < plotArea.x) - continue; - - DrawLine(pointsStart[i], pointsStart[i + 1], chartOrangeLineTex, 2.5f); - } - - // Draw end curve (max data) in green - for (int i = 0; i < pointsEnd.Length - 1; i++) - { - // Skip line segments outside the plot area - if (pointsEnd[i].x > plotArea.x + plotArea.width && pointsEnd[i + 1].x > plotArea.x + plotArea.width) - continue; - if (pointsEnd[i].x < plotArea.x && pointsEnd[i + 1].x < plotArea.x) - continue; - - DrawLine(pointsEnd[i], pointsEnd[i + 1], chartGreenLineTex, 2.5f); - } - - // Draw current data curve in light blue - if (hasCurrentData && pointsCurrent != null) - { - for (int i = 0; i < pointsCurrent.Length - 1; i++) - { - // Skip line segments outside the plot area - if (pointsCurrent[i].x > plotArea.x + plotArea.width && pointsCurrent[i + 1].x > plotArea.x + plotArea.width) - continue; - if (pointsCurrent[i].x < plotArea.x && pointsCurrent[i + 1].x < plotArea.x) - continue; - - DrawLine(pointsCurrent[i], pointsCurrent[i + 1], chartBlueLineTex, 2.5f); - } - } - } - - // X-axis labels (time in minutes) - var timeStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.grey }, alignment = TextAnchor.UpperCenter }; - - if (useLogScaleX) - { - // Logarithmic scale labels: show key time points - float[] logTimes = { 0.1f, 1f, 10f, 60f, 300f, 600f, 1800f, 3600f }; - foreach (float time in logTimes) - { - if (time > maxTime) break; - float x = TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, useLogScaleX); - string label = FormatTime(time); - GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), label, timeStyle); - } - } - else - { - // Linear scale labels - for (int i = 0; i <= 4; i++) - { - float time = (i / 4f) * maxTime; - float x = TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, useLogScaleX); - GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20), FormatTime(time), timeStyle); - } - } - - // Chart title - var titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, fontStyle = FontStyle.Bold, normal = { textColor = Color.white }, alignment = TextAnchor.MiddleCenter }; - GUI.Label(new Rect(chartRect.x, chartRect.y + 4, chartWidth, 24), "Survival Probability vs Burn Time", titleStyle); - - // Legend with colored circles (positioned at top right) - var legendStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, normal = { textColor = Color.white }, alignment = TextAnchor.UpperLeft }; - float legendWidth = 110f; // Approximate width of legend items - float legendX = plotArea.x + plotArea.width - legendWidth; - float legendY = plotArea.y + 5; - - // Orange circle and line for 0 data - DrawCircle(new Rect(legendX, legendY + 5, 8, 8), chartOrangeLineTex); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 7, 15, 3), chartOrangeLineTex); - GUI.Label(new Rect(legendX + 28, legendY, 80, 18), "0 Data", legendStyle); - - // Blue circle and line for current data (if available) - if (hasCurrentData) - { - DrawCircle(new Rect(legendX, legendY + 23, 8, 8), chartBlueLineTex); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), chartBlueLineTex); - GUI.Label(new Rect(legendX + 28, legendY + 18, 100, 18), "Current Data", legendStyle); - - // Green circle and line for max data (shifted down) - DrawCircle(new Rect(legendX, legendY + 41, 8, 8), chartGreenLineTex); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 43, 15, 3), chartGreenLineTex); - GUI.Label(new Rect(legendX + 28, legendY + 36, 80, 18), "Max Data", legendStyle); - } - else - { - // Green circle and line for max data (no shift if no current data) - DrawCircle(new Rect(legendX, legendY + 23, 8, 8), chartGreenLineTex); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), chartGreenLineTex); - GUI.Label(new Rect(legendX + 28, legendY + 18, 80, 18), "Max Data", legendStyle); - } - - // Tooltip handling and hover line - if (mouseInPlot) - { - // Draw vertical hover line (but not when hovering over a marker) - bool hoveringAnyMarker = nearStartupMarker || nearRatedMarker || nearTestedMarker || near100xMarker; - if (Event.current.type == EventType.Repaint && !hoveringAnyMarker) - { - GUI.DrawTexture(new Rect(mousePos.x, plotArea.y, 1, plotArea.height), chartHoverLineTex); - } - - // Calculate the time at mouse position - float mouseT = XPositionToTime(mousePos.x, maxTime, plotArea.x, plotArea.width, useLogScaleX); - mouseT = Mathf.Clamp(mouseT, 0f, maxTime); - - // Determine which zone we're in - string zoneName = ""; - if (mouseT <= 5f) - { - zoneName = "Engine Startup"; - } - else if (mouseT <= ratedBurnTime + 5f) - { - zoneName = "Rated Operation"; - } - else if (hasTestedBurnTime && mouseT <= testedBurnTime) - { - zoneName = "Tested Overburn"; - } - else if (mouseT <= max100xTime) - { - zoneName = "Severe Overburn"; - } - else - { - zoneName = "Maximum Overburn"; - } - - // Calculate cycle modifier at this time - float cycleModifier = cycleCurve.Evaluate(mouseT); - - string tooltipText = ""; - string valueColor = "#E6D68A"; // Muted yellow for time values and numeric values - - if (nearStartupMarker) - { - tooltipText = $"Startup Period End\n\nFailure risk drops from 10× to 1× during startup.\nAfter 5 seconds, the engine reaches stable operation."; - } - else if (nearRatedMarker) - { - string ratedTimeStr = FormatTime(ratedBurnTime); - tooltipText = $"Rated Burn Time\n\nThis engine is designed to run for {ratedTimeStr}.\nBeyond this point, overburn penalties increase failure risk."; - } - else if (nearTestedMarker) - { - string testedTimeStr = FormatTime(testedBurnTime); - tooltipText = $"Tested Overburn Limit\n\nThis engine was tested to {testedTimeStr} in real life.\nFailure risk reaches {overburnPenalty:F1}× at this point.\nBeyond here, risk increases rapidly toward certain failure."; - } - else if (near100xMarker) - { - string max100xTimeStr = FormatTime(max100xTime); - tooltipText = $"Maximum Cycle Penalty (100×)\n\nAt {max100xTimeStr}, the failure rate multiplier reaches its maximum of 100×.\n\nBeyond this point, it doesn't get much worse—failure probability increases nearly linearly with time."; - } - else - { - // Calculate survival probabilities at mouse position - float mouseSurviveStart = 0f; - float mouseSurviveEnd = 0f; - float mouseSurviveCurrent = 0f; - - if (mouseT <= ratedBurnTime) - { - mouseSurviveStart = Mathf.Pow(cycleReliabilityStart, mouseT / ratedBurnTime); - mouseSurviveEnd = Mathf.Pow(cycleReliabilityEnd, mouseT / ratedBurnTime); - if (hasCurrentData) - mouseSurviveCurrent = Mathf.Pow(cycleReliabilityCurrent, mouseT / ratedBurnTime); - } - else - { - float survivalToRatedStart = cycleReliabilityStart; - float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, mouseT, 20); - float additionalFailRate = baseRateStart * integratedModifier; - mouseSurviveStart = Mathf.Clamp01(survivalToRatedStart * Mathf.Exp(-additionalFailRate)); - - float survivalToRatedEnd = cycleReliabilityEnd; - additionalFailRate = baseRateEnd * integratedModifier; - mouseSurviveEnd = Mathf.Clamp01(survivalToRatedEnd * Mathf.Exp(-additionalFailRate)); - - if (hasCurrentData) - { - float survivalToRatedCurrent = cycleReliabilityCurrent; - additionalFailRate = baseRateCurrent * integratedModifier; - mouseSurviveCurrent = Mathf.Clamp01(survivalToRatedCurrent * Mathf.Exp(-additionalFailRate)); - } - } - - // Apply cluster math to tooltip values (cluster survival = single^N) - if (clusterSize > 1) - { - mouseSurviveStart = Mathf.Pow(mouseSurviveStart, clusterSize); - mouseSurviveEnd = Mathf.Pow(mouseSurviveEnd, clusterSize); - if (hasCurrentData) - mouseSurviveCurrent = Mathf.Pow(mouseSurviveCurrent, clusterSize); - } - - // Format time string - string timeStr = FormatTime(mouseT); - - // Color code the zone name based on zone type - string zoneColor = ""; - if (mouseT <= 5f) - zoneColor = "#6699CC"; // Blue for startup - else if (mouseT <= ratedBurnTime + 5f) - zoneColor = "#66DD66"; // Green for rated - else if (hasTestedBurnTime && mouseT <= testedBurnTime) - zoneColor = "#FFCC44"; // Yellow for tested overburn - else if (mouseT <= max100xTime) - zoneColor = "#FF6666"; // Red for severe overburn - else - zoneColor = "#CC2222"; // Dark red for maximum overburn - - // Build simplified tooltip - string orangeColor = "#FF8033"; // Match orange line (0 data) - string blueColor = "#7DD9FF"; // Match lighter blue line (current data) - string greenColor = "#4DE64D"; // Match green line (max data) - - string entityName = clusterSize > 1 ? "cluster" : "engine"; - - tooltipText = $"{zoneName}\n\n"; - - // Show survival percentages in X%/X%/X% format - if (hasCurrentData) - { - tooltipText += $"This {entityName} has a {mouseSurviveStart * 100f:F1}% / {mouseSurviveCurrent * 100f:F1}% / {mouseSurviveEnd * 100f:F1}% chance to survive to {timeStr}\n\n"; - } - else - { - tooltipText += $"This {entityName} has a {mouseSurviveStart * 100f:F1}% / {mouseSurviveEnd * 100f:F1}% chance to survive to {timeStr}\n\n"; - } - - tooltipText += $"Cycle modifier: {cycleModifier:F2}×"; - } - - // Store tooltip to draw last (after info panel) so it appears on top - string finalTooltipText = tooltipText; - Vector2 finalMousePos = mousePos; - - // Draw info panel first - float ignitionReliabilityStart = 1f; - float ignitionReliabilityEnd = 1f; - configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); - configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); - - // Calculate current ignition reliability using TestFlight's non-linear curve - float ignitionReliabilityCurrent = hasCurrentData ? EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd) : 0f; - - DrawFailureInfoPanel(infoRect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, - cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, - hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, - currentDataValue, maxDataValue, realCurrentData, realMaxData); - - // Draw tooltip last so it appears on top of everything - DrawChartTooltip(finalMousePos, finalTooltipText); - } - else - { - // No hover, just draw info panel - float ignitionReliabilityStart = 1f; - float ignitionReliabilityEnd = 1f; - configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); - configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); - - // Calculate current ignition reliability using TestFlight's non-linear curve - float ignitionReliabilityCurrent = hasCurrentData ? EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd) : 0f; - - DrawFailureInfoPanel(infoRect, ratedBurnTime, testedBurnTime, hasTestedBurnTime, - cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, - hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, - currentDataValue, maxDataValue, realCurrentData, realMaxData); - } - } - - private void DrawFailureInfoPanel(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, - float cycleReliabilityStart, float cycleReliabilityEnd, float ignitionReliabilityStart, float ignitionReliabilityEnd, - bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage, - float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData) - { - // Draw background - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(rect, infoPanelBgTex); - } - - // Calculate success probabilities (chance to complete the burn) - float ratedSuccessStart = cycleReliabilityStart * 100f; - float ratedSuccessEnd = cycleReliabilityEnd * 100f; - float ignitionSuccessStart = ignitionReliabilityStart * 100f; - float ignitionSuccessEnd = ignitionReliabilityEnd * 100f; - - // Calculate tested burn success if available - float testedSuccessStart = 0f; - float testedSuccessEnd = 0f; - if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) - { - // Use the cycle reliability for the full tested duration - float testedRatio = testedBurnTime / ratedBurnTime; - testedSuccessStart = Mathf.Pow(cycleReliabilityStart, testedRatio) * 100f; - testedSuccessEnd = Mathf.Pow(cycleReliabilityEnd, testedRatio) * 100f; - } - - // Calculate current data success probabilities - float ratedSuccessCurrent = 0f; - float testedSuccessCurrent = 0f; - float ignitionSuccessCurrent = 0f; - if (hasCurrentData) - { - ratedSuccessCurrent = cycleReliabilityCurrent * 100f; - ignitionSuccessCurrent = ignitionReliabilityCurrent * 100f; - if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) - { - float testedRatio = testedBurnTime / ratedBurnTime; - testedSuccessCurrent = Mathf.Pow(cycleReliabilityCurrent, testedRatio) * 100f; - } - } - - // Apply cluster math: for N engines all succeeding = (singleSuccess)^N - if (clusterSize > 1) - { - // Convert from percentage to decimal, apply power, convert back - ignitionSuccessStart = Mathf.Pow(ignitionSuccessStart / 100f, clusterSize) * 100f; - ignitionSuccessEnd = Mathf.Pow(ignitionSuccessEnd / 100f, clusterSize) * 100f; - ratedSuccessStart = Mathf.Pow(ratedSuccessStart / 100f, clusterSize) * 100f; - ratedSuccessEnd = Mathf.Pow(ratedSuccessEnd / 100f, clusterSize) * 100f; - testedSuccessStart = Mathf.Pow(testedSuccessStart / 100f, clusterSize) * 100f; - testedSuccessEnd = Mathf.Pow(testedSuccessEnd / 100f, clusterSize) * 100f; - - if (hasCurrentData) - { - ignitionSuccessCurrent = Mathf.Pow(ignitionSuccessCurrent / 100f, clusterSize) * 100f; - ratedSuccessCurrent = Mathf.Pow(ratedSuccessCurrent / 100f, clusterSize) * 100f; - testedSuccessCurrent = Mathf.Pow(testedSuccessCurrent / 100f, clusterSize) * 100f; - } - } - - // Style for rich text labels - var textStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 15, - normal = { textColor = Color.white }, - wordWrap = true, - richText = true, - padding = new RectOffset(8, 8, 2, 2) - }; - - var headerStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 17, - fontStyle = FontStyle.Bold, - normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }, - wordWrap = true, - richText = true, - padding = new RectOffset(8, 8, 0, 4) // No top padding to align with chart title - }; - - var sectionStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 16, - fontStyle = FontStyle.Bold, - normal = { textColor = new Color(1f, 0.5f, 0.2f) }, // Orange for 0 data - wordWrap = true, - richText = true, - padding = new RectOffset(8, 8, 4, 2) - }; - - // Color codes matching the chart lines - string orangeColor = "#FF8033"; // 0 data - string blueColor = "#7DD9FF"; // Current data (lighter blue) - string greenColor = "#4DE64D"; // Max data - string valueColor = "#E6D68A"; // Time values - faded yellow - - // Start at same vertical position as chart title for alignment - float yPos = rect.y + 4; - - // === COMBINED RELIABILITY SECTION === - // Color-coded section header - string headerText = $"At Starting"; - if (hasCurrentData) - { - string dataLabel = maxDataValue > 0f ? $"{currentDataValue:F0} du" : $"{dataPercentage * 100f:F0}%"; - headerText += $" / Current ({dataLabel})"; - } - headerText += $" / Max:"; - - sectionStyle.normal.textColor = Color.white; // Use white for base, colors come from rich text - GUI.Label(new Rect(rect.x, yPos, rect.width, 20), headerText, sectionStyle); - yPos += 24; - - // Build single narrative with all values - string engineText = clusterSize > 1 ? $"A cluster of {clusterSize} engines" : "This engine"; - string forAll = clusterSize > 1 ? " for all" : ""; - string combinedText = $"{engineText} has a "; - - // Ignition success rates (with larger font for percentages) - if (hasCurrentData) - combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessCurrent:F1}% / {ignitionSuccessEnd:F1}%"; - else - combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessEnd:F1}%"; - - combinedText += $" chance{forAll} to ignite, then a "; - - // Rated burn success rates (with larger font for percentages) - if (hasCurrentData) - combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessCurrent:F1}% / {ratedSuccessEnd:F1}%"; - else - combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessEnd:F1}%"; - - combinedText += $" chance{forAll} to burn for {FormatTime(ratedBurnTime)} (rated)"; - - // Tested burn success rates (if applicable, with larger font for percentages) - if (hasTestedBurnTime) - { - combinedText += ", and a "; - if (hasCurrentData) - combinedText += $"{testedSuccessStart:F1}% / {testedSuccessCurrent:F1}% / {testedSuccessEnd:F1}%"; - else - combinedText += $"{testedSuccessStart:F1}% / {testedSuccessEnd:F1}%"; - - combinedText += $" chance{forAll} to burn to {FormatTime(testedBurnTime)} (tested)"; - } - combinedText += "."; - - float combinedHeight = textStyle.CalcHeight(new GUIContent(combinedText), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, combinedHeight), combinedText, textStyle); - yPos += combinedHeight + 12; - - // === SIDE-BY-SIDE LAYOUT: DATA GAINS (LEFT) AND CONTROLS (RIGHT) === - // Separator line across full width - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), chartSeparatorTex); - } - yPos += 10; - - // Split the panel into two columns - float columnStartY = yPos; - float leftColumnWidth = rect.width * 0.5f; - float rightColumnWidth = rect.width * 0.5f; - float leftColumnX = rect.x; - float rightColumnX = rect.x + leftColumnWidth; - - // Track heights of both columns to know where to place bottom separator - float leftColumnEndY = columnStartY; - float rightColumnEndY = columnStartY; - - // === LEFT COLUMN: DATA GAINS === - float leftYPos = columnStartY; - string purpleColor = "#CCB3FF"; // Light purple - matches section header - - // Calculate data collection info - float ratedContinuousBurnTime = ratedBurnTime; - float dataRate = 640f / ratedContinuousBurnTime; - float duPerFlight = dataRate * ratedBurnTime; - - // Section header - sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); // Light purple/lavender - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, 20), "How To Gain Data:", sectionStyle); - leftYPos += 24; - - // Styles - var bulletStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 14, - normal = { textColor = Color.white }, - wordWrap = false, - richText = true, - padding = new RectOffset(8, 8, 1, 1) - }; - - var indentedBulletStyle = new GUIStyle(bulletStyle) - { - padding = new RectOffset(24, 8, 1, 1) - }; - - var footerStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 11, - normal = { textColor = new Color(0.6f, 0.6f, 0.6f) }, - padding = new RectOffset(8, 8, 1, 1) - }; - - float bulletHeight = 18; - - // Failures section - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), "An engine can fail in 4 ways:", bulletStyle); - leftYPos += bulletHeight; - - // In-flight failures (based on weight distribution: total = 58) - // Values capped at 1000 du (per-flight max) - string[] failureTypes = { "Shutdown", "Perf. Loss", "Reduced Thrust", "Explode" }; - int[] failureDu = { 1000, 800, 700, 1000 }; - float[] failurePercents = { 55.2f, 27.6f, 13.8f, 3.4f }; // weights: 32, 16, 8, 2 out of 58 - - for (int i = 0; i < failureTypes.Length; i++) - { - string failText = $" ({failurePercents[i]:F0}%) {failureTypes[i]} +{failureDu[i]} du"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), failText, indentedBulletStyle); - leftYPos += bulletHeight; - } - - leftYPos += 4; - - // Running gains - string runningText = $"Running gains {dataRate:F1} du/s"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), runningText, bulletStyle); - leftYPos += bulletHeight; - - // Ignition failure (separate from cycle failures) - string ignitionText = $"Ignition Fail +1000 du"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), ignitionText, bulletStyle); - leftYPos += bulletHeight + 8; - - // Footer note - string footerText = "(no more than 1000 du per flight)"; - GUI.Label(new Rect(leftColumnX, leftYPos, leftColumnWidth, bulletHeight), footerText, footerStyle); - leftYPos += bulletHeight; - - leftColumnEndY = leftYPos + 8; - - // === RIGHT COLUMN: SIMULATION CONTROLS === - float rightYPos = columnStartY; - bool hasRealData = realCurrentData >= 0f && realMaxData > 0f; - - var controlStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 12, - fontStyle = FontStyle.Bold, - normal = { textColor = new Color(0.8f, 0.8f, 0.8f) }, - padding = new RectOffset(8, 8, 2, 2) - }; - - var sliderStyle = GUI.skin.horizontalSlider; - var thumbStyle = GUI.skin.horizontalSliderThumb; - var buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 12, fontStyle = FontStyle.Bold }; - var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - - // Section header - sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); // Light purple/lavender - GUI.Label(new Rect(rightColumnX, rightYPos, rightColumnWidth, 20), "Simulate:", sectionStyle); - rightYPos += 24; - - // Reset button (full width) - float resetBtnWidth = rightColumnWidth - 16; - string resetButtonText = hasRealData ? $"Set to Current du ({realCurrentData:F0})" : "Set to Current du (0)"; - if (GUI.Button(new Rect(rightColumnX + 8, rightYPos, resetBtnWidth, 20), resetButtonText, buttonStyle)) - { - // Reset data value - if (hasRealData) - { - simulatedDataValue = realCurrentData; - dataValueInput = $"{realCurrentData:F0}"; - useSimulatedData = false; - } - else - { - simulatedDataValue = 0f; - dataValueInput = "0"; - useSimulatedData = true; - } - - // Reset cluster - clusterSize = 1; - clusterSizeInput = "1"; - } - rightYPos += 24; - - // Define width for slider controls - float btnWidth = rightColumnWidth - 16; - - // Data slider - GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Data (du)", controlStyle); - rightYPos += 16; - - // Initialize simulated value to real data if not yet simulating - if (!useSimulatedData) - { - simulatedDataValue = hasRealData ? realCurrentData : 0f; - dataValueInput = $"{simulatedDataValue:F0}"; - } - - // Data slider - simulatedDataValue = GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), - simulatedDataValue, 0f, maxDataValue, sliderStyle, thumbStyle); - - // Mark as simulated if user moved slider away from real data - if (hasRealData && Mathf.Abs(simulatedDataValue - realCurrentData) > 0.1f) - { - useSimulatedData = true; - } - else if (!hasRealData && simulatedDataValue > 0.1f) - { - useSimulatedData = true; - } - - // Data input field - dataValueInput = $"{simulatedDataValue:F0}"; - GUI.SetNextControlName("dataValueInput"); - string newDataInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), - dataValueInput, 6, inputStyle); - - if (newDataInput != dataValueInput) - { - dataValueInput = newDataInput; - if (GUI.GetNameOfFocusedControl() == "dataValueInput" && float.TryParse(dataValueInput, out float inputDataValue)) - { - inputDataValue = Mathf.Clamp(inputDataValue, 0f, maxDataValue); - simulatedDataValue = inputDataValue; - useSimulatedData = true; - } - } - rightYPos += 24; - - // Cluster slider - GUI.Label(new Rect(rightColumnX + 8, rightYPos, btnWidth, 16), "Cluster", controlStyle); - rightYPos += 16; - - clusterSize = Mathf.RoundToInt(GUI.HorizontalSlider(new Rect(rightColumnX + 8, rightYPos, btnWidth - 50, 16), - clusterSize, 1f, 100f, sliderStyle, thumbStyle)); - - // Cluster input field - clusterSizeInput = clusterSize.ToString(); - GUI.SetNextControlName("clusterSizeInput"); - string newClusterInput = GUI.TextField(new Rect(rightColumnX + btnWidth - 35, rightYPos - 2, 40, 20), - clusterSizeInput, 3, inputStyle); - - if (newClusterInput != clusterSizeInput) - { - clusterSizeInput = newClusterInput; - if (GUI.GetNameOfFocusedControl() == "clusterSizeInput" && int.TryParse(clusterSizeInput, out int inputCluster)) - { - inputCluster = Mathf.Clamp(inputCluster, 1, 100); - clusterSize = inputCluster; - clusterSizeInput = clusterSize.ToString(); - } - } - rightYPos += 24; - - rightColumnEndY = rightYPos; - - // Draw vertical separator between columns - if (Event.current.type == EventType.Repaint) - { - float separatorX = rect.x + leftColumnWidth; - float separatorHeight = Mathf.Max(leftColumnEndY, rightColumnEndY) - columnStartY; - GUI.DrawTexture(new Rect(separatorX, columnStartY, 1, separatorHeight), chartSeparatorTex); - } - - // Use the taller column to determine where to place bottom separator - yPos = Mathf.Max(leftColumnEndY, rightColumnEndY) + 8; - - // Draw horizontal separator - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), chartSeparatorTex); - } - yPos += 10; - - // === FAILURE RATE SECTION === - // Calculate failure rate based on current data slider value - float cycleReliabilityAtCurrentData = EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); - - // Apply cluster math if needed - if (clusterSize > 1) - { - cycleReliabilityAtCurrentData = Mathf.Pow(cycleReliabilityAtCurrentData, clusterSize); - } - - float failureRate = 1f - cycleReliabilityAtCurrentData; - float oneInX = failureRate > 0.0001f ? (1f / failureRate) : 9999f; - - // Display in large font - var failureRateStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 18, - fontStyle = FontStyle.Bold, - normal = { textColor = Color.white }, - alignment = TextAnchor.MiddleCenter, - richText = true - }; - - string failureRateColor = "#FF6666"; // Light red to emphasize the failure rate - string failureText = $"With {currentDataValue:F0} du, 1 in {oneInX:F1} rated burns will fail ({FormatTime(ratedBurnTime)})"; - float failureTextHeight = failureRateStyle.CalcHeight(new GUIContent(failureText), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, failureTextHeight), failureText, failureRateStyle); - yPos += failureTextHeight + 8; - } - - private void DrawLine(Vector2 start, Vector2 end, Texture2D texture, float width) - { - if (texture == null) - return; - - Vector2 diff = end - start; - float angle = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg; - float length = diff.magnitude; - - Matrix4x4 matrixBackup = GUI.matrix; - try - { - GUIUtility.RotateAroundPivot(angle, start); - GUI.DrawTexture(new Rect(start.x, start.y - width / 2, length, width), texture); - } - finally - { - GUI.matrix = matrixBackup; - } - } - - private void DrawCircle(Rect rect, Texture2D texture) - { - if (texture == null || Event.current.type != EventType.Repaint) - return; - - // Draw circle by drawing a filled square with rounded appearance - // For simplicity, we'll draw concentric squares that approximate a circle - float centerX = rect.x + rect.width / 2f; - float centerY = rect.y + rect.height / 2f; - float radius = rect.width / 2f; - - for (float r = radius; r > 0; r -= 0.5f) - { - float size = r * 2f; - GUI.DrawTexture(new Rect(centerX - r, centerY - r, size, size), texture); - } - } - - private float RoundToNiceNumber(float value, bool roundUp) - { - if (value <= 0f) return 0f; - - // Find the order of magnitude - float exponent = Mathf.Floor(Mathf.Log10(value)); - float fraction = value / Mathf.Pow(10f, exponent); - - // Round to 1, 2, or 5 times the magnitude - float niceFraction; - if (roundUp) - { - if (fraction <= 1f) niceFraction = 1f; - else if (fraction <= 2f) niceFraction = 2f; - else if (fraction <= 5f) niceFraction = 5f; - else niceFraction = 10f; - } - else - { - if (fraction < 1.5f) niceFraction = 1f; - else if (fraction < 3.5f) niceFraction = 2f; - else if (fraction < 7.5f) niceFraction = 5f; - else niceFraction = 10f; - } - - return niceFraction * Mathf.Pow(10f, exponent); - } - - private void DrawChartTooltip(Vector2 mousePos, string text) - { - if (string.IsNullOrEmpty(text)) return; - - var tooltipStyle = new GUIStyle(GUI.skin.box) - { - fontSize = 15, - normal = { textColor = Color.white, background = chartTooltipBgTex }, - padding = new RectOffset(8, 8, 6, 6), - alignment = TextAnchor.MiddleLeft, - wordWrap = false, - richText = true - }; - - GUIContent content = new GUIContent(text); - Vector2 size = tooltipStyle.CalcSize(content); - - // Position tooltip offset from mouse, but keep it within screen bounds - float tooltipX = mousePos.x + 15; - float tooltipY = mousePos.y + 15; - - // Adjust if tooltip would go off-screen - if (tooltipX + size.x > Screen.width) - tooltipX = mousePos.x - size.x - 5; - if (tooltipY + size.y > Screen.height) - tooltipY = mousePos.y - size.y - 5; - - Rect tooltipRect = new Rect(tooltipX, tooltipY, size.x, size.y); - GUI.Box(tooltipRect, content, tooltipStyle); - } - - virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) - { - DrawConfigTable(BuildConfigRows()); - } - - - protected void DrawTechLevelSelector() - { - // NK Tech Level - if (techLevel != -1) - { - GUILayout.BeginHorizontal(); - - GUILayout.Label($"{Localizer.GetStringByTag("#RF_Engine_TechLevel")}: "); // Tech Level - string minusStr = "X"; - bool canMinus = false; - if (TechLevel.CanTL(config, techNodes, engineType, techLevel - 1) && techLevel > minTechLevel) - { - minusStr = "-"; - canMinus = true; - } - if (GUILayout.Button(minusStr) && canMinus) - { - techLevel--; - SetConfiguration(); - UpdateSymmetryCounterparts(); - MarkWindowDirty(); - } - GUILayout.Label(techLevel.ToString()); - string plusStr = "X"; - bool canPlus = false; - bool canBuy = false; - string tlName = Utilities.GetPartName(part) + configuration; - double tlIncrMult = (double)(techLevel + 1 - origTechLevel); - if (TechLevel.CanTL(config, techNodes, engineType, techLevel + 1) && techLevel < maxTechLevel) - { - if (UnlockedTL(tlName, techLevel + 1)) - { - plusStr = "+"; - canPlus = true; - } - else - { - double cost = EntryCostManager.Instance.TLEntryCost(tlName) * tlIncrMult; - double sciCost = EntryCostManager.Instance.TLSciEntryCost(tlName) * tlIncrMult; - bool autobuy = true; - plusStr = string.Empty; - if (cost > 0d) - { - plusStr += cost.ToString("N0") + "√"; - autobuy = false; - canBuy = true; - } - if (sciCost > 0d) - { - if (cost > 0d) - plusStr += "/"; - autobuy = false; - canBuy = true; - plusStr += sciCost.ToString("N1") + "s"; - } - if (autobuy) - { - // auto-upgrade - EntryCostManager.Instance.SetTLUnlocked(tlName, techLevel + 1); - plusStr = "+"; - canPlus = true; - canBuy = false; - } - } - } - if (GUILayout.Button(plusStr) && (canPlus || canBuy)) - { - if (!canBuy || EntryCostManager.Instance.PurchaseTL(tlName, techLevel + 1, tlIncrMult)) - { - techLevel++; - SetConfiguration(); - UpdateSymmetryCounterparts(); - MarkWindowDirty(); - } - } - GUILayout.EndHorizontal(); - } - } - - private void EngineManagerGUI(int WindowID) - { - // Use BeginVertical with GUILayout.ExpandHeight(false) to prevent extra vertical space - GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); - - GUILayout.Space(4); // Minimal top padding - - GUILayout.BeginHorizontal(); - GUILayout.Label(EditorDescription); - GUILayout.FlexibleSpace(); - if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) - { - compactView = !compactView; - } - if (GUILayout.Button("Settings", GUILayout.Width(70))) - { - showColumnMenu = !showColumnMenu; - } - GUILayout.EndHorizontal(); - - GUILayout.Space(4); // Minimal space before table - DrawConfigSelectors(FilteredDisplayConfigs(false)); - - // Draw failure probability chart for current config - if (config != null && config.HasValue("cycleReliabilityStart")) - { - GUILayout.Space(6); - DrawFailureProbabilityChart(config, guiWindowRect.width - 10, 360); GUILayout.Space(6); // Consistent small space after chart } - DrawTechLevelSelector(); + TechLevels.DrawTechLevelSelector(); GUILayout.Space(4); // Minimal bottom padding @@ -4370,7 +2537,7 @@ protected void ConfigSaveLoad() protected static PartModule GetSpecifiedModule(Part p, string eID, int mIdx, string eType, bool weakType) => GetSpecifiedModules(p, eID, mIdx, eType, weakType).FirstOrDefault(); private static readonly List _searchList = new List(); - protected static List GetSpecifiedModules(Part p, string eID, int mIdx, string eType, bool weakType) + internal static List GetSpecifiedModules(Part p, string eID, int mIdx, string eType, bool weakType) { int mCount = p.Modules.Count; int tmpIdx = 0; @@ -4442,10 +2609,10 @@ public virtual bool Validate(out string validationError, out bool canBeResolved, ConfigNode node = GetConfigByName(configuration); - if (UnlockedConfig(node, part)) return true; + if (EngineConfigTechLevels.UnlockedConfig(node, part)) return true; techToResolve = config.GetValue("techRequired"); - if (!CanConfig(node)) + if (!EngineConfigTechLevels.CanConfig(node)) { validationError = $"{Localizer.GetStringByTag("#RF_Engine_unlocktech")} {ResearchAndDevelopment.GetTechnologyTitle(techToResolve)}"; // unlock tech canBeResolved = false; diff --git a/Source/RealFuels.csproj b/Source/RealFuels.csproj index be97f524..ff396fc5 100644 --- a/Source/RealFuels.csproj +++ b/Source/RealFuels.csproj @@ -90,6 +90,14 @@ + + + + + + + + From 4b0952f0ce37662b53180c7c879a0ee727a3a9dd Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 22:15:55 -0800 Subject: [PATCH 07/12] Even more reorganization of the engine config UI code. --- Source/Engines/EngineConfigGUI.cs | 120 -- Source/Engines/ModuleBimodalEngineConfigs.cs | 7 +- Source/Engines/ModuleEngineConfigs.cs | 1313 +---------------- Source/Engines/{ => UI}/ChartMath.cs | 0 Source/Engines/{ => UI}/EngineConfigChart.cs | 0 Source/Engines/UI/EngineConfigGUI.cs | 1004 +++++++++++++ .../Engines/{ => UI}/EngineConfigInfoPanel.cs | 0 Source/Engines/{ => UI}/EngineConfigStyles.cs | 0 .../Engines/{ => UI}/EngineConfigTextures.cs | 0 9 files changed, 1050 insertions(+), 1394 deletions(-) delete mode 100644 Source/Engines/EngineConfigGUI.cs rename Source/Engines/{ => UI}/ChartMath.cs (100%) rename Source/Engines/{ => UI}/EngineConfigChart.cs (100%) create mode 100644 Source/Engines/UI/EngineConfigGUI.cs rename Source/Engines/{ => UI}/EngineConfigInfoPanel.cs (100%) rename Source/Engines/{ => UI}/EngineConfigStyles.cs (100%) rename Source/Engines/{ => UI}/EngineConfigTextures.cs (100%) diff --git a/Source/Engines/EngineConfigGUI.cs b/Source/Engines/EngineConfigGUI.cs deleted file mode 100644 index a73fb7d4..00000000 --- a/Source/Engines/EngineConfigGUI.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using UnityEngine; -using KSP.Localization; - -namespace RealFuels -{ - /// - /// Handles all GUI rendering for ModuleEngineConfigs. - /// Manages the configuration selector window, column visibility, tooltips, and user interaction. - /// - public class EngineConfigGUI - { - private readonly ModuleEngineConfigsBase _module; - private readonly EngineConfigTechLevels _techLevels; - private readonly EngineConfigTextures _textures; - private EngineConfigChart _chart; - - // GUI state - private static Vector3 mousePos = Vector3.zero; - private static Rect guiWindowRect = new Rect(0, 0, 0, 0); - private static uint lastPartId = 0; - private static int lastConfigCount = 0; - private static bool lastCompactView = false; - private static bool lastHasChart = false; - private string myToolTip = string.Empty; - private int counterTT; - private bool editorLocked = false; - - private Vector2 configScrollPos = Vector2.zero; - private GUIContent configGuiContent; - private bool compactView = false; - private bool useLogScaleX = false; - private bool useLogScaleY = false; - - // Column visibility customization - private bool showColumnMenu = false; - private static Rect columnMenuRect = new Rect(100, 100, 280, 650); - private static bool[] columnsVisibleFull = new bool[18]; - private static bool[] columnsVisibleCompact = new bool[18]; - private static bool columnVisibilityInitialized = false; - - // Simulation controls - private bool useSimulatedData = false; - private float simulatedDataValue = 0f; - private int clusterSize = 1; - private string clusterSizeInput = "1"; - private string dataValueInput = "0"; - - private const int ConfigRowHeight = 22; - private const int ConfigMaxVisibleRows = 16; - private float[] ConfigColumnWidths = new float[18]; - - private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; - private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); - - public EngineConfigGUI(ModuleEngineConfigsBase module) - { - _module = module; - _techLevels = new EngineConfigTechLevels(module); - _textures = EngineConfigTextures.Instance; - } - - private EngineConfigChart Chart - { - get - { - if (_chart == null) - { - _chart = new EngineConfigChart(_module); - _chart.UseLogScaleX = useLogScaleX; - _chart.UseLogScaleY = useLogScaleY; - _chart.UseSimulatedData = useSimulatedData; - _chart.SimulatedDataValue = simulatedDataValue; - _chart.ClusterSize = clusterSize; - } - return _chart; - } - } - - #region Main GUI Entry Point - - public void OnGUI() - { - // GUI rendering code will go here - // This is a placeholder - will be filled with actual implementation - } - - #endregion - - #region Helper Methods - - internal void MarkWindowDirty() - { - lastPartId = 0; - } - - private void EditorLock() - { - if (!editorLocked) - { - EditorLogic.fetch.Lock(true, true, true, _module.GetInstanceID().ToString()); - editorLocked = true; - } - } - - private void EditorUnlock() - { - if (editorLocked) - { - EditorLogic.fetch.Unlock(_module.GetInstanceID().ToString()); - editorLocked = false; - } - } - - #endregion - } -} diff --git a/Source/Engines/ModuleBimodalEngineConfigs.cs b/Source/Engines/ModuleBimodalEngineConfigs.cs index a4eeb88c..d2a93f81 100644 --- a/Source/Engines/ModuleBimodalEngineConfigs.cs +++ b/Source/Engines/ModuleBimodalEngineConfigs.cs @@ -121,7 +121,7 @@ public override string GetConfigInfo(ConfigNode config, bool addDescription = tr return info; } - protected override IEnumerable BuildConfigRows() + public override IEnumerable BuildConfigRows() { foreach (var node in FilteredDisplayConfigs(false)) { @@ -143,12 +143,11 @@ protected override IEnumerable BuildConfigRows() } } - protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) + protected internal override void DrawConfigSelectors(IEnumerable availableConfigNodes) { + // Add custom toggle button UI before the config table if (GUILayout.Button(new GUIContent(ToggleText, toggleButtonHoverInfo))) ToggleMode(); - - DrawConfigTable(BuildConfigRows()); } [KSPAction("#RF_BimodalEngine_ToggleEngineMode")] // Toggle Engine Mode diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index 1ec21cf9..bc9e3c92 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -159,7 +159,7 @@ public override string GetConfigDisplayName(ConfigNode node) return node.GetValue(PatchNameKey); // Just show subconfig name without parent prefix } - protected override IEnumerable BuildConfigRows() + public override IEnumerable BuildConfigRows() { foreach (var node in FilteredDisplayConfigs(false)) { @@ -197,11 +197,6 @@ protected override IEnumerable BuildConfigRows() } } } - - protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) - { - DrawConfigTable(BuildConfigRows()); - } } public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassModifier @@ -210,7 +205,7 @@ public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassM public const string groupName = ModuleEnginesRF.groupName; public const string groupDisplayName = "#RF_Engine_EngineConfigs"; // "Engine Configs" #region Fields - protected bool compatible = true; + internal bool compatible = true; [KSPField(isPersistant = true)] public string configuration = string.Empty; @@ -361,7 +356,7 @@ public static void RelocateRCSPawItems(ModuleRCS module) field.group = new BasePAWGroup(groupName, groupDisplayName, false); } - protected List FilteredDisplayConfigs(bool update) + internal List FilteredDisplayConfigs(bool update) { if (update || filteredDisplayConfigs == null) { @@ -821,7 +816,7 @@ virtual public void SetConfiguration(string newConfiguration = null, bool resetT } #region SetConfiguration Tools - private Dictionary ExtractGimbals(ConfigNode cfg) + internal Dictionary ExtractGimbals(ConfigNode cfg) { Gimbal ExtractGimbalKeys(ConfigNode c) { @@ -1138,44 +1133,6 @@ public void OnDestroy() // are shared across all instances. They'll be cleaned up when Unity unloads the scene. } - private static Vector3 mousePos = Vector3.zero; - private static Rect guiWindowRect = new Rect(0, 0, 0, 0); - private static uint lastPartId = 0; - private static int lastConfigCount = 0; - private static bool lastCompactView = false; - private static bool lastHasChart = false; - private string myToolTip = string.Empty; - private int counterTT; - private bool editorLocked = false; - - private Vector2 configScrollPos = Vector2.zero; - private GUIContent configGuiContent; - private bool compactView = false; - private bool useLogScaleX = false; // Toggle for logarithmic x-axis on failure chart - private bool useLogScaleY = false; // Toggle for logarithmic y-axis on failure chart - - // Column visibility customization - private bool showColumnMenu = false; - private static Rect columnMenuRect = new Rect(100, 100, 280, 650); // Separate window rect - tall enough for all columns - private static bool[] columnsVisibleFull = new bool[18]; - private static bool[] columnsVisibleCompact = new bool[18]; - private static bool columnVisibilityInitialized = false; - - // Simulation controls for data percentage and cluster size - private bool useSimulatedData = false; // Whether to override real TestFlight data - private float simulatedDataValue = 0f; // Simulated data value in du (data units) - private int clusterSize = 1; // Number of engines in cluster (default 1) - private string clusterSizeInput = "1"; // Text input for cluster size - private string dataValueInput = "0"; // Text input for data value in du - - private const int ConfigRowHeight = 22; - private const int ConfigMaxVisibleRows = 16; // Max rows before scrolling (60% taller) - // Dynamic column widths - calculated based on content - private float[] ConfigColumnWidths = new float[18]; - - // Texture and style management - using singletons to prevent memory leaks - private EngineConfigTextures Textures => EngineConfigTextures.Instance; - // Tech level management - lazy initialization private EngineConfigTechLevels _techLevels; protected EngineConfigTechLevels TechLevels @@ -1200,244 +1157,22 @@ internal EngineConfigIntegrations Integrations } } - // Chart rendering - lazy initialization - private EngineConfigChart _chart; - private EngineConfigChart Chart + // GUI rendering - lazy initialization + private EngineConfigGUI _gui; + private EngineConfigGUI GUI { get { - if (_chart == null) - { - _chart = new EngineConfigChart(this); - _chart.UseLogScaleX = useLogScaleX; - _chart.UseLogScaleY = useLogScaleY; - _chart.UseSimulatedData = useSimulatedData; - _chart.SimulatedDataValue = simulatedDataValue; - _chart.ClusterSize = clusterSize; - } - return _chart; - } - } - - private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; - private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); - - public void OnGUI() - { - if (!compatible || !isMaster || !HighLogic.LoadedSceneIsEditor || EditorLogic.fetch == null) - return; - - bool inPartsEditor = EditorLogic.fetch.editorScreen == EditorScreen.Parts; - if (!(showRFGUI && inPartsEditor) && !(EditorLogic.fetch.editorScreen == EditorScreen.Actions && EditorActionGroups.Instance.GetSelectedParts().Contains(part))) - { - EditorUnlock(); - return; - } - - if (inPartsEditor && part.symmetryCounterparts.FirstOrDefault(p => p.persistentId < part.persistentId) is Part) - return; - - if (guiWindowRect.width == 0) - { - int posAdd = inPartsEditor ? 256 : 0; - int posMult = (offsetGUIPos == -1) ? (part.Modules.Contains("ModuleFuelTanks") ? 1 : 0) : offsetGUIPos; - // Set position with minimal initial size - both width and height will auto-size tightly - guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 100, 100); - } - - // Only reset height when switching parts or when content changes (compact view, config count, chart visibility) - // This prevents flickering during dragging and slider interaction - uint currentPartId = part.persistentId; - int currentConfigCount = FilteredDisplayConfigs(false).Count; - bool currentHasChart = config != null && config.HasValue("cycleReliabilityStart"); - bool contentChanged = currentPartId != lastPartId - || currentConfigCount != lastConfigCount - || compactView != lastCompactView - || currentHasChart != lastHasChart; - - if (contentChanged) - { - float savedX = guiWindowRect.x; - float savedY = guiWindowRect.y; - float savedWidth = guiWindowRect.width; - guiWindowRect = new Rect(savedX, savedY, savedWidth, 100); - - lastPartId = currentPartId; - lastConfigCount = currentConfigCount; - lastCompactView = compactView; - lastHasChart = currentHasChart; - } - - mousePos = Input.mousePosition; //Mouse location; based on Kerbal Engineer Redux code - mousePos.y = Screen.height - mousePos.y; - if (guiWindowRect.Contains(mousePos)) - EditorLock(); - else - EditorUnlock(); - - myToolTip = myToolTip.Trim(); - if (!string.IsNullOrEmpty(myToolTip)) - { - int offset = inPartsEditor ? -222 : 440; - GUI.Label(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, toolTipHeight), myToolTip, Styles.styleEditorTooltip); - } - - guiWindowRect = GUILayout.Window(unchecked((int)part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", part.partInfo.title), Styles.styleEditorPanel); // "Configure " + part.partInfo.title - - // Draw column menu as separate window if open - if (showColumnMenu) - { - columnMenuRect = GUI.Window(unchecked((int)part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Settings", HighLogic.Skin.window); - } - } - - private void DrawColumnMenuWindow(int windowID) - { - DrawColumnMenu(new Rect(0, 20, columnMenuRect.width, columnMenuRect.height - 20)); - GUI.DragWindow(new Rect(0, 0, columnMenuRect.width, 20)); - } - - private void EditorLock() - { - if (!editorLocked) - { - EditorLogic.fetch.Lock(false, false, false, "RFGUILock"); - editorLocked = true; - KSP.UI.Screens.Editor.PartListTooltipMasterController.Instance?.HideTooltip(); - } - } - - private void EditorUnlock() - { - if (editorLocked) - { - EditorLogic.fetch.Unlock("RFGUILock"); - editorLocked = false; - } - } - - protected string GetCostString(ConfigNode node) - { - string costString = string.Empty; - if (node.HasValue("cost")) - { - float curCost = scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture); - - if (techLevel != -1) - { - curCost = TechLevels.CostTL(curCost, node) - TechLevels.CostTL(0f, node); // get purely the config cost difference - } - costString = $" ({((curCost < 0) ? string.Empty : "+")}{curCost:N0}√)"; + if (_gui == null) + _gui = new EngineConfigGUI(this); + return _gui; } - return costString; } - /// Normal apply action for the 'select ' button. - protected void GUIApplyConfig(string configName) - { - SetConfiguration(configName, true); - UpdateSymmetryCounterparts(); - GameEvents.onEditorShipModified.Fire(EditorLogic.fetch.ship); - MarkWindowDirty(); - } - - protected void DrawSelectButton(ConfigNode node, bool isSelected, Action apply) - { - var nName = node.GetValue("name"); - var dispName = GetConfigDisplayName(node); - var costString = GetCostString(node); - var configInfo = GetConfigInfo(node); - - using (new GUILayout.HorizontalScope()) - { - // For simulations, RP-1 will allow selecting all configs despite tech status or whether the entry cost has been paid. - // The KCT that comes with RP-1 will call the Validate() method when the player tries to add a vessel to the build queue. - if (Utilities.RP1Found) - { - // Currently selected. - if (isSelected) - { - GUILayout.Label(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_CurrentConfig")}: {dispName}{costString}", configInfo)); // Current config - } - else if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to - apply(nName); - - if (!EngineConfigTechLevels.UnlockedConfig(node, part)) - { - double upgradeCost = EntryCostManager.Instance.ConfigEntryCost(nName); - string techRequired = node.GetValue("techRequired"); - if (upgradeCost <= 0) - { - // Auto-buy. - EntryCostManager.Instance.PurchaseConfig(nName, techRequired); - } - - bool isConfigAvailable = EngineConfigTechLevels.CanConfig(node); - string tooltip = string.Empty; - if (!isConfigAvailable && techNameToTitle.TryGetValue(techRequired, out string techStr)) - { - tooltip = Localizer.Format("#RF_Engine_LacksTech", techStr); // $"Lacks tech for {techStr}" - } - - GUI.enabled = isConfigAvailable; - if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} ({upgradeCost:N0}√)", tooltip), GUILayout.Width(145))) // Purchase - { - if (EntryCostManager.Instance.PurchaseConfig(nName, node.GetValue("techRequired"))) - apply(nName); - } - GUI.enabled = true; - } - } - else - { - // Currently selected. - if (isSelected) - { - GUILayout.Label(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_CurrentConfig")}: {dispName}{costString}", configInfo)); // Current config - return; - } - - // Locked. - if (!EngineConfigTechLevels.CanConfig(node)) - { - if (techNameToTitle.TryGetValue(node.GetValue("techRequired"), out string techStr)) - techStr = $"\n{Localizer.GetStringByTag("#RF_Engine_Requires")}: " + techStr; // Requires - GUILayout.Label(new GUIContent(Localizer.Format("#RF_Engine_LacksTech", dispName), configInfo + techStr)); // $"Lacks tech for {dispName}" - return; - } - - // Available. - if (EngineConfigTechLevels.UnlockedConfig(node, part)) - { - if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to - apply(nName); - return; - } - - // Purchase. - double upgradeCost = EntryCostManager.Instance.ConfigEntryCost(nName); - string techRequired = node.GetValue("techRequired"); - if (upgradeCost > 0d) - { - costString = $" ({upgradeCost:N0}√)"; - if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} {dispName}{costString}", configInfo))) // Purchase - { - if (EntryCostManager.Instance.PurchaseConfig(nName, techRequired)) - apply(nName); - } - } - else - { - // Auto-buy. - EntryCostManager.Instance.PurchaseConfig(nName, techRequired); - if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to - apply(nName); - } - } - } - } - - protected struct ConfigRowDefinition + /// + /// Struct for passing configuration row data to GUI + /// + public struct ConfigRowDefinition { public ConfigNode Node; public string DisplayName; @@ -1446,9 +1181,13 @@ protected struct ConfigRowDefinition public Action Apply; } - protected virtual IEnumerable BuildConfigRows() + /// + /// Builds the list of configuration rows to display in the GUI. + /// Virtual so derived classes can customize the row structure. + /// + public virtual IEnumerable BuildConfigRows() { - foreach (ConfigNode node in FilteredDisplayConfigs(false)) + foreach (var node in FilteredDisplayConfigs(false)) { string configName = node.GetValue("name"); yield return new ConfigRowDefinition @@ -1462,1009 +1201,43 @@ protected virtual IEnumerable BuildConfigRows() } } - protected void CalculateColumnWidths(List rows) - { - // Create style for measuring cell content - var cellStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 14, - fontStyle = FontStyle.Bold, - padding = new RectOffset(5, 0, 0, 0) - }; - - // Initialize with minimum widths - for (int i = 0; i < ConfigColumnWidths.Length; i++) - { - ConfigColumnWidths[i] = 30f; // Start with minimum - } - - // Measure all row content (ignore headers - they're rotated and centered) - foreach (var row in rows) - { - string nameText = row.DisplayName; - if (row.Indent) nameText = " ↳ " + nameText; - - string[] cellValues = new string[] - { - nameText, - GetThrustString(row.Node), - GetMinThrottleString(row.Node), - GetIspString(row.Node), - GetMassString(row.Node), - GetGimbalString(row.Node), - GetIgnitionsString(row.Node), - GetBoolSymbol(row.Node, "ullage"), - GetBoolSymbol(row.Node, "pressureFed"), - GetRatedBurnTimeString(row.Node), - GetTestedBurnTimeString(row.Node), // NEW: Tested burn time column - GetIgnitionReliabilityStartString(row.Node), - GetIgnitionReliabilityEndString(row.Node), - GetCycleReliabilityStartString(row.Node), - GetCycleReliabilityEndString(row.Node), - GetTechString(row.Node), - GetCostDeltaString(row.Node), - "" // Action column - buttons - }; - - for (int i = 0; i < cellValues.Length; i++) - { - if (!string.IsNullOrEmpty(cellValues[i])) - { - float width = cellStyle.CalcSize(new GUIContent(cellValues[i])).x + 10f; // Add padding - if (width > ConfigColumnWidths[i]) - ConfigColumnWidths[i] = width; - } - } - } - - // Action column needs fixed width for two buttons - ConfigColumnWidths[17] = 160f; - - // Set minimum widths for specific columns - ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); // Ull - ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); // PFed - ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); // Rated burn - ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f); // Tested burn - } - - protected void DrawConfigTable(IEnumerable rows) - { - EnsureTexturesAndStyles(); - - var rowList = rows.ToList(); - - // Calculate dynamic column widths - CalculateColumnWidths(rowList); - - // Sum only visible column widths - float totalWidth = 0f; - for (int i = 0; i < ConfigColumnWidths.Length; i++) - { - if (IsColumnVisible(i)) - totalWidth += ConfigColumnWidths[i]; - } - - // Update window width to fit table exactly (accounting for window padding: 5px left + 5px right = 10px) - float requiredWindowWidth = totalWidth + 10f; // Table width + padding - const float minWindowWidth = 900f; // Minimum width to prevent squishing - guiWindowRect.width = Mathf.Max(requiredWindowWidth, minWindowWidth); - - Rect headerRowRect = GUILayoutUtility.GetRect(GUIContent.none, GUI.skin.label, GUILayout.Height(45)); - float headerStartX = headerRowRect.x; // No left margin - DrawHeaderRow(new Rect(headerStartX, headerRowRect.y, totalWidth, headerRowRect.height)); - - // Dynamic height: grow up to max, then scroll - int actualRows = rowList.Count; - int visibleRows = Mathf.Min(actualRows, ConfigMaxVisibleRows); - int scrollViewHeight = visibleRows * ConfigRowHeight; - - // No spacing in scroll view - var scrollStyle = new GUIStyle(GUI.skin.scrollView) { padding = new RectOffset(0, 0, 0, 0) }; - configScrollPos = GUILayout.BeginScrollView(configScrollPos, false, false, GUIStyle.none, GUI.skin.verticalScrollbar, scrollStyle, GUILayout.Height(scrollViewHeight)); - - // Use a style with no margin/padding for tight row spacing - var noSpaceStyle = new GUIStyle { margin = new RectOffset(0, 0, 0, 0), padding = new RectOffset(0, 0, 0, 0) }; - - int rowIndex = 0; - foreach (var row in rowList) - { - Rect rowRect = GUILayoutUtility.GetRect(GUIContent.none, noSpaceStyle, GUILayout.Height(ConfigRowHeight)); - float rowStartX = rowRect.x; // No left margin - Rect tableRowRect = new Rect(rowStartX, rowRect.y, totalWidth, rowRect.height); - bool isHovered = tableRowRect.Contains(Event.current.mousePosition); - - bool isLocked = !EngineConfigTechLevels.CanConfig(row.Node); - if (Event.current.type == EventType.Repaint) - { - // Draw alternating row background first - if (!row.IsSelected && !isLocked && !isHovered && rowIndex % 2 == 1) - { - GUI.DrawTexture(tableRowRect, Textures.ZebraStripe); - } - - if (row.IsSelected) - GUI.DrawTexture(tableRowRect, Textures.RowCurrent); - else if (isLocked) - GUI.DrawTexture(tableRowRect, Textures.RowLocked); - else if (isHovered) - GUI.DrawTexture(tableRowRect, Textures.RowHover); - } - - string tooltip = GetRowTooltip(row.Node); - if (configGuiContent == null) - configGuiContent = new GUIContent(); - configGuiContent.text = string.Empty; - configGuiContent.tooltip = tooltip; - GUI.Label(tableRowRect, configGuiContent, GUIStyle.none); - - DrawConfigRow(tableRowRect, row, isHovered, isLocked); - - // Draw column separators - if (Event.current.type == EventType.Repaint) - { - DrawColumnSeparators(tableRowRect); - } - - rowIndex++; - } - - GUILayout.EndScrollView(); - } - - private void DrawColumnMenu(Rect menuRect) - { - InitializeColumnVisibility(); - - // Column names - string[] columnNames = { - "Name", "Thrust", "Min%", "ISP", "Mass", "Gimbal", - "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)", - "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", - "Tech", "Cost", "Actions" - }; - - float yPos = menuRect.y + 10; - float leftX = menuRect.x + 10; - float rightX = menuRect.x + menuRect.width / 2 + 5; - - // Use cached menu styles - GUIStyle headerStyle = EngineConfigStyles.MenuHeader; - GUIStyle labelStyle = EngineConfigStyles.MenuLabel; - - // Title - GUI.Label(new Rect(leftX, yPos, menuRect.width - 20, 20), "Column Visibility", headerStyle); - yPos += 25; - - // Separator - if (Event.current.type == EventType.Repaint) - { - Texture2D separatorTex = Styles.CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.5f)); - GUI.DrawTexture(new Rect(leftX, yPos, menuRect.width - 20, 1), separatorTex); - } - yPos += 10; - - // Headers for Full and Compact - GUI.Label(new Rect(leftX + 100, yPos, 60, 20), "Full", headerStyle); - GUI.Label(new Rect(leftX + 170, yPos, 60, 20), "Compact", headerStyle); - yPos += 25; - - // Scrollable area for columns - Rect scrollRect = new Rect(leftX, yPos, menuRect.width - 20, menuRect.height - 80); - Rect viewRect = new Rect(0, 0, scrollRect.width - 20, columnNames.Length * 25); - - GUI.BeginGroup(scrollRect); - float itemY = 0; - - for (int i = 0; i < columnNames.Length; i++) - { - // Column name - GUI.Label(new Rect(5, itemY, 90, 20), columnNames[i], labelStyle); - - // Full view checkbox - bool newFullVisible = GUI.Toggle(new Rect(105, itemY, 20, 20), columnsVisibleFull[i], ""); - if (newFullVisible != columnsVisibleFull[i]) - { - columnsVisibleFull[i] = newFullVisible; - } - - // Compact view checkbox - bool newCompactVisible = GUI.Toggle(new Rect(175, itemY, 20, 20), columnsVisibleCompact[i], ""); - if (newCompactVisible != columnsVisibleCompact[i]) - { - columnsVisibleCompact[i] = newCompactVisible; - } - - itemY += 25; - } - - GUI.EndGroup(); - - // Close button - if (GUI.Button(new Rect(menuRect.x + menuRect.width - 60, menuRect.y + menuRect.height - 30, 50, 20), "Close")) - { - showColumnMenu = false; - } - } - - private void DrawHeaderRow(Rect headerRect) - { - float currentX = headerRect.x; - if (IsColumnVisible(0)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[0], headerRect.height), - "Name", "Configuration name"); - currentX += ConfigColumnWidths[0]; - } - if (IsColumnVisible(1)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[1], headerRect.height), - Localizer.GetStringByTag("#RF_EngineRF_Thrust"), "Rated thrust"); - currentX += ConfigColumnWidths[1]; - } - if (IsColumnVisible(2)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[2], headerRect.height), - "Min%", "Minimum throttle"); - currentX += ConfigColumnWidths[2]; - } - if (IsColumnVisible(3)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[3], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_Isp"), "Sea level and vacuum Isp"); - currentX += ConfigColumnWidths[3]; - } - if (IsColumnVisible(4)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[4], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_Enginemass"), "Engine mass"); - currentX += ConfigColumnWidths[4]; - } - if (IsColumnVisible(5)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[5], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_TLTInfo_Gimbal"), "Gimbal range"); - currentX += ConfigColumnWidths[5]; - } - if (IsColumnVisible(6)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[6], headerRect.height), - Localizer.GetStringByTag("#RF_EngineRF_Ignitions"), "Ignitions"); - currentX += ConfigColumnWidths[6]; - } - if (IsColumnVisible(7)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[7], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_ullage"), "Ullage requirement"); - currentX += ConfigColumnWidths[7]; - } - if (IsColumnVisible(8)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[8], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_pressureFed"), "Pressure-fed"); - currentX += ConfigColumnWidths[8]; - } - if (IsColumnVisible(9)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[9], headerRect.height), - "Rated (s)", "Rated burn time"); - currentX += ConfigColumnWidths[9]; - } - if (IsColumnVisible(10)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[10], headerRect.height), - "Tested (s)", "Tested burn time (real-world test duration)"); - currentX += ConfigColumnWidths[10]; - } - if (IsColumnVisible(11)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[11], headerRect.height), - "Ign No Data", "Ignition reliability at 0 data"); - currentX += ConfigColumnWidths[11]; - } - if (IsColumnVisible(12)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[12], headerRect.height), - "Ign Max Data", "Ignition reliability at max data"); - currentX += ConfigColumnWidths[12]; - } - if (IsColumnVisible(13)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[13], headerRect.height), - "Burn No Data", "Cycle reliability at 0 data"); - currentX += ConfigColumnWidths[13]; - } - if (IsColumnVisible(14)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[14], headerRect.height), - "Burn Max Data", "Cycle reliability at max data"); - currentX += ConfigColumnWidths[14]; - } - if (IsColumnVisible(15)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[15], headerRect.height), - Localizer.GetStringByTag("#RF_Engine_Requires"), "Required technology"); - currentX += ConfigColumnWidths[15]; - } - if (IsColumnVisible(16)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[16], headerRect.height), - "Extra Cost", "Extra cost for this config"); - currentX += ConfigColumnWidths[16]; - } - if (IsColumnVisible(17)) { - DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[17], headerRect.height), - "", "Switch and purchase actions"); // No label, just tooltip - } - } - - private void DrawColumnSeparators(Rect rowRect) - { - float currentX = rowRect.x; - for (int i = 0; i < ConfigColumnWidths.Length - 1; i++) - { - if (IsColumnVisible(i)) - { - currentX += ConfigColumnWidths[i]; - Rect separatorRect = new Rect(currentX, rowRect.y, 1, rowRect.height); - GUI.DrawTexture(separatorRect, Textures.ColumnSeparator); - } - } - } - - private void DrawHeaderCell(Rect rect, string text, string tooltip) - { - bool hover = rect.Contains(Event.current.mousePosition); - GUIStyle headerStyle = hover ? EngineConfigStyles.HeaderCellHover : EngineConfigStyles.HeaderCell; - - if (configGuiContent == null) - configGuiContent = new GUIContent(); - configGuiContent.text = text; - configGuiContent.tooltip = tooltip; - Matrix4x4 matrixBackup = GUI.matrix; - // Start text at horizontal center of column - float offsetX = rect.width / 2f; - Vector2 pivot = new Vector2(rect.x + offsetX, rect.y + rect.height + 4f); - GUIUtility.RotateAroundPivot(-45f, pivot); - GUI.Label(new Rect(rect.x + offsetX, rect.y + rect.height - 22f, 140f, 24f), configGuiContent, headerStyle); - GUI.matrix = matrixBackup; - } - - private void DrawConfigRow(Rect rowRect, ConfigRowDefinition row, bool isHovered, bool isLocked) - { - // Use cached styles instead of creating new ones every frame - GUIStyle primaryStyle; - if (isLocked) - primaryStyle = EngineConfigStyles.RowPrimaryLocked; - else if (isHovered) - primaryStyle = EngineConfigStyles.RowPrimaryHover; - else - primaryStyle = EngineConfigStyles.RowPrimary; - - GUIStyle secondaryStyle = EngineConfigStyles.RowSecondary; - - float currentX = rowRect.x; - string nameText = row.DisplayName; - if (row.Indent) nameText = " ↳ " + nameText; - - if (IsColumnVisible(0)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[0], rowRect.height), nameText, primaryStyle); - currentX += ConfigColumnWidths[0]; - } - - if (IsColumnVisible(1)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[1], rowRect.height), GetThrustString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[1]; - } - - if (IsColumnVisible(2)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[2], rowRect.height), GetMinThrottleString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[2]; - } - - if (IsColumnVisible(3)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[3], rowRect.height), GetIspString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[3]; - } - - if (IsColumnVisible(4)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[4], rowRect.height), GetMassString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[4]; - } - - if (IsColumnVisible(5)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[5], rowRect.height), GetGimbalString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[5]; - } - - if (IsColumnVisible(6)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[6], rowRect.height), GetIgnitionsString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[6]; - } - - if (IsColumnVisible(7)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[7], rowRect.height), GetBoolSymbol(row.Node, "ullage"), secondaryStyle); - currentX += ConfigColumnWidths[7]; - } - - if (IsColumnVisible(8)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[8], rowRect.height), GetBoolSymbol(row.Node, "pressureFed"), secondaryStyle); - currentX += ConfigColumnWidths[8]; - } - - if (IsColumnVisible(9)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[9], rowRect.height), GetRatedBurnTimeString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[9]; - } - - if (IsColumnVisible(10)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[10], rowRect.height), GetTestedBurnTimeString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[10]; - } - - if (IsColumnVisible(11)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[11], rowRect.height), GetIgnitionReliabilityStartString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[11]; - } - - if (IsColumnVisible(12)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[12], rowRect.height), GetIgnitionReliabilityEndString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[12]; - } - - if (IsColumnVisible(13)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[13], rowRect.height), GetCycleReliabilityStartString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[13]; - } - - if (IsColumnVisible(14)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[14], rowRect.height), GetCycleReliabilityEndString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[14]; - } - - if (IsColumnVisible(15)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[15], rowRect.height), GetTechString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[15]; - } - - if (IsColumnVisible(16)) { - GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[16], rowRect.height), GetCostDeltaString(row.Node), secondaryStyle); - currentX += ConfigColumnWidths[16]; - } - - if (IsColumnVisible(17)) { - DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[17], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); - } - } - - private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action apply) - { - GUIStyle smallButtonStyle = EngineConfigStyles.SmallButton; - - string configName = node.GetValue("name"); - bool canUse = EngineConfigTechLevels.CanConfig(node); - bool unlocked = EngineConfigTechLevels.UnlockedConfig(node, part); - double cost = EntryCostManager.Instance.ConfigEntryCost(configName); - - // Auto-purchase free configs - if (cost <= 0 && !unlocked && canUse) - EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); - - // Split the rect into two buttons side by side - float buttonWidth = rect.width / 2f - 2f; - Rect switchRect = new Rect(rect.x, rect.y, buttonWidth, rect.height); - Rect purchaseRect = new Rect(rect.x + buttonWidth + 4f, rect.y, buttonWidth, rect.height); - - // Switch button - always enabled except when already selected - GUI.enabled = !isSelected; - string switchLabel = isSelected ? "Active" : "Switch"; - if (GUI.Button(switchRect, switchLabel, smallButtonStyle)) - { - if (!unlocked && cost <= 0) - EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); - apply?.Invoke(); - } - - // Purchase button (shows cost) - GUI.enabled = canUse && !unlocked && cost > 0; - string purchaseLabel; - if (cost > 0) - purchaseLabel = unlocked ? "Owned" : $"Buy ({cost:N0}√)"; - else - purchaseLabel = "Free"; - - if (GUI.Button(purchaseRect, purchaseLabel, smallButtonStyle)) - { - if (EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired"))) - apply?.Invoke(); - } - - GUI.enabled = true; - } - - internal string GetThrustString(ConfigNode node) - { - if (!node.HasValue(thrustRating)) - return "-"; - - float thrust = scale * TechLevels.ThrustTL(node.GetValue(thrustRating), node); - // Remove decimals for large thrust values - if (thrust >= 100f) - return $"{thrust:N0} kN"; - return $"{thrust:N2} kN"; - } - - internal string GetMinThrottleString(ConfigNode node) - { - float value = -1f; - if (node.HasValue("minThrust") && node.HasValue(thrustRating)) - { - float.TryParse(node.GetValue("minThrust"), out float minT); - float.TryParse(node.GetValue(thrustRating), out float maxT); - if (maxT > 0) - value = minT / maxT; - } - else if (node.HasValue("throttle")) - { - float.TryParse(node.GetValue("throttle"), out value); - } - - if (value < 0f) - return "-"; - return value.ToString("P0"); - } - - internal string GetIspString(ConfigNode node) - { - if (node.HasNode("atmosphereCurve")) - { - FloatCurve isp = new FloatCurve(); - isp.Load(node.GetNode("atmosphereCurve")); - float ispVac = isp.Evaluate(isp.maxTime); - float ispSL = isp.Evaluate(isp.minTime); - return $"{ispVac:N0}-{ispSL:N0}"; - } - - if (node.HasValue("IspSL") && node.HasValue("IspV")) - { - float.TryParse(node.GetValue("IspSL"), out float ispSL); - float.TryParse(node.GetValue("IspV"), out float ispV); - if (techLevel != -1) - { - TechLevel cTL = new TechLevel(); - if (cTL.Load(node, techNodes, engineType, techLevel)) - { - ispSL *= ispSLMult * cTL.AtmosphereCurve.Evaluate(1); - ispV *= ispVMult * cTL.AtmosphereCurve.Evaluate(0); - } - } - return $"{ispV:N0}-{ispSL:N0}"; - } - - return "-"; - } - - internal string GetMassString(ConfigNode node) - { - if (origMass <= 0f) - return "-"; - - float cMass = scale * origMass * RFSettings.Instance.EngineMassMultiplier; - if (node.HasValue("massMult") && float.TryParse(node.GetValue("massMult"), out float ftmp)) - cMass *= ftmp; - - return $"{cMass:N3}t"; - } - - internal string GetGimbalString(ConfigNode node) - { - if (!part.HasModuleImplementing()) - return "✗"; - - var gimbals = ExtractGimbals(node); - - // If no explicit gimbal in config, check if we should use tech level gimbal - if (gimbals.Count == 0 && techLevel != -1 && (!gimbalTransform.Equals(string.Empty) || useGimbalAnyway)) - { - TechLevel cTL = new TechLevel(); - if (cTL.Load(node, techNodes, engineType, techLevel)) - { - float gimbalRange = cTL.GimbalRange; - if (node.HasValue("gimbalMult")) - gimbalRange *= float.Parse(node.GetValue("gimbalMult"), CultureInfo.InvariantCulture); - - if (gimbalRange >= 0) - return $"{gimbalRange * gimbalMult:0.#}°"; - } - } - - // Fallback: if config has no gimbal data, use the part's ModuleGimbal - if (gimbals.Count == 0) - { - foreach (var gimbalMod in part.Modules.OfType()) - { - if (gimbalMod != null) - { - var gimbal = new Gimbal(gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange); - gimbals[gimbalMod.gimbalTransformName] = gimbal; - } - } - } - - if (gimbals.Count == 0) - return "✗"; - - var first = gimbals.Values.First(); - bool allSame = gimbals.Values.All(g => g.gimbalRange == first.gimbalRange - && g.gimbalRangeXP == first.gimbalRangeXP - && g.gimbalRangeXN == first.gimbalRangeXN - && g.gimbalRangeYP == first.gimbalRangeYP - && g.gimbalRangeYN == first.gimbalRangeYN); - - if (allSame) - return first.Info(); - - // Multiple different gimbal ranges - list them all - var uniqueInfos = gimbals.Values.Select(g => g.Info()).Distinct().OrderBy(s => s); - return string.Join(", ", uniqueInfos); - } - - internal string GetIgnitionsString(ConfigNode node) - { - if (!node.HasValue("ignitions")) - return "-"; - - if (!int.TryParse(node.GetValue("ignitions"), out int ignitions)) - return "∞"; - - int resolved = TechLevels.ConfigIgnitions(ignitions); - if (resolved == -1) - return "∞"; - if (resolved == 0 && literalZeroIgnitions) - return "Gnd"; // Yellow G for ground-only ignitions - return resolved.ToString(); - } - - internal string GetBoolSymbol(ConfigNode node, string key) - { - if (!node.HasValue(key)) - return "✗"; // Treat missing as false - gray (no restriction) - bool isTrue = node.GetValue(key).ToLower() == "true"; - return isTrue ? "✓" : "✗"; // Orange for restriction, gray for no restriction - } - - private void InitializeColumnVisibility() - { - if (columnVisibilityInitialized) - return; - - // Initialize full view: all columns visible by default - for (int i = 0; i < 18; i++) - columnsVisibleFull[i] = true; - - // Initialize compact view: only essential columns - for (int i = 0; i < 18; i++) - columnsVisibleCompact[i] = false; - - // Essential columns for compact view - int[] compactColumns = { 0, 1, 3, 4, 6, 9, 10, 15, 16, 17 }; // Tech, Cost, Actions - foreach (int col in compactColumns) - columnsVisibleCompact[col] = true; - - columnVisibilityInitialized = true; - } - - private bool IsColumnVisible(int columnIndex) - { - InitializeColumnVisibility(); - - if (columnIndex < 0 || columnIndex >= 18) - return false; - - return compactView ? columnsVisibleCompact[columnIndex] : columnsVisibleFull[columnIndex]; - } - - internal string GetRatedBurnTimeString(ConfigNode node) - { - bool hasRatedBurnTime = node.HasValue("ratedBurnTime"); - bool hasRatedContinuousBurnTime = node.HasValue("ratedContinuousBurnTime"); - - if (!hasRatedBurnTime && !hasRatedContinuousBurnTime) - return "∞"; - - // If both values exist, show as "continuous/cumulative" - if (hasRatedBurnTime && hasRatedContinuousBurnTime) - { - string continuous = node.GetValue("ratedContinuousBurnTime"); - string cumulative = node.GetValue("ratedBurnTime"); - return $"{continuous}/{cumulative}"; - } - - // Otherwise show whichever one exists - return hasRatedBurnTime ? node.GetValue("ratedBurnTime") : node.GetValue("ratedContinuousBurnTime"); - } - - internal string GetTestedBurnTimeString(ConfigNode node) - { - // Values are copied to CONFIG level by ModuleManager patch - if (!node.HasValue("testedBurnTime")) - return "-"; - - float testedBurnTime = 0f; - if (node.TryGetValue("testedBurnTime", ref testedBurnTime)) - return testedBurnTime.ToString("F0"); - - return "-"; - } - - internal string GetIgnitionReliabilityStartString(ConfigNode node) - { - // Values are copied to CONFIG level by ModuleManager patch - if (!node.HasValue("ignitionReliabilityStart")) - return "-"; - if (float.TryParse(node.GetValue("ignitionReliabilityStart"), out float val)) - return $"{val:P1}"; - return "-"; - } - - internal string GetIgnitionReliabilityEndString(ConfigNode node) - { - // Values are copied to CONFIG level by ModuleManager patch - if (!node.HasValue("ignitionReliabilityEnd")) - return "-"; - if (float.TryParse(node.GetValue("ignitionReliabilityEnd"), out float val)) - return $"{val:P1}"; - return "-"; - } - - internal string GetCycleReliabilityStartString(ConfigNode node) - { - // Values are copied to CONFIG level by ModuleManager patch - if (!node.HasValue("cycleReliabilityStart")) - return "-"; - if (float.TryParse(node.GetValue("cycleReliabilityStart"), out float val)) - return $"{val:P1}"; - return "-"; - } - - internal string GetCycleReliabilityEndString(ConfigNode node) - { - // Values are copied to CONFIG level by ModuleManager patch - if (!node.HasValue("cycleReliabilityEnd")) - return "-"; - if (float.TryParse(node.GetValue("cycleReliabilityEnd"), out float val)) - return $"{val:P1}"; - return "-"; - } - - private string GetFlightDataString() - { - // Get current flight data from TestFlight - float currentData = TestFlightWrapper.GetCurrentFlightData(part); - float maxData = TestFlightWrapper.GetMaximumData(part); - - if (currentData < 0f || maxData <= 0f) - return "-"; - - return $"{currentData:F0} / {maxData:F0}"; - } - - internal string GetTechString(ConfigNode node) - { - if (!node.HasValue("techRequired")) - return "-"; - - string tech = node.GetValue("techRequired"); - if (techNameToTitle.TryGetValue(tech, out string title)) - tech = title; - - // Abbreviate: keep first word, then first 4 letters of other words with "-" - var words = tech.Split(' '); - if (words.Length <= 1) - return tech; - - var abbreviated = words[0]; - for (int i = 1; i < words.Length; i++) - { - if (words[i].Length > 4) - abbreviated += "-" + words[i].Substring(0, 4); - else - abbreviated += "-" + words[i]; - } - return abbreviated; - } - - internal string GetCostDeltaString(ConfigNode node) + /// + /// Draws the configuration selector UI elements. + /// Virtual so derived classes can add custom UI before the config table. + /// + protected internal virtual void DrawConfigSelectors(IEnumerable availableConfigNodes) { - if (!node.HasValue("cost")) - return "-"; - - float curCost = scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture); - if (techLevel != -1) - curCost = TechLevels.CostTL(curCost, node) - TechLevels.CostTL(0f, node); - - if (Mathf.Approximately(curCost, 0f)) - return "-"; - - string sign = curCost < 0 ? string.Empty : "+"; - return $"{sign}{curCost:N0}√"; + // Default implementation - just draw the table + // Derived classes can override to add custom UI } - #region Removed TestFlight UI Integration - // All TestFlight column display code removed due to: - // 1. Data spread across multiple modules (TestFlightCore, TestFlightFailure_IgnitionFail) - // 2. Reliability/MTBF values require complex calculations, not simple field access - // 3. Reflection-based approach was error-prone and caused GUI crashes - // - // TestFlight integration still works via UpdateTFInterops() to notify TestFlight - // of active configuration changes. TestFlight UI displays its own data. - // - // Removed methods: - // - GetFlightDataString, GetIgnitionChanceString, GetIgnitionChanceAtMaxDataString - // - GetReliabilityString, GetReliabilityAtMaxDataString - // - TryGetTestFlightStats, GetAllTestFlightDataSources, GetTestFlightDataSource - // - TryGetConfigDataSource, TryGetNumber, TryGetMemberValue, TryGetStringMember - // - TryConvertToDouble, FormatPercent - // - TestFlightStats struct - #endregion - - private string GetRowTooltip(ConfigNode node) + /// + /// Internal callback for GUI to apply a selected configuration. + /// + internal void GUIApplyConfig(string configName) { - List tooltipParts = new List(); - - // Add description if present - if (node.HasValue("description")) - tooltipParts.Add(node.GetValue("description")); - - // Add propellants with flow rates if present - if (node.HasNode("PROPELLANT")) - { - // Get thrust and ISP for flow calculations - float thrust = 0f; - float isp = 0f; - - if (node.HasValue(thrustRating) && float.TryParse(node.GetValue(thrustRating), out float maxThrust)) - thrust = TechLevels.ThrustTL(node.GetValue(thrustRating), node) * scale; - - if (node.HasNode("atmosphereCurve")) - { - var atmCurve = new FloatCurve(); - atmCurve.Load(node.GetNode("atmosphereCurve")); - isp = atmCurve.Evaluate(0f); // Vacuum ISP - } - - // Calculate total mass flow: F = mdot * Isp * g0 - // Thrust is in kN (kilonewtons), convert to N (newtons) for the equation - const float g0 = 9.80665f; - float thrustN = thrust * 1000f; - float totalMassFlow = (thrustN > 0f && isp > 0f) ? thrustN / (isp * g0) : 0f; - - // Get propellant ratios - var propNodes = node.GetNodes("PROPELLANT"); - float totalRatio = 0f; - foreach (var propNode in propNodes) - { - string ratioStr = null; - if (propNode.TryGetValue("ratio", ref ratioStr) && float.TryParse(ratioStr, out float ratio)) - totalRatio += ratio; - } - - var propellantLines = new List(); - foreach (var propNode in propNodes) - { - string name = propNode.GetValue("name"); - if (string.IsNullOrWhiteSpace(name)) continue; - - string line = $" • {name}"; - - string ratioStr2 = null; - if (propNode.TryGetValue("ratio", ref ratioStr2) && float.TryParse(ratioStr2, out float ratio) && totalMassFlow > 0f && totalRatio > 0f) - { - float propMassFlow = totalMassFlow * (ratio / totalRatio); - - // Get density from resource library - var resource = PartResourceLibrary.Instance?.GetDefinition(name); - - // Format mass flow: use grams if < 1 kg/s for better precision - string massFlowStr = propMassFlow >= 1f - ? $"{propMassFlow:F2} kg/s" - : $"{propMassFlow * 1000f:F1} g/s"; - - if (resource != null) - { - float volumeFlow = propMassFlow / (float)resource.density; - line += $": {volumeFlow:F2} L/s ({massFlowStr})"; - } - else - { - line += $": {massFlowStr}"; - } - } - - propellantLines.Add(line); - } - - if (propellantLines.Count > 0) - tooltipParts.Add($"Propellant Consumption:\n{string.Join("\n", propellantLines)}"); - } - - return tooltipParts.Count > 0 ? string.Join("\n\n", tooltipParts) : string.Empty; + SetConfiguration(configName); + UpdateSymmetryCounterparts(); } /// - /// Ensures textures are initialized. Handles Unity texture destruction on scene changes. + /// Hook point for external mod compatibility (e.g., RP-1 Harmony patches). + /// Called by the GUI before rendering each config row to allow external mods + /// to track context via Harmony prefix/postfix patches. + /// Does not render anything - rendering is handled by EngineConfigGUI. /// - private void EnsureTexturesAndStyles() + internal void DrawSelectButton(ConfigNode node, bool isSelected, Action applyCallback) { - Textures.EnsureInitialized(); - EngineConfigStyles.Initialize(); + // Hook point only - external mods (RP-1) patch this to track tech node context + // Actual rendering is handled by EngineConfigGUI table system } - virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) + public void OnGUI() { - DrawConfigTable(BuildConfigRows()); + if (isMaster && HighLogic.LoadedSceneIsEditor) + GUI.OnGUI(); } - private void EngineManagerGUI(int WindowID) - { - // Use BeginVertical with GUILayout.ExpandHeight(false) to prevent extra vertical space - GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); - - GUILayout.Space(4); // Minimal top padding - - GUILayout.BeginHorizontal(); - GUILayout.Label(EditorDescription); - GUILayout.FlexibleSpace(); - if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) - { - compactView = !compactView; - } - if (GUILayout.Button("Settings", GUILayout.Width(70))) - { - showColumnMenu = !showColumnMenu; - } - GUILayout.EndHorizontal(); - - GUILayout.Space(4); // Minimal space before table - DrawConfigSelectors(FilteredDisplayConfigs(false)); - - // Draw failure probability chart for current config - if (config != null && config.HasValue("cycleReliabilityStart")) - { - GUILayout.Space(6); - - // Update chart settings from instance fields - Chart.UseLogScaleX = useLogScaleX; - Chart.UseLogScaleY = useLogScaleY; - Chart.UseSimulatedData = useSimulatedData; - Chart.SimulatedDataValue = simulatedDataValue; - Chart.ClusterSize = clusterSize; - Chart.ClusterSizeInput = clusterSizeInput; - Chart.DataValueInput = dataValueInput; - - // Draw the chart - Chart.Draw(config, guiWindowRect.width - 10, 360); - - // Update instance fields from chart (for UI controls) - useLogScaleX = Chart.UseLogScaleX; - useLogScaleY = Chart.UseLogScaleY; - useSimulatedData = Chart.UseSimulatedData; - simulatedDataValue = Chart.SimulatedDataValue; - clusterSize = Chart.ClusterSize; - clusterSizeInput = Chart.ClusterSizeInput; - dataValueInput = Chart.DataValueInput; - - GUILayout.Space(6); // Consistent small space after chart - } - - TechLevels.DrawTechLevelSelector(); - - GUILayout.Space(4); // Minimal bottom padding - - GUILayout.EndVertical(); - - if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty)) - { - if (counterTT > 4) - { - myToolTip = GUI.tooltip; - counterTT = 0; - } - else - { - counterTT++; - } - } - else - { - myToolTip = GUI.tooltip; - counterTT = 0; - } - - GUI.DragWindow(); - } #endregion diff --git a/Source/Engines/ChartMath.cs b/Source/Engines/UI/ChartMath.cs similarity index 100% rename from Source/Engines/ChartMath.cs rename to Source/Engines/UI/ChartMath.cs diff --git a/Source/Engines/EngineConfigChart.cs b/Source/Engines/UI/EngineConfigChart.cs similarity index 100% rename from Source/Engines/EngineConfigChart.cs rename to Source/Engines/UI/EngineConfigChart.cs diff --git a/Source/Engines/UI/EngineConfigGUI.cs b/Source/Engines/UI/EngineConfigGUI.cs new file mode 100644 index 00000000..fd4b5ed1 --- /dev/null +++ b/Source/Engines/UI/EngineConfigGUI.cs @@ -0,0 +1,1004 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEngine; +using KSP.Localization; +using KSP.UI.Screens; +using RealFuels.TechLevels; + +namespace RealFuels +{ + /// + /// Handles all GUI rendering for ModuleEngineConfigs. + /// Manages the configuration selector window, column visibility, tooltips, and user interaction. + /// + public class EngineConfigGUI + { + private readonly ModuleEngineConfigsBase _module; + private readonly EngineConfigTechLevels _techLevels; + private readonly EngineConfigTextures _textures; + private EngineConfigChart _chart; + + // GUI state + private static Vector3 mousePos = Vector3.zero; + private static Rect guiWindowRect = new Rect(0, 0, 0, 0); + private static uint lastPartId = 0; + private static int lastConfigCount = 0; + private static bool lastCompactView = false; + private static bool lastHasChart = false; + private string myToolTip = string.Empty; + private int counterTT; + private bool editorLocked = false; + + private Vector2 configScrollPos = Vector2.zero; + private GUIContent configGuiContent; + private bool compactView = false; + private bool useLogScaleX = false; + private bool useLogScaleY = false; + + // Column visibility customization + private bool showColumnMenu = false; + private static Rect columnMenuRect = new Rect(100, 100, 280, 650); + private static bool[] columnsVisibleFull = new bool[18]; + private static bool[] columnsVisibleCompact = new bool[18]; + private static bool columnVisibilityInitialized = false; + + // Simulation controls + private bool useSimulatedData = false; + private float simulatedDataValue = 0f; + private int clusterSize = 1; + private string clusterSizeInput = "1"; + private string dataValueInput = "0"; + + private const int ConfigRowHeight = 22; + private const int ConfigMaxVisibleRows = 16; + private float[] ConfigColumnWidths = new float[18]; + + private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; + private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); + + public EngineConfigGUI(ModuleEngineConfigsBase module) + { + _module = module; + _techLevels = new EngineConfigTechLevels(module); + _textures = EngineConfigTextures.Instance; + } + + private EngineConfigChart Chart + { + get + { + if (_chart == null) + { + _chart = new EngineConfigChart(_module); + _chart.UseLogScaleX = useLogScaleX; + _chart.UseLogScaleY = useLogScaleY; + _chart.UseSimulatedData = useSimulatedData; + _chart.SimulatedDataValue = simulatedDataValue; + _chart.ClusterSize = clusterSize; + } + return _chart; + } + } + + #region Main GUI Entry Point + + public void OnGUI() + { + if (!_module.compatible || !_module.isMaster || !HighLogic.LoadedSceneIsEditor || EditorLogic.fetch == null) + return; + + bool inPartsEditor = EditorLogic.fetch.editorScreen == EditorScreen.Parts; + if (!(_module.showRFGUI && inPartsEditor) && !(EditorLogic.fetch.editorScreen == EditorScreen.Actions && EditorActionGroups.Instance.GetSelectedParts().Contains(_module.part))) + { + EditorUnlock(); + return; + } + + if (inPartsEditor && _module.part.symmetryCounterparts.FirstOrDefault(p => p.persistentId < _module.part.persistentId) is Part) + return; + + if (guiWindowRect.width == 0) + { + int posAdd = inPartsEditor ? 256 : 0; + int posMult = (_module.offsetGUIPos == -1) ? (_module.part.Modules.Contains("ModuleFuelTanks") ? 1 : 0) : _module.offsetGUIPos; + guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 100, 100); + } + + uint currentPartId = _module.part.persistentId; + int currentConfigCount = _module.FilteredDisplayConfigs(false).Count; + bool currentHasChart = _module.config != null && _module.config.HasValue("cycleReliabilityStart"); + bool contentChanged = currentPartId != lastPartId + || currentConfigCount != lastConfigCount + || compactView != lastCompactView + || currentHasChart != lastHasChart; + + if (contentChanged) + { + float savedX = guiWindowRect.x; + float savedY = guiWindowRect.y; + float savedWidth = guiWindowRect.width; + guiWindowRect = new Rect(savedX, savedY, savedWidth, 100); + + lastPartId = currentPartId; + lastConfigCount = currentConfigCount; + lastCompactView = compactView; + lastHasChart = currentHasChart; + } + + mousePos = Input.mousePosition; + mousePos.y = Screen.height - mousePos.y; + if (guiWindowRect.Contains(mousePos)) + EditorLock(); + else + EditorUnlock(); + + myToolTip = myToolTip.Trim(); + if (!string.IsNullOrEmpty(myToolTip)) + { + int offset = inPartsEditor ? -222 : 440; + GUI.Label(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, toolTipHeight), myToolTip, Styles.styleEditorTooltip); + } + + guiWindowRect = GUILayout.Window(unchecked((int)_module.part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", _module.part.partInfo.title), Styles.styleEditorPanel); + + if (showColumnMenu) + { + columnMenuRect = GUI.Window(unchecked((int)_module.part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Settings", HighLogic.Skin.window); + } + } + + #endregion + + #region GUI Windows + + private void EngineManagerGUI(int WindowID) + { + GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); + GUILayout.Space(4); + + GUILayout.BeginHorizontal(); + GUILayout.Label(_module.EditorDescription); + GUILayout.FlexibleSpace(); + if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) + { + compactView = !compactView; + } + if (GUILayout.Button("Settings", GUILayout.Width(70))) + { + showColumnMenu = !showColumnMenu; + } + GUILayout.EndHorizontal(); + + GUILayout.Space(4); + DrawConfigSelectors(_module.FilteredDisplayConfigs(false)); + + if (_module.config != null && _module.config.HasValue("cycleReliabilityStart")) + { + GUILayout.Space(6); + + Chart.UseLogScaleX = useLogScaleX; + Chart.UseLogScaleY = useLogScaleY; + Chart.UseSimulatedData = useSimulatedData; + Chart.SimulatedDataValue = simulatedDataValue; + Chart.ClusterSize = clusterSize; + Chart.ClusterSizeInput = clusterSizeInput; + Chart.DataValueInput = dataValueInput; + + Chart.Draw(_module.config, guiWindowRect.width - 10, 360); + + useLogScaleX = Chart.UseLogScaleX; + useLogScaleY = Chart.UseLogScaleY; + useSimulatedData = Chart.UseSimulatedData; + simulatedDataValue = Chart.SimulatedDataValue; + clusterSize = Chart.ClusterSize; + clusterSizeInput = Chart.ClusterSizeInput; + dataValueInput = Chart.DataValueInput; + + GUILayout.Space(6); + } + + _techLevels.DrawTechLevelSelector(); + + GUILayout.Space(4); + GUILayout.EndVertical(); + + if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty)) + { + if (counterTT > 4) + { + myToolTip = GUI.tooltip; + counterTT = 0; + } + else + { + counterTT++; + } + } + else + { + myToolTip = GUI.tooltip; + counterTT = 0; + } + + GUI.DragWindow(); + } + + private void DrawColumnMenuWindow(int windowID) + { + DrawColumnMenu(new Rect(0, 20, columnMenuRect.width, columnMenuRect.height - 20)); + GUI.DragWindow(new Rect(0, 0, columnMenuRect.width, 20)); + } + + #endregion + + #region Config Table Drawing + + protected void DrawConfigSelectors(IEnumerable availableConfigNodes) + { + // Allow derived module classes to add custom UI before the config table + _module.DrawConfigSelectors(availableConfigNodes); + + // Then draw the standard config table + DrawConfigTable(_module.BuildConfigRows()); + } + + protected void DrawConfigTable(IEnumerable rows) + { + EnsureTexturesAndStyles(); + + var rowList = rows.ToList(); + CalculateColumnWidths(rowList); + + float totalWidth = 0f; + for (int i = 0; i < ConfigColumnWidths.Length; i++) + { + if (IsColumnVisible(i)) + totalWidth += ConfigColumnWidths[i]; + } + + float requiredWindowWidth = totalWidth + 10f; + const float minWindowWidth = 900f; + guiWindowRect.width = Mathf.Max(requiredWindowWidth, minWindowWidth); + + Rect headerRowRect = GUILayoutUtility.GetRect(GUIContent.none, GUI.skin.label, GUILayout.Height(45)); + float headerStartX = headerRowRect.x; + DrawHeaderRow(new Rect(headerStartX, headerRowRect.y, totalWidth, headerRowRect.height)); + + int actualRows = rowList.Count; + int visibleRows = Mathf.Min(actualRows, ConfigMaxVisibleRows); + int scrollViewHeight = visibleRows * ConfigRowHeight; + + var scrollStyle = new GUIStyle(GUI.skin.scrollView) { padding = new RectOffset(0, 0, 0, 0) }; + configScrollPos = GUILayout.BeginScrollView(configScrollPos, false, false, GUIStyle.none, GUI.skin.verticalScrollbar, scrollStyle, GUILayout.Height(scrollViewHeight)); + + var noSpaceStyle = new GUIStyle { margin = new RectOffset(0, 0, 0, 0), padding = new RectOffset(0, 0, 0, 0) }; + + int rowIndex = 0; + foreach (var row in rowList) + { + Rect rowRect = GUILayoutUtility.GetRect(GUIContent.none, noSpaceStyle, GUILayout.Height(ConfigRowHeight)); + float rowStartX = rowRect.x; + Rect tableRowRect = new Rect(rowStartX, rowRect.y, totalWidth, rowRect.height); + bool isHovered = tableRowRect.Contains(Event.current.mousePosition); + + bool isLocked = !EngineConfigTechLevels.CanConfig(row.Node); + if (Event.current.type == EventType.Repaint) + { + if (!row.IsSelected && !isLocked && !isHovered && rowIndex % 2 == 1) + { + GUI.DrawTexture(tableRowRect, _textures.ZebraStripe); + } + + if (row.IsSelected) + GUI.DrawTexture(tableRowRect, _textures.RowCurrent); + else if (isLocked) + GUI.DrawTexture(tableRowRect, _textures.RowLocked); + else if (isHovered) + GUI.DrawTexture(tableRowRect, _textures.RowHover); + } + + string tooltip = GetRowTooltip(row.Node); + if (configGuiContent == null) + configGuiContent = new GUIContent(); + configGuiContent.text = string.Empty; + configGuiContent.tooltip = tooltip; + GUI.Label(tableRowRect, configGuiContent, GUIStyle.none); + + // Call DrawSelectButton as a hook point for external mod compatibility (RP-1) + // This triggers Harmony patches but doesn't actually draw anything + _module.DrawSelectButton(row.Node, row.IsSelected, (_) => row.Apply()); + + DrawConfigRow(tableRowRect, row, isHovered, isLocked); + + if (Event.current.type == EventType.Repaint) + { + DrawColumnSeparators(tableRowRect); + } + + rowIndex++; + } + + GUILayout.EndScrollView(); + } + + private void DrawHeaderRow(Rect headerRect) + { + float currentX = headerRect.x; + string[] headers = { + "Name", Localizer.GetStringByTag("#RF_EngineRF_Thrust"), "Min%", + Localizer.GetStringByTag("#RF_Engine_Isp"), Localizer.GetStringByTag("#RF_Engine_Enginemass"), + Localizer.GetStringByTag("#RF_Engine_TLTInfo_Gimbal"), Localizer.GetStringByTag("#RF_EngineRF_Ignitions"), + Localizer.GetStringByTag("#RF_Engine_ullage"), Localizer.GetStringByTag("#RF_Engine_pressureFed"), + "Rated (s)", "Tested (s)", "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", + Localizer.GetStringByTag("#RF_Engine_Requires"), "Extra Cost", "" + }; + string[] tooltips = { + "Configuration name", "Rated thrust", "Minimum throttle", + "Sea level and vacuum Isp", "Engine mass", "Gimbal range", "Ignitions", + "Ullage requirement", "Pressure-fed", "Rated burn time", + "Tested burn time (real-world test duration)", + "Ignition reliability at 0 data", "Ignition reliability at max data", + "Cycle reliability at 0 data", "Cycle reliability at max data", + "Required technology", "Extra cost for this config", "Switch and purchase actions" + }; + + for (int i = 0; i < headers.Length; i++) + { + if (IsColumnVisible(i)) + { + DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[i], headerRect.height), headers[i], tooltips[i]); + currentX += ConfigColumnWidths[i]; + } + } + } + + private void DrawHeaderCell(Rect rect, string text, string tooltip) + { + bool hover = rect.Contains(Event.current.mousePosition); + GUIStyle headerStyle = hover ? EngineConfigStyles.HeaderCellHover : EngineConfigStyles.HeaderCell; + + if (configGuiContent == null) + configGuiContent = new GUIContent(); + configGuiContent.text = text; + configGuiContent.tooltip = tooltip; + Matrix4x4 matrixBackup = GUI.matrix; + float offsetX = rect.width / 2f; + Vector2 pivot = new Vector2(rect.x + offsetX, rect.y + rect.height + 4f); + GUIUtility.RotateAroundPivot(-45f, pivot); + GUI.Label(new Rect(rect.x + offsetX, rect.y + rect.height - 22f, 140f, 24f), configGuiContent, headerStyle); + GUI.matrix = matrixBackup; + } + + private void DrawConfigRow(Rect rowRect, ModuleEngineConfigsBase.ConfigRowDefinition row, bool isHovered, bool isLocked) + { + GUIStyle primaryStyle; + if (isLocked) + primaryStyle = EngineConfigStyles.RowPrimaryLocked; + else if (isHovered) + primaryStyle = EngineConfigStyles.RowPrimaryHover; + else + primaryStyle = EngineConfigStyles.RowPrimary; + + GUIStyle secondaryStyle = EngineConfigStyles.RowSecondary; + + float currentX = rowRect.x; + string nameText = row.DisplayName; + if (row.Indent) nameText = " ↳ " + nameText; + + Action drawCell = (index, text) => + { + if (IsColumnVisible(index)) + { + GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[index], rowRect.height), text, index == 0 ? primaryStyle : secondaryStyle); + currentX += ConfigColumnWidths[index]; + } + }; + + drawCell(0, nameText); + drawCell(1, GetThrustString(row.Node)); + drawCell(2, GetMinThrottleString(row.Node)); + drawCell(3, GetIspString(row.Node)); + drawCell(4, GetMassString(row.Node)); + drawCell(5, GetGimbalString(row.Node)); + drawCell(6, GetIgnitionsString(row.Node)); + drawCell(7, GetBoolSymbol(row.Node, "ullage")); + drawCell(8, GetBoolSymbol(row.Node, "pressureFed")); + drawCell(9, GetRatedBurnTimeString(row.Node)); + drawCell(10, GetTestedBurnTimeString(row.Node)); + drawCell(11, GetIgnitionReliabilityStartString(row.Node)); + drawCell(12, GetIgnitionReliabilityEndString(row.Node)); + drawCell(13, GetCycleReliabilityStartString(row.Node)); + drawCell(14, GetCycleReliabilityEndString(row.Node)); + drawCell(15, GetTechString(row.Node)); + drawCell(16, GetCostDeltaString(row.Node)); + + if (IsColumnVisible(17)) + { + DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[17], rowRect.height - 2), row.Node, row.IsSelected, row.Apply); + } + } + + private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action apply) + { + GUIStyle smallButtonStyle = EngineConfigStyles.SmallButton; + + string configName = node.GetValue("name"); + bool canUse = EngineConfigTechLevels.CanConfig(node); + bool unlocked = EngineConfigTechLevels.UnlockedConfig(node, _module.part); + double cost = EntryCostManager.Instance.ConfigEntryCost(configName); + + if (cost <= 0 && !unlocked && canUse) + EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); + + float buttonWidth = rect.width / 2f - 2f; + Rect switchRect = new Rect(rect.x, rect.y, buttonWidth, rect.height); + Rect purchaseRect = new Rect(rect.x + buttonWidth + 4f, rect.y, buttonWidth, rect.height); + + GUI.enabled = !isSelected; + string switchLabel = isSelected ? "Active" : "Switch"; + if (GUI.Button(switchRect, switchLabel, smallButtonStyle)) + { + if (!unlocked && cost <= 0) + EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); + apply?.Invoke(); + } + + GUI.enabled = canUse && !unlocked && cost > 0; + string purchaseLabel; + if (cost > 0) + purchaseLabel = unlocked ? "Owned" : $"Buy ({cost:N0}√)"; + else + purchaseLabel = "Free"; + + if (GUI.Button(purchaseRect, purchaseLabel, smallButtonStyle)) + { + if (EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired"))) + apply?.Invoke(); + } + + GUI.enabled = true; + } + + private void DrawColumnSeparators(Rect rowRect) + { + float currentX = rowRect.x; + for (int i = 0; i < ConfigColumnWidths.Length - 1; i++) + { + if (IsColumnVisible(i)) + { + currentX += ConfigColumnWidths[i]; + Rect separatorRect = new Rect(currentX, rowRect.y, 1, rowRect.height); + GUI.DrawTexture(separatorRect, _textures.ColumnSeparator); + } + } + } + + #endregion + + #region Column Management + + private void DrawColumnMenu(Rect menuRect) + { + InitializeColumnVisibility(); + + string[] columnNames = { + "Name", "Thrust", "Min%", "ISP", "Mass", "Gimbal", + "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)", + "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", + "Tech", "Cost", "Actions" + }; + + float yPos = menuRect.y + 10; + float leftX = menuRect.x + 10; + + GUIStyle headerStyle = EngineConfigStyles.MenuHeader; + GUIStyle labelStyle = EngineConfigStyles.MenuLabel; + + GUI.Label(new Rect(leftX, yPos, menuRect.width - 20, 20), "Column Visibility", headerStyle); + yPos += 25; + + if (Event.current.type == EventType.Repaint) + { + Texture2D separatorTex = Styles.CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.5f)); + GUI.DrawTexture(new Rect(leftX, yPos, menuRect.width - 20, 1), separatorTex); + } + yPos += 10; + + GUI.Label(new Rect(leftX + 100, yPos, 60, 20), "Full", headerStyle); + GUI.Label(new Rect(leftX + 170, yPos, 60, 20), "Compact", headerStyle); + yPos += 25; + + Rect scrollRect = new Rect(leftX, yPos, menuRect.width - 20, menuRect.height - 80); + + GUI.BeginGroup(scrollRect); + float itemY = 0; + + for (int i = 0; i < columnNames.Length; i++) + { + GUI.Label(new Rect(5, itemY, 90, 20), columnNames[i], labelStyle); + + bool newFullVisible = GUI.Toggle(new Rect(105, itemY, 20, 20), columnsVisibleFull[i], ""); + if (newFullVisible != columnsVisibleFull[i]) + { + columnsVisibleFull[i] = newFullVisible; + } + + bool newCompactVisible = GUI.Toggle(new Rect(175, itemY, 20, 20), columnsVisibleCompact[i], ""); + if (newCompactVisible != columnsVisibleCompact[i]) + { + columnsVisibleCompact[i] = newCompactVisible; + } + + itemY += 25; + } + + GUI.EndGroup(); + + if (GUI.Button(new Rect(menuRect.x + menuRect.width - 60, menuRect.y + menuRect.height - 30, 50, 20), "Close")) + { + showColumnMenu = false; + } + } + + private void InitializeColumnVisibility() + { + if (columnVisibilityInitialized) + return; + + for (int i = 0; i < 18; i++) + columnsVisibleFull[i] = true; + + for (int i = 0; i < 18; i++) + columnsVisibleCompact[i] = false; + + int[] compactColumns = { 0, 1, 3, 4, 6, 9, 10, 15, 16, 17 }; + foreach (int col in compactColumns) + columnsVisibleCompact[col] = true; + + columnVisibilityInitialized = true; + } + + private bool IsColumnVisible(int columnIndex) + { + InitializeColumnVisibility(); + + if (columnIndex < 0 || columnIndex >= 18) + return false; + + return compactView ? columnsVisibleCompact[columnIndex] : columnsVisibleFull[columnIndex]; + } + + private void CalculateColumnWidths(List rows) + { + var cellStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 14, + fontStyle = FontStyle.Bold, + padding = new RectOffset(5, 0, 0, 0) + }; + + for (int i = 0; i < ConfigColumnWidths.Length; i++) + { + ConfigColumnWidths[i] = 30f; + } + + foreach (var row in rows) + { + string nameText = row.DisplayName; + if (row.Indent) nameText = " ↳ " + nameText; + + string[] cellValues = new string[] + { + nameText, + GetThrustString(row.Node), + GetMinThrottleString(row.Node), + GetIspString(row.Node), + GetMassString(row.Node), + GetGimbalString(row.Node), + GetIgnitionsString(row.Node), + GetBoolSymbol(row.Node, "ullage"), + GetBoolSymbol(row.Node, "pressureFed"), + GetRatedBurnTimeString(row.Node), + GetTestedBurnTimeString(row.Node), + GetIgnitionReliabilityStartString(row.Node), + GetIgnitionReliabilityEndString(row.Node), + GetCycleReliabilityStartString(row.Node), + GetCycleReliabilityEndString(row.Node), + GetTechString(row.Node), + GetCostDeltaString(row.Node), + "" + }; + + for (int i = 0; i < cellValues.Length; i++) + { + if (!string.IsNullOrEmpty(cellValues[i])) + { + float width = cellStyle.CalcSize(new GUIContent(cellValues[i])).x + 10f; + if (width > ConfigColumnWidths[i]) + ConfigColumnWidths[i] = width; + } + } + } + + ConfigColumnWidths[17] = 160f; + ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); + ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); + ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); + ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f); + } + + #endregion + + #region Cell Formatters + + internal string GetThrustString(ConfigNode node) + { + if (!node.HasValue(_module.thrustRating)) + return "-"; + + float thrust = _module.scale * _techLevels.ThrustTL(node.GetValue(_module.thrustRating), node); + if (thrust >= 100f) + return $"{thrust:N0} kN"; + return $"{thrust:N2} kN"; + } + + internal string GetMinThrottleString(ConfigNode node) + { + float value = -1f; + if (node.HasValue("minThrust") && node.HasValue(_module.thrustRating)) + { + float.TryParse(node.GetValue("minThrust"), out float minT); + float.TryParse(node.GetValue(_module.thrustRating), out float maxT); + if (maxT > 0) + value = minT / maxT; + } + else if (node.HasValue("throttle")) + { + float.TryParse(node.GetValue("throttle"), out value); + } + + if (value < 0f) + return "-"; + return value.ToString("P0"); + } + + internal string GetIspString(ConfigNode node) + { + if (node.HasNode("atmosphereCurve")) + { + FloatCurve isp = new FloatCurve(); + isp.Load(node.GetNode("atmosphereCurve")); + float ispVac = isp.Evaluate(isp.maxTime); + float ispSL = isp.Evaluate(isp.minTime); + return $"{ispVac:N0}-{ispSL:N0}"; + } + + if (node.HasValue("IspSL") && node.HasValue("IspV")) + { + float.TryParse(node.GetValue("IspSL"), out float ispSL); + float.TryParse(node.GetValue("IspV"), out float ispV); + if (_module.techLevel != -1) + { + TechLevel cTL = new TechLevel(); + if (cTL.Load(node, _module.techNodes, _module.engineType, _module.techLevel)) + { + ispSL *= ModuleEngineConfigsBase.ispSLMult * cTL.AtmosphereCurve.Evaluate(1); + ispV *= ModuleEngineConfigsBase.ispVMult * cTL.AtmosphereCurve.Evaluate(0); + } + } + return $"{ispV:N0}-{ispSL:N0}"; + } + + return "-"; + } + + internal string GetMassString(ConfigNode node) + { + if (_module.origMass <= 0f) + return "-"; + + float cMass = _module.scale * _module.origMass * RFSettings.Instance.EngineMassMultiplier; + if (node.HasValue("massMult") && float.TryParse(node.GetValue("massMult"), out float ftmp)) + cMass *= ftmp; + + return $"{cMass:N3}t"; + } + + internal string GetGimbalString(ConfigNode node) + { + if (!_module.part.HasModuleImplementing()) + return "✗"; + + var gimbals = _module.ExtractGimbals(node); + + if (gimbals.Count == 0 && _module.techLevel != -1 && (!_module.gimbalTransform.Equals(string.Empty) || _module.useGimbalAnyway)) + { + TechLevel cTL = new TechLevel(); + if (cTL.Load(node, _module.techNodes, _module.engineType, _module.techLevel)) + { + float gimbalRange = cTL.GimbalRange; + if (node.HasValue("gimbalMult")) + gimbalRange *= float.Parse(node.GetValue("gimbalMult"), CultureInfo.InvariantCulture); + + if (gimbalRange >= 0) + return $"{gimbalRange * _module.gimbalMult:0.#}°"; + } + } + + if (gimbals.Count == 0) + { + foreach (var gimbalMod in _module.part.Modules.OfType()) + { + if (gimbalMod != null) + { + var gimbal = new Gimbal(gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange); + gimbals[gimbalMod.gimbalTransformName] = gimbal; + } + } + } + + if (gimbals.Count == 0) + return "✗"; + + var first = gimbals.Values.First(); + bool allSame = gimbals.Values.All(g => g.gimbalRange == first.gimbalRange + && g.gimbalRangeXP == first.gimbalRangeXP + && g.gimbalRangeXN == first.gimbalRangeXN + && g.gimbalRangeYP == first.gimbalRangeYP + && g.gimbalRangeYN == first.gimbalRangeYN); + + if (allSame) + return first.Info(); + + var uniqueInfos = gimbals.Values.Select(g => g.Info()).Distinct().OrderBy(s => s); + return string.Join(", ", uniqueInfos); + } + + internal string GetIgnitionsString(ConfigNode node) + { + if (!node.HasValue("ignitions")) + return "-"; + + if (!int.TryParse(node.GetValue("ignitions"), out int ignitions)) + return "∞"; + + int resolved = _techLevels.ConfigIgnitions(ignitions); + if (resolved == -1) + return "∞"; + if (resolved == 0 && _module.literalZeroIgnitions) + return "Gnd"; + return resolved.ToString(); + } + + internal string GetBoolSymbol(ConfigNode node, string key) + { + if (!node.HasValue(key)) + return "✗"; + bool isTrue = node.GetValue(key).ToLower() == "true"; + return isTrue ? "✓" : "✗"; + } + + internal string GetRatedBurnTimeString(ConfigNode node) + { + bool hasRatedBurnTime = node.HasValue("ratedBurnTime"); + bool hasRatedContinuousBurnTime = node.HasValue("ratedContinuousBurnTime"); + + if (!hasRatedBurnTime && !hasRatedContinuousBurnTime) + return "∞"; + + if (hasRatedBurnTime && hasRatedContinuousBurnTime) + { + string continuous = node.GetValue("ratedContinuousBurnTime"); + string cumulative = node.GetValue("ratedBurnTime"); + return $"{continuous}/{cumulative}"; + } + + return hasRatedBurnTime ? node.GetValue("ratedBurnTime") : node.GetValue("ratedContinuousBurnTime"); + } + + internal string GetTestedBurnTimeString(ConfigNode node) + { + if (!node.HasValue("testedBurnTime")) + return "-"; + + float testedBurnTime = 0f; + if (node.TryGetValue("testedBurnTime", ref testedBurnTime)) + return testedBurnTime.ToString("F0"); + + return "-"; + } + + internal string GetIgnitionReliabilityStartString(ConfigNode node) + { + if (!node.HasValue("ignitionReliabilityStart")) + return "-"; + if (float.TryParse(node.GetValue("ignitionReliabilityStart"), out float val)) + return $"{val:P1}"; + return "-"; + } + + internal string GetIgnitionReliabilityEndString(ConfigNode node) + { + if (!node.HasValue("ignitionReliabilityEnd")) + return "-"; + if (float.TryParse(node.GetValue("ignitionReliabilityEnd"), out float val)) + return $"{val:P1}"; + return "-"; + } + + internal string GetCycleReliabilityStartString(ConfigNode node) + { + if (!node.HasValue("cycleReliabilityStart")) + return "-"; + if (float.TryParse(node.GetValue("cycleReliabilityStart"), out float val)) + return $"{val:P1}"; + return "-"; + } + + internal string GetCycleReliabilityEndString(ConfigNode node) + { + if (!node.HasValue("cycleReliabilityEnd")) + return "-"; + if (float.TryParse(node.GetValue("cycleReliabilityEnd"), out float val)) + return $"{val:P1}"; + return "-"; + } + + internal string GetTechString(ConfigNode node) + { + if (!node.HasValue("techRequired")) + return "-"; + + string tech = node.GetValue("techRequired"); + if (ModuleEngineConfigsBase.techNameToTitle.TryGetValue(tech, out string title)) + tech = title; + + var words = tech.Split(' '); + if (words.Length <= 1) + return tech; + + var abbreviated = words[0]; + for (int i = 1; i < words.Length; i++) + { + if (words[i].Length > 4) + abbreviated += "-" + words[i].Substring(0, 4); + else + abbreviated += "-" + words[i]; + } + return abbreviated; + } + + internal string GetCostDeltaString(ConfigNode node) + { + if (!node.HasValue("cost")) + return "-"; + + float curCost = _module.scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture); + if (_module.techLevel != -1) + curCost = _techLevels.CostTL(curCost, node) - _techLevels.CostTL(0f, node); + + if (Mathf.Approximately(curCost, 0f)) + return "-"; + + string sign = curCost < 0 ? string.Empty : "+"; + return $"{sign}{curCost:N0}√"; + } + + #endregion + + #region Tooltips + + private string GetRowTooltip(ConfigNode node) + { + List tooltipParts = new List(); + + if (node.HasValue("description")) + tooltipParts.Add(node.GetValue("description")); + + if (node.HasNode("PROPELLANT")) + { + float thrust = 0f; + float isp = 0f; + + if (node.HasValue(_module.thrustRating) && float.TryParse(node.GetValue(_module.thrustRating), out float maxThrust)) + thrust = _techLevels.ThrustTL(node.GetValue(_module.thrustRating), node) * _module.scale; + + if (node.HasNode("atmosphereCurve")) + { + var atmCurve = new FloatCurve(); + atmCurve.Load(node.GetNode("atmosphereCurve")); + isp = atmCurve.Evaluate(0f); + } + + const float g0 = 9.80665f; + float thrustN = thrust * 1000f; + float totalMassFlow = (thrustN > 0f && isp > 0f) ? thrustN / (isp * g0) : 0f; + + var propNodes = node.GetNodes("PROPELLANT"); + float totalRatio = 0f; + foreach (var propNode in propNodes) + { + string ratioStr = null; + if (propNode.TryGetValue("ratio", ref ratioStr) && float.TryParse(ratioStr, out float ratio)) + totalRatio += ratio; + } + + var propellantLines = new List(); + foreach (var propNode in propNodes) + { + string name = propNode.GetValue("name"); + if (string.IsNullOrWhiteSpace(name)) continue; + + string line = $" • {name}"; + + string ratioStr2 = null; + if (propNode.TryGetValue("ratio", ref ratioStr2) && float.TryParse(ratioStr2, out float ratio) && totalMassFlow > 0f && totalRatio > 0f) + { + float propMassFlow = totalMassFlow * (ratio / totalRatio); + + var resource = PartResourceLibrary.Instance?.GetDefinition(name); + + string massFlowStr = propMassFlow >= 1f + ? $"{propMassFlow:F2} kg/s" + : $"{propMassFlow * 1000f:F1} g/s"; + + if (resource != null) + { + float volumeFlow = propMassFlow / (float)resource.density; + line += $": {volumeFlow:F2} L/s ({massFlowStr})"; + } + else + { + line += $": {massFlowStr}"; + } + } + + propellantLines.Add(line); + } + + if (propellantLines.Count > 0) + tooltipParts.Add($"Propellant Consumption:\n{string.Join("\n", propellantLines)}"); + } + + return tooltipParts.Count > 0 ? string.Join("\n\n", tooltipParts) : string.Empty; + } + + #endregion + + #region Helper Methods + + internal void MarkWindowDirty() + { + lastPartId = 0; + } + + private void EditorLock() + { + if (!editorLocked) + { + EditorLogic.fetch.Lock(false, false, false, "RFGUILock"); + editorLocked = true; + KSP.UI.Screens.Editor.PartListTooltipMasterController.Instance?.HideTooltip(); + } + } + + private void EditorUnlock() + { + if (editorLocked) + { + EditorLogic.fetch.Unlock("RFGUILock"); + editorLocked = false; + } + } + + private void EnsureTexturesAndStyles() + { + _textures.EnsureInitialized(); + EngineConfigStyles.Initialize(); + } + + #endregion + } +} diff --git a/Source/Engines/EngineConfigInfoPanel.cs b/Source/Engines/UI/EngineConfigInfoPanel.cs similarity index 100% rename from Source/Engines/EngineConfigInfoPanel.cs rename to Source/Engines/UI/EngineConfigInfoPanel.cs diff --git a/Source/Engines/EngineConfigStyles.cs b/Source/Engines/UI/EngineConfigStyles.cs similarity index 100% rename from Source/Engines/EngineConfigStyles.cs rename to Source/Engines/UI/EngineConfigStyles.cs diff --git a/Source/Engines/EngineConfigTextures.cs b/Source/Engines/UI/EngineConfigTextures.cs similarity index 100% rename from Source/Engines/EngineConfigTextures.cs rename to Source/Engines/UI/EngineConfigTextures.cs From 9ff0dd2477c566f2053893228daab86cd7425917 Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 22:16:01 -0800 Subject: [PATCH 08/12] Reorganize engine config UI code structure by moving UI-related files into a dedicated UI folder --- Source/RealFuels.csproj | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Source/RealFuels.csproj b/Source/RealFuels.csproj index ff396fc5..bf8bf89f 100644 --- a/Source/RealFuels.csproj +++ b/Source/RealFuels.csproj @@ -90,14 +90,15 @@ - - - + + + + - - + + From 9393ec094a23500d4b121460b832f12c67ac764b Mon Sep 17 00:00:00 2001 From: Arodoid Date: Sun, 8 Feb 2026 23:36:01 -0800 Subject: [PATCH 09/12] Enhance engine configuration UI by adding close functionality and improving tooltip styles --- Source/Engines/ModuleEngineConfigs.cs | 21 ++- Source/Engines/UI/EngineConfigGUI.cs | 198 +++++++++++++-------- Source/Engines/UI/EngineConfigInfoPanel.cs | 2 +- Source/Utilities/Styles.cs | 2 +- 4 files changed, 149 insertions(+), 74 deletions(-) diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index bc9e3c92..f988fb9b 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1101,6 +1101,8 @@ public void ChangeEngineType(string newEngineType) [NonSerialized] public bool showRFGUI; + public static bool userClosedWindow = false; + private void OnPartActionGuiDismiss(Part p) { if (p == part || p.isSymmetryCounterPart(part)) @@ -1109,7 +1111,7 @@ private void OnPartActionGuiDismiss(Part p) private void OnPartActionUIShown(UIPartActionWindow window, Part p) { - if (p == part) + if (p == part && !userClosedWindow) showRFGUI = isMaster; } @@ -1232,10 +1234,27 @@ internal void DrawSelectButton(ConfigNode node, bool isSelected, Action // Actual rendering is handled by EngineConfigGUI table system } + private bool lastShowRFGUI = false; + public void OnGUI() { if (isMaster && HighLogic.LoadedSceneIsEditor) + { + // If the user clicked the PAW button to show the GUI, clear the userClosedWindow flag + if (showRFGUI && !lastShowRFGUI) + { + userClosedWindow = false; + } + lastShowRFGUI = showRFGUI; + GUI.OnGUI(); + } + } + + internal void CloseWindow() + { + showRFGUI = false; + userClosedWindow = true; } diff --git a/Source/Engines/UI/EngineConfigGUI.cs b/Source/Engines/UI/EngineConfigGUI.cs index fd4b5ed1..9db35536 100644 --- a/Source/Engines/UI/EngineConfigGUI.cs +++ b/Source/Engines/UI/EngineConfigGUI.cs @@ -27,19 +27,21 @@ public class EngineConfigGUI private static int lastConfigCount = 0; private static bool lastCompactView = false; private static bool lastHasChart = false; + private static bool lastShowBottomSection = true; private string myToolTip = string.Empty; private int counterTT; private bool editorLocked = false; private Vector2 configScrollPos = Vector2.zero; private GUIContent configGuiContent; - private bool compactView = false; + private static bool compactView = false; private bool useLogScaleX = false; private bool useLogScaleY = false; + private static bool showBottomSection = true; // Column visibility customization private bool showColumnMenu = false; - private static Rect columnMenuRect = new Rect(100, 100, 280, 650); + private static Rect columnMenuRect = new Rect(100, 100, 220, 400); private static bool[] columnsVisibleFull = new bool[18]; private static bool[] columnsVisibleCompact = new bool[18]; private static bool columnVisibilityInitialized = false; @@ -55,8 +57,7 @@ public class EngineConfigGUI private const int ConfigMaxVisibleRows = 16; private float[] ConfigColumnWidths = new float[18]; - private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; - private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); + private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 320 : 380; public EngineConfigGUI(ModuleEngineConfigsBase module) { @@ -112,7 +113,8 @@ public void OnGUI() bool contentChanged = currentPartId != lastPartId || currentConfigCount != lastConfigCount || compactView != lastCompactView - || currentHasChart != lastHasChart; + || currentHasChart != lastHasChart + || showBottomSection != lastShowBottomSection; if (contentChanged) { @@ -125,6 +127,7 @@ public void OnGUI() lastConfigCount = currentConfigCount; lastCompactView = compactView; lastHasChart = currentHasChart; + lastShowBottomSection = showBottomSection; } mousePos = Input.mousePosition; @@ -137,15 +140,23 @@ public void OnGUI() myToolTip = myToolTip.Trim(); if (!string.IsNullOrEmpty(myToolTip)) { - int offset = inPartsEditor ? -222 : 440; - GUI.Label(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, toolTipHeight), myToolTip, Styles.styleEditorTooltip); + int offset = inPartsEditor ? -330 : 440; + var tooltipStyle = new GUIStyle(EngineConfigStyles.ChartTooltip) + { + fontSize = 13, + wordWrap = true, + normal = { background = _textures.ChartTooltipBg } + }; + var content = new GUIContent(myToolTip); + float tooltipHeight = tooltipStyle.CalcHeight(content, toolTipWidth); + GUI.Box(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, tooltipHeight), myToolTip, tooltipStyle); } guiWindowRect = GUILayout.Window(unchecked((int)_module.part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", _module.part.partInfo.title), Styles.styleEditorPanel); if (showColumnMenu) { - columnMenuRect = GUI.Window(unchecked((int)_module.part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Settings", HighLogic.Skin.window); + columnMenuRect = GUI.Window(unchecked((int)_module.part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Column Settings", Styles.styleEditorPanel); } } @@ -156,51 +167,72 @@ public void OnGUI() private void EngineManagerGUI(int WindowID) { GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); - GUILayout.Space(4); + GUILayout.Space(6); GUILayout.BeginHorizontal(); - GUILayout.Label(_module.EditorDescription); + var descStyle = new GUIStyle(GUI.skin.label) { padding = new RectOffset(0, 0, 0, 0), margin = new RectOffset(0, 0, 0, 0) }; + GUILayout.Label(_module.EditorDescription, descStyle); GUILayout.FlexibleSpace(); if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) { compactView = !compactView; } + if (GUILayout.Button(showBottomSection ? "Hide Chart" : "Show Chart", GUILayout.Width(85))) + { + showBottomSection = !showBottomSection; + } if (GUILayout.Button("Settings", GUILayout.Width(70))) { showColumnMenu = !showColumnMenu; } + // Close button + var closeButtonStyle = new GUIStyle(GUI.skin.button) + { + normal = { textColor = new Color(1f, 0.4f, 0.4f) }, + hover = { textColor = new Color(1f, 0.2f, 0.2f) }, + fontStyle = FontStyle.Bold, + fontSize = 14 + }; + if (GUILayout.Button("✕", closeButtonStyle, GUILayout.Width(25))) + { + _module.CloseWindow(); + return; + } GUILayout.EndHorizontal(); - GUILayout.Space(4); + GUILayout.Space(7); DrawConfigSelectors(_module.FilteredDisplayConfigs(false)); - if (_module.config != null && _module.config.HasValue("cycleReliabilityStart")) + if (showBottomSection) { - GUILayout.Space(6); - - Chart.UseLogScaleX = useLogScaleX; - Chart.UseLogScaleY = useLogScaleY; - Chart.UseSimulatedData = useSimulatedData; - Chart.SimulatedDataValue = simulatedDataValue; - Chart.ClusterSize = clusterSize; - Chart.ClusterSizeInput = clusterSizeInput; - Chart.DataValueInput = dataValueInput; - - Chart.Draw(_module.config, guiWindowRect.width - 10, 360); - - useLogScaleX = Chart.UseLogScaleX; - useLogScaleY = Chart.UseLogScaleY; - useSimulatedData = Chart.UseSimulatedData; - simulatedDataValue = Chart.SimulatedDataValue; - clusterSize = Chart.ClusterSize; - clusterSizeInput = Chart.ClusterSizeInput; - dataValueInput = Chart.DataValueInput; + if (_module.config != null && _module.config.HasValue("cycleReliabilityStart")) + { + GUILayout.Space(6); + + Chart.UseLogScaleX = useLogScaleX; + Chart.UseLogScaleY = useLogScaleY; + Chart.UseSimulatedData = useSimulatedData; + Chart.SimulatedDataValue = simulatedDataValue; + Chart.ClusterSize = clusterSize; + Chart.ClusterSizeInput = clusterSizeInput; + Chart.DataValueInput = dataValueInput; + + Chart.Draw(_module.config, guiWindowRect.width - 10, 375); + + useLogScaleX = Chart.UseLogScaleX; + useLogScaleY = Chart.UseLogScaleY; + useSimulatedData = Chart.UseSimulatedData; + simulatedDataValue = Chart.SimulatedDataValue; + clusterSize = Chart.ClusterSize; + clusterSizeInput = Chart.ClusterSizeInput; + dataValueInput = Chart.DataValueInput; + + GUILayout.Space(6); + } - GUILayout.Space(6); + _techLevels.DrawTechLevelSelector(); } - _techLevels.DrawTechLevelSelector(); - GUILayout.Space(4); GUILayout.EndVertical(); @@ -227,6 +259,20 @@ private void EngineManagerGUI(int WindowID) private void DrawColumnMenuWindow(int windowID) { + // Close button in top right + var closeButtonStyle = new GUIStyle(GUI.skin.button) + { + normal = { textColor = new Color(1f, 0.4f, 0.4f) }, + hover = { textColor = new Color(1f, 0.2f, 0.2f) }, + fontStyle = FontStyle.Bold, + fontSize = 14 + }; + if (GUI.Button(new Rect(columnMenuRect.width - 29, 4, 25, 20), "✕", closeButtonStyle)) + { + showColumnMenu = false; + return; + } + DrawColumnMenu(new Rect(0, 20, columnMenuRect.width, columnMenuRect.height - 20)); GUI.DragWindow(new Rect(0, 0, columnMenuRect.width, 20)); } @@ -260,7 +306,12 @@ protected void DrawConfigTable(IEnumerable 0) purchaseLabel = unlocked ? "Owned" : $"Buy ({cost:N0}√)"; else - purchaseLabel = "Free"; + purchaseLabel = unlocked ? "Owned" : "Free"; if (GUI.Button(purchaseRect, purchaseLabel, smallButtonStyle)) { @@ -490,56 +541,50 @@ private void DrawColumnMenu(Rect menuRect) "Tech", "Cost", "Actions" }; - float yPos = menuRect.y + 10; - float leftX = menuRect.x + 10; - - GUIStyle headerStyle = EngineConfigStyles.MenuHeader; - GUIStyle labelStyle = EngineConfigStyles.MenuLabel; - - GUI.Label(new Rect(leftX, yPos, menuRect.width - 20, 20), "Column Visibility", headerStyle); - yPos += 25; + float yPos = menuRect.y + 5; + float leftX = menuRect.x + 8; - if (Event.current.type == EventType.Repaint) + GUIStyle headerStyle = new GUIStyle(GUI.skin.label) { - Texture2D separatorTex = Styles.CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.5f)); - GUI.DrawTexture(new Rect(leftX, yPos, menuRect.width - 20, 1), separatorTex); - } - yPos += 10; + fontSize = 11, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) } + }; + GUIStyle labelStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + normal = { textColor = new Color(0.85f, 0.85f, 0.85f) } + }; - GUI.Label(new Rect(leftX + 100, yPos, 60, 20), "Full", headerStyle); - GUI.Label(new Rect(leftX + 170, yPos, 60, 20), "Compact", headerStyle); - yPos += 25; + GUI.Label(new Rect(leftX + 80, yPos, 50, 16), "Full", headerStyle); + GUI.Label(new Rect(leftX + 135, yPos, 60, 16), "Compact", headerStyle); + yPos += 18; - Rect scrollRect = new Rect(leftX, yPos, menuRect.width - 20, menuRect.height - 80); + Rect scrollRect = new Rect(leftX, yPos, menuRect.width - 16, menuRect.height - 28); GUI.BeginGroup(scrollRect); float itemY = 0; for (int i = 0; i < columnNames.Length; i++) { - GUI.Label(new Rect(5, itemY, 90, 20), columnNames[i], labelStyle); + GUI.Label(new Rect(0, itemY, 75, 18), columnNames[i], labelStyle); - bool newFullVisible = GUI.Toggle(new Rect(105, itemY, 20, 20), columnsVisibleFull[i], ""); + bool newFullVisible = GUI.Toggle(new Rect(85, itemY + 1, 18, 18), columnsVisibleFull[i], ""); if (newFullVisible != columnsVisibleFull[i]) { columnsVisibleFull[i] = newFullVisible; } - bool newCompactVisible = GUI.Toggle(new Rect(175, itemY, 20, 20), columnsVisibleCompact[i], ""); + bool newCompactVisible = GUI.Toggle(new Rect(140, itemY + 1, 18, 18), columnsVisibleCompact[i], ""); if (newCompactVisible != columnsVisibleCompact[i]) { columnsVisibleCompact[i] = newCompactVisible; } - itemY += 25; + itemY += 20; } GUI.EndGroup(); - - if (GUI.Button(new Rect(menuRect.x + menuRect.width - 60, menuRect.y + menuRect.height - 30, 50, 20), "Close")) - { - showColumnMenu = false; - } } private void InitializeColumnVisibility() @@ -894,6 +939,12 @@ private string GetRowTooltip(ConfigNode node) { List tooltipParts = new List(); + // Color palette + string headerColor = "#FFA726"; // Orange + string propNameColor = "#7DD9FF"; // Cyan/Blue + string valueColor = "#E6D68A"; // Yellow/Gold + string unitColor = "#B0B0B0"; // Light gray + if (node.HasValue("description")) tooltipParts.Add(node.GetValue("description")); @@ -931,7 +982,7 @@ private string GetRowTooltip(ConfigNode node) string name = propNode.GetValue("name"); if (string.IsNullOrWhiteSpace(name)) continue; - string line = $" • {name}"; + string line = $" • {name}"; string ratioStr2 = null; if (propNode.TryGetValue("ratio", ref ratioStr2) && float.TryParse(ratioStr2, out float ratio) && totalMassFlow > 0f && totalRatio > 0f) @@ -940,17 +991,22 @@ private string GetRowTooltip(ConfigNode node) var resource = PartResourceLibrary.Instance?.GetDefinition(name); - string massFlowStr = propMassFlow >= 1f - ? $"{propMassFlow:F2} kg/s" - : $"{propMassFlow * 1000f:F1} g/s"; - if (resource != null) { - float volumeFlow = propMassFlow / (float)resource.density; - line += $": {volumeFlow:F2} L/s ({massFlowStr})"; + // resource.density is in t/unit, convert to kg/unit, then to units/s + float volumeFlow = propMassFlow / (float)(resource.density * 1000f); + line += $": {volumeFlow:F2} units/s"; + + string massFlowStr = propMassFlow >= 1f + ? $"{propMassFlow:F2} kg/s" + : $"{propMassFlow * 1000f:F1} g/s"; + line += $" ({massFlowStr})"; } else { + string massFlowStr = propMassFlow >= 1f + ? $"{propMassFlow:F2} kg/s" + : $"{propMassFlow * 1000f:F1} g/s"; line += $": {massFlowStr}"; } } @@ -959,7 +1015,7 @@ private string GetRowTooltip(ConfigNode node) } if (propellantLines.Count > 0) - tooltipParts.Add($"Propellant Consumption:\n{string.Join("\n", propellantLines)}"); + tooltipParts.Add($"Propellant Consumption:\n{string.Join("\n", propellantLines)}"); } return tooltipParts.Count > 0 ? string.Join("\n\n", tooltipParts) : string.Empty; diff --git a/Source/Engines/UI/EngineConfigInfoPanel.cs b/Source/Engines/UI/EngineConfigInfoPanel.cs index 082b7394..82111eff 100644 --- a/Source/Engines/UI/EngineConfigInfoPanel.cs +++ b/Source/Engines/UI/EngineConfigInfoPanel.cs @@ -274,7 +274,7 @@ private float DrawSimulationControls(float x, float width, float yPos, float max bool hasRealData = realCurrentData >= 0f && realMaxData > 0f; GUIStyle sectionStyle = EngineConfigStyles.InfoSection; - sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f); + sectionStyle.normal.textColor = Color.white; GUI.Label(new Rect(x, yPos, width, 20), "Simulate:", sectionStyle); yPos += 24; diff --git a/Source/Utilities/Styles.cs b/Source/Utilities/Styles.cs index 86c437d8..0fc950dc 100644 --- a/Source/Utilities/Styles.cs +++ b/Source/Utilities/Styles.cs @@ -35,7 +35,7 @@ internal static void InitStyles() styleEditorTooltip.alignment = TextAnchor.MiddleLeft; styleEditorPanel = new GUIStyle(); - styleEditorPanel.normal.background = CreateColorPixel(new Color32(7,54,66,200)); + styleEditorPanel.normal.background = CreateColorPixel(new Color32(45,45,45,200)); styleEditorPanel.border = new RectOffset(27, 27, 27, 27); styleEditorPanel.padding = new RectOffset(5, 5, 6, 0); styleEditorPanel.normal.textColor = new Color32(147,161,161,255); From 52be4435eefdee27d4f3a0b0415ebabcf4f0caa1 Mon Sep 17 00:00:00 2001 From: Arodoid Date: Mon, 9 Feb 2026 13:23:34 -0800 Subject: [PATCH 10/12] MM Patch fix, and other fixes --- RealFuels/RF_TestFlight_UISupport.cfg | 33 ++- Source/Engines/ModuleEngineConfigs.cs | 9 +- Source/Engines/UI/EngineConfigChart.cs | 18 +- Source/Engines/UI/EngineConfigGUI.cs | 275 ++++++++++++++++++++++--- 4 files changed, 296 insertions(+), 39 deletions(-) diff --git a/RealFuels/RF_TestFlight_UISupport.cfg b/RealFuels/RF_TestFlight_UISupport.cfg index 540a4814..af92a0be 100644 --- a/RealFuels/RF_TestFlight_UISupport.cfg +++ b/RealFuels/RF_TestFlight_UISupport.cfg @@ -1,11 +1,34 @@ // RealFuels TestFlight UI Support -// This patch copies TestFlight reliability values from TESTFLIGHT nodes into CONFIG nodes -// so the RealFuels engine configuration UI can display them. -// This runs after RealismOverhaul but before TESTFLIGHT nodes are deleted. -@PART[*]:HAS[@MODULE[Module*EngineConfigs]]:BEFORE[zTestFlight]:AFTER[RealismOverhaul] +// Process standard engine configs +@PART[*]:HAS[@MODULE[ModuleEngineConfigs]] +:NEEDS[TestFlight] +:AFTER[RealismOverhaul] +:BEFORE[zTestFlight] { - @MODULE[Module*EngineConfigs] + @MODULE[ModuleEngineConfigs] + { + // Copy reliability values for RealFuels UI display + @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityStart]] { &ignitionReliabilityStart = #$TESTFLIGHT/ignitionReliabilityStart$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityEnd]] { &ignitionReliabilityEnd = #$TESTFLIGHT/ignitionReliabilityEnd$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityStart]] { &cycleReliabilityStart = #$TESTFLIGHT/cycleReliabilityStart$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityEnd]] { &cycleReliabilityEnd = #$TESTFLIGHT/cycleReliabilityEnd$ } + + // Copy burn time values for failure probability chart + @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedBurnTime]] { &ratedBurnTime = #$TESTFLIGHT/ratedBurnTime$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedContinuousBurnTime]] { &ratedContinuousBurnTime = #$TESTFLIGHT/ratedContinuousBurnTime$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#testedBurnTime]] { &testedBurnTime = #$TESTFLIGHT/testedBurnTime$ } + @CONFIG:HAS[@TESTFLIGHT:HAS[#overburnPenalty]] { &overburnPenalty = #$TESTFLIGHT/overburnPenalty$ } + } +} + +// Process bimodal engine configs (same logic as above) +@PART[*]:HAS[@MODULE[ModuleBimodalEngineConfigs]] +:NEEDS[TestFlight] +:AFTER[RealismOverhaul] +:BEFORE[zTestFlight] +{ + @MODULE[ModuleBimodalEngineConfigs] { // Copy reliability values for RealFuels UI display @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityStart]] { &ignitionReliabilityStart = #$TESTFLIGHT/ignitionReliabilityStart$ } diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index f988fb9b..46124559 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1230,8 +1230,13 @@ internal void GUIApplyConfig(string configName) /// internal void DrawSelectButton(ConfigNode node, bool isSelected, Action applyCallback) { - // Hook point only - external mods (RP-1) patch this to track tech node context - // Actual rendering is handled by EngineConfigGUI table system + // Hook point for external mods (RP-1) to patch and track tech node context + // RP-1's Harmony prefix sets techNode here, and postfix clears it after this method returns + // So we must invoke the callback HERE, not after this returns, to keep techNode set + string configName = node?.GetValue("name") ?? "null"; + + // Invoke the callback while we're still inside this method (before RP-1's postfix clears techNode) + applyCallback?.Invoke(configName); } private bool lastShowRFGUI = false; diff --git a/Source/Engines/UI/EngineConfigChart.cs b/Source/Engines/UI/EngineConfigChart.cs index 0c161db8..00267e53 100644 --- a/Source/Engines/UI/EngineConfigChart.cs +++ b/Source/Engines/UI/EngineConfigChart.cs @@ -63,7 +63,23 @@ public void Draw(ConfigNode configNode, float width, float height) configNode.TryGetValue("ratedContinuousBurnTime", ref ratedContinuousBurnTime); // Skip chart if this is a cumulative-limited engine (continuous << total) - if (ratedContinuousBurnTime < ratedBurnTime * 0.9f) return; + if (ratedContinuousBurnTime < ratedBurnTime * 0.9f) + { + // Display error message for dual burn time configs + GUIStyle redCenteredStyle = new GUIStyle(GUI.skin.label) + { + normal = { textColor = Color.red }, + alignment = TextAnchor.MiddleCenter, + wordWrap = true + }; + + GUILayout.BeginVertical(GUILayout.Width(width), GUILayout.Height(height)); + GUILayout.FlexibleSpace(); + GUILayout.Label("Dual burn time configurations (continuous/cumulative)\nare not supported for reliability charts", redCenteredStyle); + GUILayout.FlexibleSpace(); + GUILayout.EndVertical(); + return; + } // Read testedBurnTime to match TestFlight's exact behavior float testedBurnTime = 0f; diff --git a/Source/Engines/UI/EngineConfigGUI.cs b/Source/Engines/UI/EngineConfigGUI.cs index 9db35536..e90d7803 100644 --- a/Source/Engines/UI/EngineConfigGUI.cs +++ b/Source/Engines/UI/EngineConfigGUI.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using UnityEngine; using KSP.Localization; using KSP.UI.Screens; @@ -20,6 +21,14 @@ public class EngineConfigGUI private readonly EngineConfigTextures _textures; private EngineConfigChart _chart; + // RP-1 integration for credit display + private static bool _rp1Checked = false; + private static Type _unlockCreditHandlerType = null; + private static PropertyInfo _unlockCreditInstanceProperty = null; + private static PropertyInfo _totalCreditProperty = null; + private static double _cachedCredits = 0; + private static int _creditCacheFrame = -1; + // GUI state private static Vector3 mousePos = Vector3.zero; private static Rect guiWindowRect = new Rect(0, 0, 0, 0); @@ -41,7 +50,7 @@ public class EngineConfigGUI // Column visibility customization private bool showColumnMenu = false; - private static Rect columnMenuRect = new Rect(100, 100, 220, 400); + private static Rect columnMenuRect = new Rect(100, 100, 220, 500); private static bool[] columnsVisibleFull = new bool[18]; private static bool[] columnsVisibleCompact = new bool[18]; private static bool columnVisibilityInitialized = false; @@ -138,25 +147,77 @@ public void OnGUI() EditorUnlock(); myToolTip = myToolTip.Trim(); + + guiWindowRect = GUILayout.Window(unchecked((int)_module.part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", _module.part.partInfo.title), Styles.styleEditorPanel); + + if (showColumnMenu) + { + columnMenuRect = GUI.Window(unchecked((int)_module.part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Column Settings", Styles.styleEditorPanel); + } + + // Draw tooltip AFTER all windows to ensure it appears on top if (!string.IsNullOrEmpty(myToolTip)) { - int offset = inPartsEditor ? -330 : 440; + // Check if this is a button tooltip (marked with [BTN]) + bool isButtonTooltip = myToolTip.StartsWith("[BTN]"); + string displayText = isButtonTooltip ? myToolTip.Substring(5) : myToolTip; + var tooltipStyle = new GUIStyle(EngineConfigStyles.ChartTooltip) { fontSize = 13, - wordWrap = true, + wordWrap = false, // Disable word wrap for button tooltips to get natural width normal = { background = _textures.ChartTooltipBg } }; - var content = new GUIContent(myToolTip); - float tooltipHeight = tooltipStyle.CalcHeight(content, toolTipWidth); - GUI.Box(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, tooltipHeight), myToolTip, tooltipStyle); - } - guiWindowRect = GUILayout.Window(unchecked((int)_module.part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", _module.part.partInfo.title), Styles.styleEditorPanel); + var content = new GUIContent(displayText); - if (showColumnMenu) - { - columnMenuRect = GUI.Window(unchecked((int)_module.part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Column Settings", Styles.styleEditorPanel); + // Calculate dynamic width based on content + float actualTooltipWidth; + float tooltipHeight; + + if (isButtonTooltip) + { + // For button tooltips: use natural width of content with some padding + Vector2 contentSize = tooltipStyle.CalcSize(content); + actualTooltipWidth = Mathf.Min(contentSize.x + 20, 400); // Max 400px width + tooltipStyle.wordWrap = actualTooltipWidth >= 400; // Enable wrap only if we hit max width + tooltipHeight = tooltipStyle.CalcHeight(content, actualTooltipWidth); + } + else + { + // For row tooltips: use fixed width with word wrap + tooltipStyle.wordWrap = true; + actualTooltipWidth = toolTipWidth; + tooltipHeight = tooltipStyle.CalcHeight(content, actualTooltipWidth); + } + + // Position button tooltips near cursor, row tooltips at fixed offset + float tooltipX, tooltipY; + if (isButtonTooltip) + { + // Position near cursor: to the right and slightly down + tooltipX = mousePos.x + 20; + tooltipY = mousePos.y + 10; + + // Keep tooltip on screen + if (tooltipX + actualTooltipWidth > Screen.width) + tooltipX = mousePos.x - actualTooltipWidth - 10; // Show to the left instead + if (tooltipY + tooltipHeight > Screen.height) + tooltipY = Screen.height - tooltipHeight - 10; + } + else + { + // Original positioning for row tooltips + int offset = inPartsEditor ? -330 : 440; + tooltipX = guiWindowRect.xMin + offset; + tooltipY = mousePos.y - 5; + } + + // Draw tooltip with higher depth (negative value = on top) + int oldDepth = GUI.depth; + GUI.depth = -1000; + GUI.Box(new Rect(tooltipX, tooltipY, actualTooltipWidth, tooltipHeight), displayText, tooltipStyle); + GUI.depth = oldDepth; } } @@ -167,10 +228,15 @@ public void OnGUI() private void EngineManagerGUI(int WindowID) { GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); - GUILayout.Space(6); + GUILayout.Space(12); // Increased spacing to prevent overlap with window title GUILayout.BeginHorizontal(); - var descStyle = new GUIStyle(GUI.skin.label) { padding = new RectOffset(0, 0, 0, 0), margin = new RectOffset(0, 0, 0, 0) }; + var descStyle = new GUIStyle(GUI.skin.label) + { + padding = new RectOffset(0, 0, 0, 0), + margin = new RectOffset(0, 0, 0, 0), + normal = { textColor = Color.white } // Make description text white + }; GUILayout.Label(_module.EditorDescription, descStyle); GUILayout.FlexibleSpace(); if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100))) @@ -274,7 +340,7 @@ private void DrawColumnMenuWindow(int windowID) } DrawColumnMenu(new Rect(0, 20, columnMenuRect.width, columnMenuRect.height - 20)); - GUI.DragWindow(new Rect(0, 0, columnMenuRect.width, 20)); + GUI.DragWindow(); // Allow dragging from anywhere in the window } #endregion @@ -358,8 +424,8 @@ protected void DrawConfigTable(IEnumerable row.Apply()); + // Pass null callback - we don't want to invoke anything during rendering, only during button clicks + _module.DrawSelectButton(row.Node, row.IsSelected, null); DrawConfigRow(tableRowRect, row, isHovered, isLocked); @@ -483,30 +549,66 @@ private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action if (cost <= 0 && !unlocked && canUse) EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); - float buttonWidth = rect.width / 2f - 2f; - Rect switchRect = new Rect(rect.x, rect.y, buttonWidth, rect.height); - Rect purchaseRect = new Rect(rect.x + buttonWidth + 4f, rect.y, buttonWidth, rect.height); + // Calculate button widths dynamically based on their labels + string switchLabel = isSelected ? "Active" : "Switch"; + float switchWidth = smallButtonStyle.CalcSize(new GUIContent(switchLabel)).x + 10f; // Add padding + + GUI.enabled = canUse && !unlocked && cost > 0; + string purchaseLabel; + string purchaseTooltip = string.Empty; + + if (cost > 0) + { + // Check if we can use credits to reduce the cost + double displayCost = cost; + if (!unlocked && TryGetCreditAdjustedCost(cost, out double creditsAvailable, out double costAfterCredits)) + { + displayCost = costAfterCredits; + double creditsUsed = cost - costAfterCredits; + // Use special marker [BTN] so we can position this tooltip near cursor + purchaseTooltip = $"[BTN]Entry Cost: {cost:N0}√\n" + + $"Credits Available: {creditsAvailable:N0}\n" + + $"Credits Used: {creditsUsed:N0}\n" + + $"Final Cost: {costAfterCredits:N0}√"; + } + + // Show final cost after credits on the button + purchaseLabel = unlocked ? "Owned" : $"Buy ({displayCost:N0}√)"; + } + else + purchaseLabel = unlocked ? "Owned" : "Free"; + + float purchaseWidth = smallButtonStyle.CalcSize(new GUIContent(purchaseLabel)).x + 10f; + + // Position buttons: Switch on left, Purchase on right + Rect switchRect = new Rect(rect.x, rect.y, switchWidth, rect.height); + Rect purchaseRect = new Rect(rect.x + switchWidth + 4f, rect.y, purchaseWidth, rect.height); GUI.enabled = !isSelected; - string switchLabel = isSelected ? "Active" : "Switch"; if (GUI.Button(switchRect, switchLabel, smallButtonStyle)) { if (!unlocked && cost <= 0) - EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired")); + { + // Auto-purchase free configs using DrawSelectButton callback + _module.DrawSelectButton(node, isSelected, (cfgName) => + { + EntryCostManager.Instance.PurchaseConfig(cfgName, node.GetValue("techRequired")); + }); + } apply?.Invoke(); } GUI.enabled = canUse && !unlocked && cost > 0; - string purchaseLabel; - if (cost > 0) - purchaseLabel = unlocked ? "Owned" : $"Buy ({cost:N0}√)"; - else - purchaseLabel = unlocked ? "Owned" : "Free"; - - if (GUI.Button(purchaseRect, purchaseLabel, smallButtonStyle)) + if (GUI.Button(purchaseRect, new GUIContent(purchaseLabel, purchaseTooltip), smallButtonStyle)) { - if (EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired"))) - apply?.Invoke(); + // Call DrawSelectButton with PurchaseConfig as the callback + // This ensures PurchaseConfig runs INSIDE DrawSelectButton (before RP-1's postfix clears techNode) + // RP-1's Harmony patches: Prefix sets techNode -> DrawSelectButton body (with callback) -> Postfix clears techNode + _module.DrawSelectButton(node, isSelected, (cfgName) => + { + if (EntryCostManager.Instance.PurchaseConfig(cfgName, node.GetValue("techRequired"))) + apply?.Invoke(); + }); } GUI.enabled = true; @@ -667,7 +769,43 @@ private void CalculateColumnWidths(List 0) + { + double displayCost = cost; + // Check if credits would reduce the cost + if (!unlocked && TryGetCreditAdjustedCost(cost, out _, out double costAfterCredits)) + displayCost = costAfterCredits; + + purchaseLabel = unlocked ? "Owned" : $"Buy ({displayCost:N0}√)"; + } + else + { + purchaseLabel = unlocked ? "Owned" : "Free"; + } + float purchaseWidth = buttonStyle.CalcSize(new GUIContent(purchaseLabel)).x; + + // Total width = both buttons + spacing between them + float totalWidth = switchWidth + purchaseWidth + 8f; // 4px spacing between buttons + if (totalWidth > maxActionWidth) + maxActionWidth = totalWidth; + } + + ConfigColumnWidths[17] = Mathf.Max(maxActionWidth, 160f); // Minimum 160px ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f); ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f); ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f); @@ -1023,6 +1161,81 @@ private string GetRowTooltip(ConfigNode node) #endregion + #region RP-1 Credit Integration + + private static void CheckRP1Integration() + { + if (_rp1Checked) return; + _rp1Checked = true; + + try + { + // Try to find RP-1's UnlockCreditHandler + // Note: The assembly name is "RP-0" (with hyphen), not "RP0" + var rp1Assembly = AssemblyLoader.loadedAssemblies + .FirstOrDefault(a => a.name == "RP-0"); + + if (rp1Assembly != null) + { + _unlockCreditHandlerType = rp1Assembly.assembly.GetType("RP0.UnlockCreditHandler"); + if (_unlockCreditHandlerType != null) + { + _unlockCreditInstanceProperty = _unlockCreditHandlerType.GetProperty("Instance", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + _totalCreditProperty = _unlockCreditHandlerType.GetProperty("TotalCredit", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[RealFuels] Failed to initialize RP-1 integration: {ex.Message}"); + } + } + + private static bool TryGetCreditAdjustedCost(double entryCost, out double creditsAvailable, out double costAfterCredits) + { + creditsAvailable = 0; + costAfterCredits = entryCost; + + CheckRP1Integration(); + + if (_unlockCreditHandlerType == null || _unlockCreditInstanceProperty == null || _totalCreditProperty == null) + return false; + + try + { + // Cache credits per frame to avoid expensive reflection calls every button render + int currentFrame = Time.frameCount; + if (_creditCacheFrame != currentFrame) + { + var instance = _unlockCreditInstanceProperty.GetValue(null); + if (instance != null) + { + _cachedCredits = (double)_totalCreditProperty.GetValue(instance); + _creditCacheFrame = currentFrame; + } + else + { + return false; + } + } + + creditsAvailable = _cachedCredits; + double creditsUsed = Math.Min(creditsAvailable, entryCost); + costAfterCredits = entryCost - creditsUsed; + return creditsUsed > 0; + } + catch (Exception ex) + { + Debug.LogError($"[RealFuels] Failed to query RP-1 credits: {ex.Message}"); + } + + return false; + } + + #endregion + #region Helper Methods internal void MarkWindowDirty() From 3b2022e4b594e4dd5ef08364ef85cd43e8f0693e Mon Sep 17 00:00:00 2001 From: Arodoid Date: Wed, 11 Feb 2026 21:34:20 -0800 Subject: [PATCH 11/12] feat: Enhance Engine Configuration GUI with Survival Probability and Slider Controls - Set default view to compact mode in EngineConfigGUI. - Introduced slider for burn time with input field and include ignition toggle. - Updated survival probability calculations based on slider time in EngineConfigGUI. - Modified reliability section to display survival probabilities for starting, current, and max data units. - Improved layout and styling for survival probability display in EngineConfigInfoPanel. - Refactored simulation controls to include new slider and checkbox for ignition. - Removed redundant failure rate summary section from EngineConfigInfoPanel. --- Source/Engines/ModuleEngineConfigs.cs | 41 +++ Source/Engines/UI/EngineConfigChart.cs | 304 +++++++------------- Source/Engines/UI/EngineConfigGUI.cs | 108 +++++--- Source/Engines/UI/EngineConfigInfoPanel.cs | 307 +++++++++++---------- 4 files changed, 373 insertions(+), 387 deletions(-) diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs index 46124559..ddd9caf8 100644 --- a/Source/Engines/ModuleEngineConfigs.cs +++ b/Source/Engines/ModuleEngineConfigs.cs @@ -1103,16 +1103,36 @@ public void ChangeEngineType(string newEngineType) public static bool userClosedWindow = false; + // Track the currently open GUI to ensure only one is visible at a time + private static ModuleEngineConfigsBase currentlyOpenGUI = null; + private void OnPartActionGuiDismiss(Part p) { if (p == part || p.isSymmetryCounterPart(part)) + { showRFGUI = false; + // Clear the currently open GUI tracker if it's this instance + if (currentlyOpenGUI == this) + currentlyOpenGUI = null; + } } private void OnPartActionUIShown(UIPartActionWindow window, Part p) { if (p == part && !userClosedWindow) + { + // Close any previously open GUI before opening this one + if (currentlyOpenGUI != null && currentlyOpenGUI != this) + { + currentlyOpenGUI.showRFGUI = false; + } + showRFGUI = isMaster; + + // Track this as the currently open GUI + if (isMaster) + currentlyOpenGUI = this; + } } public override void OnInactive() @@ -1249,7 +1269,24 @@ public void OnGUI() if (showRFGUI && !lastShowRFGUI) { userClosedWindow = false; + + // Close any previously open GUI before opening this one + if (currentlyOpenGUI != null && currentlyOpenGUI != this) + { + currentlyOpenGUI.showRFGUI = false; + } + + // Track this as the currently open GUI + currentlyOpenGUI = this; } + // If the user clicked the PAW button to hide the GUI + else if (!showRFGUI && lastShowRFGUI) + { + // Clear the currently open GUI tracker if it's this instance + if (currentlyOpenGUI == this) + currentlyOpenGUI = null; + } + lastShowRFGUI = showRFGUI; GUI.OnGUI(); @@ -1260,6 +1297,10 @@ internal void CloseWindow() { showRFGUI = false; userClosedWindow = true; + + // Clear the currently open GUI tracker if it's this instance + if (currentlyOpenGUI == this) + currentlyOpenGUI = null; } diff --git a/Source/Engines/UI/EngineConfigChart.cs b/Source/Engines/UI/EngineConfigChart.cs index 00267e53..9cac06f1 100644 --- a/Source/Engines/UI/EngineConfigChart.cs +++ b/Source/Engines/UI/EngineConfigChart.cs @@ -22,6 +22,8 @@ public class EngineConfigChart private int _clusterSize = 1; private string _clusterSizeInput = "1"; private string _dataValueInput = "0"; + private string _sliderTimeInput = "100.0"; + private bool _includeIgnition = false; // Public properties for external access public bool UseLogScaleX { get => _useLogScaleX; set => _useLogScaleX = value; } @@ -31,6 +33,8 @@ public class EngineConfigChart public int ClusterSize { get => _clusterSize; set => _clusterSize = value; } public string ClusterSizeInput { get => _clusterSizeInput; set => _clusterSizeInput = value; } public string DataValueInput { get => _dataValueInput; set => _dataValueInput = value; } + public string SliderTimeInput { get => _sliderTimeInput; set => _sliderTimeInput = value; } + public bool IncludeIgnition { get => _includeIgnition; set => _includeIgnition = value; } public EngineConfigChart(ModuleEngineConfigsBase module) { @@ -41,7 +45,7 @@ public EngineConfigChart(ModuleEngineConfigsBase module) /// /// Draws the failure probability chart and info panel side by side. /// - public void Draw(ConfigNode configNode, float width, float height) + public void Draw(ConfigNode configNode, float width, float height, ref float sliderTime) { _textures.EnsureInitialized(); EngineConfigStyles.Initialize(); @@ -111,6 +115,12 @@ public void Draw(ConfigNode configNode, float width, float height) // Info panel area (right side) Rect infoRect = new Rect(containerRect.x + chartWidth, containerRect.y, infoWidth, height); + // Get ignition reliability values + float ignitionReliabilityStart = 1f; + float ignitionReliabilityEnd = 1f; + configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart); + configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd); + // Calculate survival curves var curveData = ChartMath.CalculateSurvivalCurves( cycleReliabilityStart, cycleReliabilityEnd, @@ -126,29 +136,68 @@ public void Draw(ConfigNode configNode, float width, float height) float cycleReliabilityCurrent = 0f; ChartMath.SurvivalCurveData currentCurveData = default; + float ignitionReliabilityCurrent = 0f; if (hasCurrentData) { cycleReliabilityCurrent = ChartMath.EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd); + ignitionReliabilityCurrent = ChartMath.EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd); currentCurveData = ChartMath.CalculateSurvivalCurve( cycleReliabilityCurrent, ratedBurnTime, cycleCurve, maxTime, _clusterSize); } + // If including ignition, apply ignition reliability to all curves (vertical scaling) + if (_includeIgnition) + { + // Apply cluster math to ignition probabilities + float clusteredIgnitionStart = _clusterSize > 1 ? Mathf.Pow(ignitionReliabilityStart, _clusterSize) : ignitionReliabilityStart; + float clusteredIgnitionEnd = _clusterSize > 1 ? Mathf.Pow(ignitionReliabilityEnd, _clusterSize) : ignitionReliabilityEnd; + float clusteredIgnitionCurrent = hasCurrentData && _clusterSize > 1 ? Mathf.Pow(ignitionReliabilityCurrent, _clusterSize) : ignitionReliabilityCurrent; + + // Apply clustered ignition to start curve + for (int i = 0; i < curveData.SurvivalProbs.Length; i++) + { + curveData.SurvivalProbs[i] *= clusteredIgnitionStart; + } + + // Apply clustered ignition to end curve + for (int i = 0; i < curveData.SurvivalProbsEnd.Length; i++) + { + curveData.SurvivalProbsEnd[i] *= clusteredIgnitionEnd; + } + + // Apply clustered ignition to current curve if available + if (hasCurrentData) + { + for (int i = 0; i < currentCurveData.SurvivalProbs.Length; i++) + { + currentCurveData.SurvivalProbs[i] *= clusteredIgnitionCurrent; + } + } + + // Update min survival prob + curveData.MinSurvivalProb = Mathf.Min( + curveData.SurvivalProbs[curveData.SurvivalProbs.Length - 1], + curveData.SurvivalProbsEnd[curveData.SurvivalProbsEnd.Length - 1] + ); + } + // Draw chart DrawChartBackground(chartRect); - DrawChartZones(plotArea, ratedBurnTime, testedBurnTime, hasTestedBurnTime, maxTime, overburnPenalty); DrawGrid(plotArea, curveData.MinSurvivalProb, maxTime); DrawCurves(plotArea, curveData, currentCurveData, hasCurrentData, maxTime, curveData.MinSurvivalProb); + DrawSliderTimeLine(plotArea, sliderTime, maxTime); DrawAxisLabels(chartRect, plotArea, maxTime, curveData.MinSurvivalProb); DrawLegend(plotArea, hasCurrentData); - DrawChartTooltip(plotArea, curveData, currentCurveData, hasCurrentData, - cycleReliabilityStart, cycleReliabilityCurrent, cycleReliabilityEnd, - ratedBurnTime, testedBurnTime, hasTestedBurnTime, maxTime, overburnPenalty, cycleCurve); // Draw info panel DrawInfoPanel(infoRect, configNode, ratedBurnTime, testedBurnTime, hasTestedBurnTime, cycleReliabilityStart, cycleReliabilityEnd, hasCurrentData, cycleReliabilityCurrent, - dataPercentage, currentDataValue, maxDataValue, realCurrentData, realMaxData); + dataPercentage, currentDataValue, maxDataValue, realCurrentData, realMaxData, + cycleCurve, ref sliderTime, maxTime); + + // Sync back slider time input for consistency + _sliderTimeInput = $"{sliderTime:F1}"; } #region Chart Background & Zones @@ -165,68 +214,6 @@ private void DrawChartBackground(Rect chartRect) "Survival Probability vs Burn Time", EngineConfigStyles.ChartTitle); } - private void DrawChartZones(Rect plotArea, float ratedBurnTime, float testedBurnTime, - bool hasTestedBurnTime, float maxTime, float overburnPenalty) - { - // Zone boundaries - float startupEndX = ChartMath.TimeToXPosition(5f, maxTime, plotArea.x, plotArea.width, _useLogScaleX); - float ratedCushionedX = ChartMath.TimeToXPosition(ratedBurnTime + 5f, maxTime, plotArea.x, plotArea.width, _useLogScaleX); - float testedX = hasTestedBurnTime ? ChartMath.TimeToXPosition(testedBurnTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX) : 0f; - - float referenceBurnTime = hasTestedBurnTime ? testedBurnTime : ratedBurnTime; - float max100xTime = referenceBurnTime * 2.5f; - float max100xX = ChartMath.TimeToXPosition(max100xTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX); - - // Clamp to plot area - float plotAreaRight = plotArea.x + plotArea.width; - startupEndX = Mathf.Clamp(startupEndX, plotArea.x, plotAreaRight); - ratedCushionedX = Mathf.Clamp(ratedCushionedX, plotArea.x, plotAreaRight); - testedX = Mathf.Clamp(testedX, plotArea.x, plotAreaRight); - max100xX = Mathf.Clamp(max100xX, plotArea.x, plotAreaRight); - - if (Event.current.type != EventType.Repaint) return; - - // Draw zone backgrounds - DrawZoneRect(plotArea.x, startupEndX, plotArea, _textures.ChartStartupZone); - DrawZoneRect(startupEndX, ratedCushionedX, plotArea, _textures.ChartGreenZone); - - if (hasTestedBurnTime) - { - DrawZoneRect(ratedCushionedX, testedX, plotArea, _textures.ChartYellowZone); - DrawZoneRect(testedX, max100xX, plotArea, _textures.ChartRedZone); - DrawZoneRect(max100xX, plotAreaRight, plotArea, _textures.ChartDarkRedZone); - } - else - { - DrawZoneRect(ratedCushionedX, max100xX, plotArea, _textures.ChartRedZone); - DrawZoneRect(max100xX, plotAreaRight, plotArea, _textures.ChartDarkRedZone); - } - - // Draw zone markers - Vector2 mousePos = Event.current.mousePosition; - bool mouseInPlot = plotArea.Contains(mousePos); - - DrawZoneMarker(startupEndX, plotArea, _textures.ChartMarkerBlue, mouseInPlot, mousePos); - DrawZoneMarker(ratedCushionedX, plotArea, _textures.ChartMarkerGreen, mouseInPlot, mousePos); - if (hasTestedBurnTime) DrawZoneMarker(testedX, plotArea, _textures.ChartMarkerYellow, mouseInPlot, mousePos); - DrawZoneMarker(max100xX, plotArea, _textures.ChartMarkerDarkRed, mouseInPlot, mousePos); - } - - private void DrawZoneRect(float x1, float x2, Rect plotArea, Texture2D texture) - { - float width = Mathf.Max(0, x2 - x1); - if (width > 0) - GUI.DrawTexture(new Rect(x1, plotArea.y, width, plotArea.height), texture); - } - - private void DrawZoneMarker(float x, Rect plotArea, Texture2D texture, bool mouseInPlot, Vector2 mousePos) - { - if (x < plotArea.x || x > plotArea.x + plotArea.width) return; - - bool nearMarker = mouseInPlot && Mathf.Abs(mousePos.x - x) < 8f; - float lineWidth = nearMarker ? 4f : 1f; - GUI.DrawTexture(new Rect(x - lineWidth / 2f, plotArea.y, lineWidth, plotArea.height), texture); - } #endregion @@ -364,169 +351,73 @@ private void DrawCurveLine(Vector2[] points, Texture2D texture, Rect plotArea) #endregion - #region Legend + #region Slider Time Line - private void DrawLegend(Rect plotArea, bool hasCurrentData) + private void DrawSliderTimeLine(Rect plotArea, float sliderTime, float maxTime) { - GUIStyle legendStyle = EngineConfigStyles.Legend; - float legendWidth = 110f; - float legendX = plotArea.x + plotArea.width - legendWidth; - float legendY = plotArea.y + 5; + if (Event.current.type != EventType.Repaint) return; - // Orange circle and line for 0 data - ChartMath.DrawCircle(new Rect(legendX, legendY + 5, 8, 8), _textures.ChartOrangeLine); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 7, 15, 3), _textures.ChartOrangeLine); - GUI.Label(new Rect(legendX + 28, legendY, 80, 18), "0 Data", legendStyle); + float x = ChartMath.TimeToXPosition(sliderTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX); - if (hasCurrentData) - { - ChartMath.DrawCircle(new Rect(legendX, legendY + 23, 8, 8), _textures.ChartBlueLine); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), _textures.ChartBlueLine); - GUI.Label(new Rect(legendX + 28, legendY + 18, 100, 18), "Current Data", legendStyle); + // Clamp to plot area + if (x < plotArea.x || x > plotArea.x + plotArea.width) return; - ChartMath.DrawCircle(new Rect(legendX, legendY + 41, 8, 8), _textures.ChartGreenLine); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 43, 15, 3), _textures.ChartGreenLine); - GUI.Label(new Rect(legendX + 28, legendY + 36, 80, 18), "Max Data", legendStyle); - } - else - { - ChartMath.DrawCircle(new Rect(legendX, legendY + 23, 8, 8), _textures.ChartGreenLine); - GUI.DrawTexture(new Rect(legendX + 10, legendY + 25, 15, 3), _textures.ChartGreenLine); - GUI.Label(new Rect(legendX + 28, legendY + 18, 80, 18), "Max Data", legendStyle); - } + // Draw white vertical line + Color whiteTransparent = new Color(1f, 1f, 1f, 0.8f); + Texture2D whiteLine = MakeTex(2, 2, whiteTransparent); + GUI.DrawTexture(new Rect(x - 1f, plotArea.y, 2f, plotArea.height), whiteLine); } - #endregion - - #region Tooltip - - private void DrawChartTooltip(Rect plotArea, ChartMath.SurvivalCurveData startCurve, - ChartMath.SurvivalCurveData currentCurve, bool hasCurrentData, - float cycleReliabilityStart, float cycleReliabilityCurrent, float cycleReliabilityEnd, - float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, - float maxTime, float overburnPenalty, FloatCurve cycleCurve) + private Texture2D MakeTex(int width, int height, Color col) { - Vector2 mousePos = Event.current.mousePosition; - if (!plotArea.Contains(mousePos)) return; - - // Draw hover line - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(new Rect(mousePos.x, plotArea.y, 1, plotArea.height), _textures.ChartHoverLine); - } - - // Calculate tooltip content - float mouseT = ChartMath.XPositionToTime(mousePos.x, maxTime, plotArea.x, plotArea.width, _useLogScaleX); - mouseT = Mathf.Clamp(mouseT, 0f, maxTime); + Color[] pix = new Color[width * height]; + for (int i = 0; i < pix.Length; i++) + pix[i] = col; + Texture2D result = new Texture2D(width, height); + result.SetPixels(pix); + result.Apply(); + return result; + } - string tooltipText = BuildTooltipText(mouseT, ratedBurnTime, testedBurnTime, hasTestedBurnTime, - cycleReliabilityStart, cycleReliabilityCurrent, cycleReliabilityEnd, - hasCurrentData, cycleCurve, maxTime, overburnPenalty); + #endregion - DrawTooltip(mousePos, tooltipText); - } + #region Legend - private string BuildTooltipText(float time, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, - float cycleReliabilityStart, float cycleReliabilityCurrent, float cycleReliabilityEnd, - bool hasCurrentData, FloatCurve cycleCurve, float maxTime, float overburnPenalty) + private void DrawLegend(Rect plotArea, bool hasCurrentData) { - // Determine zone - string zoneName; - string zoneColor; + GUIStyle legendStyle = EngineConfigStyles.Legend; + float legendWidth = 110f; + float legendX = plotArea.x + plotArea.width - legendWidth; + float legendY = plotArea.y + 5; - if (time <= 5f) - { - zoneName = "Engine Startup"; - zoneColor = "#6699CC"; - } - else if (time <= ratedBurnTime + 5f) - { - zoneName = "Rated Operation"; - zoneColor = "#66DD66"; - } - else if (hasTestedBurnTime && time <= testedBurnTime) - { - zoneName = "Tested Overburn"; - zoneColor = "#FFCC44"; - } - else if (time <= (hasTestedBurnTime ? testedBurnTime : ratedBurnTime) * 2.5f) + // Orange line for 0 data + GUI.DrawTexture(new Rect(legendX, legendY + 7, 15, 3), _textures.ChartOrangeLine); + GUI.Label(new Rect(legendX + 18, legendY, 80, 18), "0 Data", legendStyle); + + if (hasCurrentData) { - zoneName = "Severe Overburn"; - zoneColor = "#FF6666"; + GUI.DrawTexture(new Rect(legendX, legendY + 25, 15, 3), _textures.ChartBlueLine); + GUI.Label(new Rect(legendX + 18, legendY + 18, 100, 18), "Current Data", legendStyle); + + GUI.DrawTexture(new Rect(legendX, legendY + 43, 15, 3), _textures.ChartGreenLine); + GUI.Label(new Rect(legendX + 18, legendY + 36, 80, 18), "Max Data", legendStyle); } else { - zoneName = "Maximum Overburn"; - zoneColor = "#CC2222"; - } - - float cycleModifier = cycleCurve.Evaluate(time); - string valueColor = "#E6D68A"; - string timeStr = ChartMath.FormatTime(time); - - // Calculate survival probabilities at this time - float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; - float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; - float baseRateCurrent = hasCurrentData ? -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime : 0f; - - float surviveStart = ChartMath.CalculateSurvivalProbAtTime(time, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve); - float surviveEnd = ChartMath.CalculateSurvivalProbAtTime(time, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve); - float surviveCurrent = hasCurrentData ? ChartMath.CalculateSurvivalProbAtTime(time, ratedBurnTime, cycleReliabilityCurrent, baseRateCurrent, cycleCurve) : 0f; - - // Apply cluster math - if (_clusterSize > 1) - { - surviveStart = Mathf.Pow(surviveStart, _clusterSize); - surviveEnd = Mathf.Pow(surviveEnd, _clusterSize); - if (hasCurrentData) surviveCurrent = Mathf.Pow(surviveCurrent, _clusterSize); + GUI.DrawTexture(new Rect(legendX, legendY + 25, 15, 3), _textures.ChartGreenLine); + GUI.Label(new Rect(legendX + 18, legendY + 18, 80, 18), "Max Data", legendStyle); } - - string orangeColor = "#FF8033"; - string blueColor = "#7DD9FF"; - string greenColor = "#4DE64D"; - string entityName = _clusterSize > 1 ? "cluster" : "engine"; - - string tooltip = $"{zoneName}\n\n"; - tooltip += $"This {entityName} has a "; - - if (hasCurrentData) - tooltip += $"{surviveStart * 100f:F1}% / {surviveCurrent * 100f:F1}% / {surviveEnd * 100f:F1}%"; - else - tooltip += $"{surviveStart * 100f:F1}% / {surviveEnd * 100f:F1}%"; - - tooltip += $" chance to survive to {timeStr}\n\n"; - tooltip += $"Cycle modifier: {cycleModifier:F2}×"; - - return tooltip; - } - - private void DrawTooltip(Vector2 mousePos, string text) - { - if (string.IsNullOrEmpty(text)) return; - - GUIStyle tooltipStyle = EngineConfigStyles.ChartTooltip; - tooltipStyle.normal.background = _textures.ChartTooltipBg; - - GUIContent content = new GUIContent(text); - Vector2 size = tooltipStyle.CalcSize(content); - - float tooltipX = mousePos.x + 15; - float tooltipY = mousePos.y + 15; - - if (tooltipX + size.x > Screen.width) tooltipX = mousePos.x - size.x - 5; - if (tooltipY + size.y > Screen.height) tooltipY = mousePos.y - size.y - 5; - - Rect tooltipRect = new Rect(tooltipX, tooltipY, size.x, size.y); - GUI.Box(tooltipRect, content, tooltipStyle); } #endregion + #region Info Panel Integration private void DrawInfoPanel(Rect rect, ConfigNode configNode, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, float cycleReliabilityStart, float cycleReliabilityEnd, bool hasCurrentData, float cycleReliabilityCurrent, - float dataPercentage, float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData) + float dataPercentage, float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData, + FloatCurve cycleCurve, ref float sliderTime, float maxTime) { float ignitionReliabilityStart = 1f; float ignitionReliabilityEnd = 1f; @@ -542,7 +433,8 @@ private void DrawInfoPanel(Rect rect, ConfigNode configNode, float ratedBurnTime hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage, currentDataValue, maxDataValue, realCurrentData, realMaxData, ref _useSimulatedData, ref _simulatedDataValue, ref _clusterSize, - ref _clusterSizeInput, ref _dataValueInput); + ref _clusterSizeInput, ref _dataValueInput, ref sliderTime, ref _sliderTimeInput, + ref _includeIgnition, cycleCurve, maxTime); } #endregion diff --git a/Source/Engines/UI/EngineConfigGUI.cs b/Source/Engines/UI/EngineConfigGUI.cs index e90d7803..cb8454c0 100644 --- a/Source/Engines/UI/EngineConfigGUI.cs +++ b/Source/Engines/UI/EngineConfigGUI.cs @@ -43,7 +43,7 @@ public class EngineConfigGUI private Vector2 configScrollPos = Vector2.zero; private GUIContent configGuiContent; - private static bool compactView = false; + private static bool compactView = true; // Default to compact view private bool useLogScaleX = false; private bool useLogScaleY = false; private static bool showBottomSection = true; @@ -61,6 +61,9 @@ public class EngineConfigGUI private int clusterSize = 1; private string clusterSizeInput = "1"; private string dataValueInput = "0"; + private float sliderTime = 100f; + private string sliderTimeInput = "100.0"; + private bool includeIgnition = false; private const int ConfigRowHeight = 22; private const int ConfigMaxVisibleRows = 16; @@ -213,9 +216,9 @@ public void OnGUI() tooltipY = mousePos.y - 5; } - // Draw tooltip with higher depth (negative value = on top) + // Draw tooltip with maximum priority depth (most negative = on top) int oldDepth = GUI.depth; - GUI.depth = -1000; + GUI.depth = -100000; // Use very negative depth to ensure it's on top of everything GUI.Box(new Rect(tooltipX, tooltipY, actualTooltipWidth, tooltipHeight), displayText, tooltipStyle); GUI.depth = oldDepth; } @@ -282,8 +285,10 @@ private void EngineManagerGUI(int WindowID) Chart.ClusterSize = clusterSize; Chart.ClusterSizeInput = clusterSizeInput; Chart.DataValueInput = dataValueInput; + Chart.SliderTimeInput = sliderTimeInput; + Chart.IncludeIgnition = includeIgnition; - Chart.Draw(_module.config, guiWindowRect.width - 10, 375); + Chart.Draw(_module.config, guiWindowRect.width - 10, 375, ref sliderTime); useLogScaleX = Chart.UseLogScaleX; useLogScaleY = Chart.UseLogScaleY; @@ -292,6 +297,8 @@ private void EngineManagerGUI(int WindowID) clusterSize = Chart.ClusterSize; clusterSizeInput = Chart.ClusterSizeInput; dataValueInput = Chart.DataValueInput; + sliderTimeInput = Chart.SliderTimeInput; + includeIgnition = Chart.IncludeIgnition; GUILayout.Space(6); } @@ -448,7 +455,8 @@ private void DrawHeaderRow(Rect headerRect) Localizer.GetStringByTag("#RF_Engine_Isp"), Localizer.GetStringByTag("#RF_Engine_Enginemass"), Localizer.GetStringByTag("#RF_Engine_TLTInfo_Gimbal"), Localizer.GetStringByTag("#RF_EngineRF_Ignitions"), Localizer.GetStringByTag("#RF_Engine_ullage"), Localizer.GetStringByTag("#RF_Engine_pressureFed"), - "Rated (s)", "Tested (s)", "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", + "Rated (s)", "Tested (s)", "Ign Reliability", "Burn No Data", "Burn Max Data", + "Survival @ Time", Localizer.GetStringByTag("#RF_Engine_Requires"), "Extra Cost", "" }; string[] tooltips = { @@ -456,8 +464,9 @@ private void DrawHeaderRow(Rect headerRect) "Sea level and vacuum Isp", "Engine mass", "Gimbal range", "Ignitions", "Ullage requirement", "Pressure-fed", "Rated burn time", "Tested burn time (real-world test duration)", - "Ignition reliability at 0 data", "Ignition reliability at max data", + "Ignition reliability (starting / max data)", "Cycle reliability at 0 data", "Cycle reliability at max data", + "Survival probability at slider time (starting / max data)", "Required technology", "Extra cost for this config", "Switch and purchase actions" }; @@ -524,10 +533,10 @@ private void DrawConfigRow(Rect rowRect, ModuleEngineConfigsBase.ConfigRowDefini drawCell(8, GetBoolSymbol(row.Node, "pressureFed")); drawCell(9, GetRatedBurnTimeString(row.Node)); drawCell(10, GetTestedBurnTimeString(row.Node)); - drawCell(11, GetIgnitionReliabilityStartString(row.Node)); - drawCell(12, GetIgnitionReliabilityEndString(row.Node)); - drawCell(13, GetCycleReliabilityStartString(row.Node)); - drawCell(14, GetCycleReliabilityEndString(row.Node)); + drawCell(11, GetIgnitionReliabilityString(row.Node)); + drawCell(12, GetCycleReliabilityStartString(row.Node)); + drawCell(13, GetCycleReliabilityEndString(row.Node)); + drawCell(14, GetSurvivalAtTimeString(row.Node)); drawCell(15, GetTechString(row.Node)); drawCell(16, GetCostDeltaString(row.Node)); @@ -639,8 +648,8 @@ private void DrawColumnMenu(Rect menuRect) string[] columnNames = { "Name", "Thrust", "Min%", "ISP", "Mass", "Gimbal", "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)", - "Ign No Data", "Ign Max Data", "Burn No Data", "Burn Max Data", - "Tech", "Cost", "Actions" + "Ign Rel.", "Burn No Data", "Burn Max Data", + "Survival", "Tech", "Cost", "Actions" }; float yPos = menuRect.y + 5; @@ -694,13 +703,15 @@ private void InitializeColumnVisibility() if (columnVisibilityInitialized) return; + // Full view: all columns visible for (int i = 0; i < 18; i++) columnsVisibleFull[i] = true; + // Compact view: Name, Thrust, ISP, Mass, Ignitions, Ullage, Press-Fed, Ign Rel., Survival, Tech, Cost, Actions for (int i = 0; i < 18; i++) columnsVisibleCompact[i] = false; - int[] compactColumns = { 0, 1, 3, 4, 6, 9, 10, 15, 16, 17 }; + int[] compactColumns = { 0, 1, 3, 4, 6, 7, 8, 11, 14, 15, 16, 17 }; foreach (int col in compactColumns) columnsVisibleCompact[col] = true; @@ -749,10 +760,10 @@ private void CalculateColumnWidths(List maxActionWidth) maxActionWidth = totalWidth; } @@ -810,6 +821,11 @@ private void CalculateColumnWidths(List ratedBurnTime; + float overburnPenalty = 2.0f; + node.TryGetValue("overburnPenalty", ref overburnPenalty); + FloatCurve cycleCurve = ChartMath.BuildTestFlightCycleCurve(ratedBurnTime, testedBurnTime, overburnPenalty, hasTestedBurnTime); + + // Calculate survival at slider time + float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; + float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; + + float surviveStart = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve); + float surviveEnd = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve); + + // Faded colors for better readability in table + string fadedOrange = "#FFB380"; + string fadedGreen = "#80E680"; + + return $"{surviveStart:P1} / {surviveEnd:P1}"; + } + internal string GetTechString(ConfigNode node) { if (!node.HasValue("techRequired")) diff --git a/Source/Engines/UI/EngineConfigInfoPanel.cs b/Source/Engines/UI/EngineConfigInfoPanel.cs index 82111eff..ca93afd4 100644 --- a/Source/Engines/UI/EngineConfigInfoPanel.cs +++ b/Source/Engines/UI/EngineConfigInfoPanel.cs @@ -22,7 +22,8 @@ public void Draw(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasT bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage, float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData, ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, - ref string clusterSizeInput, ref string dataValueInput) + ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput, + ref bool includeIgnition, FloatCurve cycleCurve, float maxGraphTime) { // Draw background if (Event.current.type == EventType.Repaint) @@ -32,11 +33,11 @@ public void Draw(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasT float yPos = rect.y + 4; - // Draw reliability section - yPos = DrawReliabilitySection(rect, yPos, ratedBurnTime, testedBurnTime, hasTestedBurnTime, - cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd, - hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, - dataPercentage, currentDataValue, maxDataValue, clusterSize); + // Draw reliability section (burn survival - three sections with optional ignition text) + yPos = DrawReliabilitySection(rect, yPos, ratedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, cycleReliabilityCurrent, + ignitionReliabilityStart, ignitionReliabilityEnd, ignitionReliabilityCurrent, + hasCurrentData, cycleCurve, clusterSize, sliderTime, includeIgnition); // Separator if (Event.current.type == EventType.Repaint) @@ -46,147 +47,149 @@ public void Draw(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasT yPos += 10; // Side-by-side: Data Gains (left) and Controls (right) - yPos = DrawSideBySideSection(rect, yPos, ratedBurnTime, maxDataValue, realCurrentData, realMaxData, - ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput); - - // Bottom separator - if (Event.current.type == EventType.Repaint) - { - GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), _textures.ChartSeparator); - } - yPos += 10; - - // Failure rate summary - DrawFailureRateSummary(rect, yPos, ratedBurnTime, currentDataValue, cycleReliabilityStart, - cycleReliabilityEnd, clusterSize); + yPos = DrawSideBySideSection(rect, yPos, ratedBurnTime, maxGraphTime, maxDataValue, realCurrentData, realMaxData, + ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput, + ref sliderTime, ref sliderTimeInput, ref includeIgnition); } #region Reliability Section private float DrawReliabilitySection(Rect rect, float yPos, - float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime, - float cycleReliabilityStart, float cycleReliabilityEnd, - float ignitionReliabilityStart, float ignitionReliabilityEnd, - bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, - float dataPercentage, float currentDataValue, float maxDataValue, int clusterSize) + float ratedBurnTime, + float cycleReliabilityStart, float cycleReliabilityEnd, float cycleReliabilityCurrent, + float ignitionReliabilityStart, float ignitionReliabilityEnd, float ignitionReliabilityCurrent, + bool hasCurrentData, FloatCurve cycleCurve, + int clusterSize, float sliderTime, bool includeIgnition) { - // Calculate success probabilities - float ratedSuccessStart = cycleReliabilityStart * 100f; - float ratedSuccessEnd = cycleReliabilityEnd * 100f; - float ignitionSuccessStart = ignitionReliabilityStart * 100f; - float ignitionSuccessEnd = ignitionReliabilityEnd * 100f; - - float testedSuccessStart = 0f; - float testedSuccessEnd = 0f; - if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) - { - float testedRatio = testedBurnTime / ratedBurnTime; - testedSuccessStart = Mathf.Pow(cycleReliabilityStart, testedRatio) * 100f; - testedSuccessEnd = Mathf.Pow(cycleReliabilityEnd, testedRatio) * 100f; - } + // Color codes + string orangeColor = "#FF8033"; + string blueColor = "#7DD9FF"; + string greenColor = "#4DE64D"; - float ratedSuccessCurrent = 0f; - float testedSuccessCurrent = 0f; - float ignitionSuccessCurrent = 0f; - if (hasCurrentData) + // Calculate base rates + float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime; + float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime; + float baseRateCurrent = hasCurrentData ? -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime : 0f; + + // Calculate burn survival probabilities at slider time + float surviveStart = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve); + float surviveEnd = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve); + float surviveCurrent = hasCurrentData ? ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityCurrent, baseRateCurrent, cycleCurve) : 0f; + + // If including ignition, multiply by ignition reliability + if (includeIgnition) { - ratedSuccessCurrent = cycleReliabilityCurrent * 100f; - ignitionSuccessCurrent = ignitionReliabilityCurrent * 100f; - if (hasTestedBurnTime && testedBurnTime > ratedBurnTime) - { - float testedRatio = testedBurnTime / ratedBurnTime; - testedSuccessCurrent = Mathf.Pow(cycleReliabilityCurrent, testedRatio) * 100f; - } + surviveStart *= ignitionReliabilityStart; + surviveEnd *= ignitionReliabilityEnd; + if (hasCurrentData) surviveCurrent *= ignitionReliabilityCurrent; } // Apply cluster math if (clusterSize > 1) { - ignitionSuccessStart = Mathf.Pow(ignitionSuccessStart / 100f, clusterSize) * 100f; - ignitionSuccessEnd = Mathf.Pow(ignitionSuccessEnd / 100f, clusterSize) * 100f; - ratedSuccessStart = Mathf.Pow(ratedSuccessStart / 100f, clusterSize) * 100f; - ratedSuccessEnd = Mathf.Pow(ratedSuccessEnd / 100f, clusterSize) * 100f; - testedSuccessStart = Mathf.Pow(testedSuccessStart / 100f, clusterSize) * 100f; - testedSuccessEnd = Mathf.Pow(testedSuccessEnd / 100f, clusterSize) * 100f; - - if (hasCurrentData) - { - ignitionSuccessCurrent = Mathf.Pow(ignitionSuccessCurrent / 100f, clusterSize) * 100f; - ratedSuccessCurrent = Mathf.Pow(ratedSuccessCurrent / 100f, clusterSize) * 100f; - testedSuccessCurrent = Mathf.Pow(testedSuccessCurrent / 100f, clusterSize) * 100f; - } + surviveStart = Mathf.Pow(surviveStart, clusterSize); + surviveEnd = Mathf.Pow(surviveEnd, clusterSize); + if (hasCurrentData) surviveCurrent = Mathf.Pow(surviveCurrent, clusterSize); } - // Color codes - string orangeColor = "#FF8033"; - string blueColor = "#7DD9FF"; - string greenColor = "#4DE64D"; - string valueColor = "#E6D68A"; + // Layout: three sections side-by-side + float sectionHeight = 125f; // Keep constant height to prevent window jumping + float totalWidth = rect.width - 16f; + float numSections = hasCurrentData ? 3f : 2f; + float sectionWidth = totalWidth / numSections; + float startX = rect.x + 8f; - // Header - string headerText = $"At Starting"; - if (hasCurrentData) - { - string dataLabel = maxDataValue > 0f ? $"{currentDataValue:F0} du" : $"{dataPercentage * 100f:F0}%"; - headerText += $" / Current ({dataLabel})"; - } - headerText += $" / Max:"; + float currentX = startX; - GUIStyle sectionStyle = EngineConfigStyles.InfoSection; - sectionStyle.normal.textColor = Color.white; - GUI.Label(new Rect(rect.x, yPos, rect.width, 20), headerText, sectionStyle); - yPos += 24; + // Calculate ignition probabilities with cluster math + float igniteStart = clusterSize > 1 ? Mathf.Pow(ignitionReliabilityStart, clusterSize) : ignitionReliabilityStart; + float igniteEnd = clusterSize > 1 ? Mathf.Pow(ignitionReliabilityEnd, clusterSize) : ignitionReliabilityEnd; + float igniteCurrent = hasCurrentData ? (clusterSize > 1 ? Mathf.Pow(ignitionReliabilityCurrent, clusterSize) : ignitionReliabilityCurrent) : 0f; - // Build narrative - string engineText = clusterSize > 1 ? $"A cluster of {clusterSize} engines" : "This engine"; - string forAll = clusterSize > 1 ? " for all" : ""; - string combinedText = $"{engineText} has a "; + // Draw Starting DU section + DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Starting DU", orangeColor, surviveStart, sliderTime, clusterSize, igniteStart, includeIgnition); + currentX += sectionWidth; - // Ignition success rates + // Draw Current DU section (if applicable) if (hasCurrentData) - combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessCurrent:F1}% / {ignitionSuccessEnd:F1}%"; - else - combinedText += $"{ignitionSuccessStart:F1}% / {ignitionSuccessEnd:F1}%"; + { + DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Current DU", blueColor, surviveCurrent, sliderTime, clusterSize, igniteCurrent, includeIgnition); + currentX += sectionWidth; + } - combinedText += $" chance{forAll} to ignite, then a "; + // Draw Max DU section + DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Max DU", greenColor, surviveEnd, sliderTime, clusterSize, igniteEnd, includeIgnition); - // Rated burn success rates - if (hasCurrentData) - combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessCurrent:F1}% / {ratedSuccessEnd:F1}%"; - else - combinedText += $"{ratedSuccessStart:F1}% / {ratedSuccessEnd:F1}%"; + yPos += sectionHeight + 12; - combinedText += $" chance{forAll} to burn for {ChartMath.FormatTime(ratedBurnTime)} (rated)"; + return yPos; + } - // Tested burn success rates - if (hasTestedBurnTime) + private void DrawSurvivalSection(float x, float y, float width, float height, string title, string color, float survivalProb, float time, int clusterSize, float ignitionProb, bool includeIgnition) + { + // Header with colored text (no background) + GUIStyle headerStyle = new GUIStyle(GUI.skin.label) { - combinedText += ", and a "; - if (hasCurrentData) - combinedText += $"{testedSuccessStart:F1}% / {testedSuccessCurrent:F1}% / {testedSuccessEnd:F1}%"; - else - combinedText += $"{testedSuccessStart:F1}% / {testedSuccessEnd:F1}%"; - - combinedText += $" chance{forAll} to burn to {ChartMath.FormatTime(testedBurnTime)} (tested)"; + fontSize = 18, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.UpperCenter, + richText = true, + normal = { textColor = Color.white } + }; + + string headerText = $"{title}"; + GUI.Label(new Rect(x, y, width, 24), headerText, headerStyle); + + // Survival probability + float survivalPercent = survivalProb * 100f; + string survivalText = $"{survivalPercent:F2}%"; + GUIStyle survivalStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 20, + alignment = TextAnchor.MiddleCenter, + richText = true, + normal = { textColor = Color.white } + }; + GUI.Label(new Rect(x, y + 26, width, 28), survivalText, survivalStyle); + + // "1 in X" text + float failureRate = 1f - survivalProb; + float oneInX = failureRate > 0.0001f ? (1f / failureRate) : 9999f; + string entityText = clusterSize > 1 ? $"cluster of {clusterSize}" : "burn"; + string failText = $"1 in {oneInX:F1} {entityText}s will fail to reach {ChartMath.FormatTime(time)}"; + GUIStyle failStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + alignment = TextAnchor.UpperCenter, + richText = true, + wordWrap = true, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) } + }; + GUI.Label(new Rect(x + 4, y + 54, width - 8, 50), failText, failStyle); + + // Small ignition probability text (only when not including ignition) + if (!includeIgnition) + { + float ignitionPercent = ignitionProb * 100f; + string ignitionText = $"Ignition: {ignitionPercent:F2}%"; + GUIStyle ignitionStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + alignment = TextAnchor.UpperCenter, + normal = { textColor = new Color(0.9f, 0.9f, 0.9f) } + }; + GUI.Label(new Rect(x + 4, y + 92, width - 8, 18), ignitionText, ignitionStyle); } - combinedText += "."; - - GUIStyle textStyle = EngineConfigStyles.InfoText; - float combinedHeight = textStyle.CalcHeight(new GUIContent(combinedText), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, combinedHeight), combinedText, textStyle); - yPos += combinedHeight + 12; - - return yPos; } #endregion #region Side-by-Side Section - private float DrawSideBySideSection(Rect rect, float yPos, float ratedBurnTime, float maxDataValue, + private float DrawSideBySideSection(Rect rect, float yPos, float ratedBurnTime, float maxGraphTime, float maxDataValue, float realCurrentData, float realMaxData, ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, - ref string clusterSizeInput, ref string dataValueInput) + ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput, ref bool includeIgnition) { float columnStartY = yPos; float leftColumnWidth = rect.width * 0.5f; @@ -199,8 +202,9 @@ private float DrawSideBySideSection(Rect rect, float yPos, float ratedBurnTime, // Draw right column: Simulation Controls float rightColumnEndY = DrawSimulationControls(rightColumnX, rightColumnWidth, columnStartY, - maxDataValue, realCurrentData, realMaxData, - ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput); + maxGraphTime, maxDataValue, realCurrentData, realMaxData, + ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput, + ref sliderTime, ref sliderTimeInput, ref includeIgnition); // Draw vertical separator if (Event.current.type == EventType.Repaint) @@ -241,7 +245,7 @@ private float DrawDataGainsSection(float x, float width, float yPos, float rated for (int i = 0; i < failureTypes.Length; i++) { - string failText = $" ({failurePercents[i]:F0}%) {failureTypes[i]} +{failureDu[i]} du"; + string failText = $" ({failurePercents[i]:F0}%) {failureTypes[i]} +{failureDu[i]} du"; GUI.Label(new Rect(x, yPos, width, bulletHeight), failText, indentedBulletStyle); yPos += bulletHeight; } @@ -266,10 +270,10 @@ private float DrawDataGainsSection(float x, float width, float yPos, float rated return yPos; } - private float DrawSimulationControls(float x, float width, float yPos, float maxDataValue, + private float DrawSimulationControls(float x, float width, float yPos, float maxGraphTime, float maxDataValue, float realCurrentData, float realMaxData, ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, - ref string clusterSizeInput, ref string dataValueInput) + ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput, ref bool includeIgnition) { bool hasRealData = realCurrentData >= 0f && realMaxData > 0f; @@ -282,10 +286,45 @@ private float DrawSimulationControls(float x, float width, float yPos, float max var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; GUIStyle controlStyle = EngineConfigStyles.Control; + // Common width for all controls + float btnWidth = width - 16; + + // Burn Time slider (first control) - max matches graph range + GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Burn Time (s)", controlStyle); + yPos += 16; + + float maxSliderTime = maxGraphTime; + sliderTime = GUI.HorizontalSlider(new Rect(x + 8, yPos, btnWidth - 50, 16), + sliderTime, 0f, maxSliderTime, GUI.skin.horizontalSlider, GUI.skin.horizontalSliderThumb); + + sliderTimeInput = $"{sliderTime:F1}"; + GUI.SetNextControlName("sliderTimeInput"); + string newTimeInput = GUI.TextField(new Rect(x + btnWidth - 35, yPos - 2, 40, 20), + sliderTimeInput, 8, inputStyle); + + if (newTimeInput != sliderTimeInput) + { + sliderTimeInput = newTimeInput; + if (GUI.GetNameOfFocusedControl() == "sliderTimeInput" && float.TryParse(sliderTimeInput, out float inputTime)) + { + inputTime = Mathf.Clamp(inputTime, 0f, maxSliderTime); + sliderTime = inputTime; + } + } + yPos += 24; + + // Include Ignition checkbox + GUIStyle checkboxStyle = new GUIStyle(GUI.skin.toggle) + { + fontSize = 12, + normal = { textColor = Color.white } + }; + includeIgnition = GUI.Toggle(new Rect(x + 8, yPos, btnWidth, 20), includeIgnition, " Include Ignition", checkboxStyle); + yPos += 24; + // Reset button - float resetBtnWidth = width - 16; string resetButtonText = hasRealData ? $"Set to Current du ({realCurrentData:F0})" : "Set to Current du (0)"; - if (GUI.Button(new Rect(x + 8, yPos, resetBtnWidth, 20), resetButtonText, buttonStyle)) + if (GUI.Button(new Rect(x + 8, yPos, btnWidth, 20), resetButtonText, buttonStyle)) { if (hasRealData) { @@ -305,7 +344,6 @@ private float DrawSimulationControls(float x, float width, float yPos, float max yPos += 24; // Data slider - float btnWidth = width - 16; GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Data (du)", controlStyle); yPos += 16; @@ -368,32 +406,5 @@ private float DrawSimulationControls(float x, float width, float yPos, float max } #endregion - - #region Failure Rate Summary - - private void DrawFailureRateSummary(Rect rect, float yPos, float ratedBurnTime, float currentDataValue, - float cycleReliabilityStart, float cycleReliabilityEnd, int clusterSize) - { - float cycleReliabilityAtCurrentData = ChartMath.EvaluateReliabilityAtData(currentDataValue, - cycleReliabilityStart, cycleReliabilityEnd); - - if (clusterSize > 1) - { - cycleReliabilityAtCurrentData = Mathf.Pow(cycleReliabilityAtCurrentData, clusterSize); - } - - float failureRate = 1f - cycleReliabilityAtCurrentData; - float oneInX = failureRate > 0.0001f ? (1f / failureRate) : 9999f; - - string valueColor = "#E6D68A"; - string failureRateColor = "#FF6666"; - string failureText = $"With {currentDataValue:F0} du, 1 in {oneInX:F1} rated burns will fail ({ChartMath.FormatTime(ratedBurnTime)})"; - - GUIStyle failureRateStyle = EngineConfigStyles.FailureRate; - float failureTextHeight = failureRateStyle.CalcHeight(new GUIContent(failureText), rect.width); - GUI.Label(new Rect(rect.x, yPos, rect.width, failureTextHeight), failureText, failureRateStyle); - } - - #endregion } } From c55e242de5d8291e3286ced75b555fc6c81822ff Mon Sep 17 00:00:00 2001 From: Arodoid Date: Wed, 11 Feb 2026 21:49:26 -0800 Subject: [PATCH 12/12] refactor: Simplify TestFlight UI support configuration by consolidating engine config processing --- RealFuels/RF_TestFlight_UISupport.cfg | 32 +++++---------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/RealFuels/RF_TestFlight_UISupport.cfg b/RealFuels/RF_TestFlight_UISupport.cfg index af92a0be..4a563f2d 100644 --- a/RealFuels/RF_TestFlight_UISupport.cfg +++ b/RealFuels/RF_TestFlight_UISupport.cfg @@ -1,34 +1,12 @@ // RealFuels TestFlight UI Support +// This patch copies TestFlight reliability values from TESTFLIGHT nodes into CONFIG nodes +// so the RealFuels engine configuration UI can display them. +// This runs after RealismOverhaul but before TESTFLIGHT nodes are deleted. -// Process standard engine configs -@PART[*]:HAS[@MODULE[ModuleEngineConfigs]] -:NEEDS[TestFlight] -:AFTER[RealismOverhaul] -:BEFORE[zTestFlight] -{ - @MODULE[ModuleEngineConfigs] - { - // Copy reliability values for RealFuels UI display - @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityStart]] { &ignitionReliabilityStart = #$TESTFLIGHT/ignitionReliabilityStart$ } - @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityEnd]] { &ignitionReliabilityEnd = #$TESTFLIGHT/ignitionReliabilityEnd$ } - @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityStart]] { &cycleReliabilityStart = #$TESTFLIGHT/cycleReliabilityStart$ } - @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityEnd]] { &cycleReliabilityEnd = #$TESTFLIGHT/cycleReliabilityEnd$ } - - // Copy burn time values for failure probability chart - @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedBurnTime]] { &ratedBurnTime = #$TESTFLIGHT/ratedBurnTime$ } - @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedContinuousBurnTime]] { &ratedContinuousBurnTime = #$TESTFLIGHT/ratedContinuousBurnTime$ } - @CONFIG:HAS[@TESTFLIGHT:HAS[#testedBurnTime]] { &testedBurnTime = #$TESTFLIGHT/testedBurnTime$ } - @CONFIG:HAS[@TESTFLIGHT:HAS[#overburnPenalty]] { &overburnPenalty = #$TESTFLIGHT/overburnPenalty$ } - } -} +@PART[*]:HAS[@MODULE[Module*EngineConfigs]]:BEFORE[zTestFlight] -// Process bimodal engine configs (same logic as above) -@PART[*]:HAS[@MODULE[ModuleBimodalEngineConfigs]] -:NEEDS[TestFlight] -:AFTER[RealismOverhaul] -:BEFORE[zTestFlight] { - @MODULE[ModuleBimodalEngineConfigs] + @MODULE[Module*EngineConfigs] { // Copy reliability values for RealFuels UI display @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityStart]] { &ignitionReliabilityStart = #$TESTFLIGHT/ignitionReliabilityStart$ }