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/RealFuels/RF_TestFlight_UISupport.cfg b/RealFuels/RF_TestFlight_UISupport.cfg new file mode 100644 index 00000000..4a563f2d --- /dev/null +++ b/RealFuels/RF_TestFlight_UISupport.cfg @@ -0,0 +1,23 @@ +// 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] + +{ + @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/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/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/ModuleBimodalEngineConfigs.cs b/Source/Engines/ModuleBimodalEngineConfigs.cs index 5ce2d2f8..d2a93f81 100644 --- a/Source/Engines/ModuleBimodalEngineConfigs.cs +++ b/Source/Engines/ModuleBimodalEngineConfigs.cs @@ -121,35 +121,35 @@ public override string GetConfigInfo(ConfigNode config, bool addDescription = tr return info; } - protected override void DrawConfigSelectors(IEnumerable availableConfigNodes) + public 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 internal override void DrawConfigSelectors(IEnumerable availableConfigNodes) { - using (new GUILayout.HorizontalScope()) - { - GUILayout.Label($"{Localizer.GetStringByTag("#RF_BimodalEngine_Currentmode")}: {ActiveModeDescription}"); // Current mode - } - base.DrawPartInfo(); + // Add custom toggle button UI before the config table + if (GUILayout.Button(new GUIContent(ToggleText, toggleButtonHoverInfo))) + ToggleMode(); } - [KSPAction("#RF_BimodalEngine_ToggleEngineMode")] // Toggle Engine Mode public void ToggleAction(KSPActionParam _) => ToggleMode(); @@ -300,9 +300,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; } @@ -320,7 +320,7 @@ private IEnumerator DriveAnimation(bool forward) if (forward && animState.normalizedTime >= switchB9PSAtAnimationTime || !forward && animState.normalizedTime <= switchB9PSAtAnimationTime) { - UpdateB9PSVariants(); + Integrations.UpdateB9PSVariants(); b9psNeedsReset = false; } } @@ -328,7 +328,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 d5b06956..ddd9caf8 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,37 +156,44 @@ 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 void DrawConfigSelectors(IEnumerable availableConfigNodes) + public 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); + } + }; } } } @@ -193,13 +201,11 @@ 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" #region Fields - protected bool compatible = true; + internal bool compatible = true; [KSPField(isPersistant = true)] public string configuration = string.Empty; @@ -298,130 +304,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; @@ -474,7 +356,7 @@ public static void RelocateRCSPawItems(ModuleRCS module) field.group = new BasePAWGroup(groupName, groupDisplayName, false); } - private List FilteredDisplayConfigs(bool update) + internal List FilteredDisplayConfigs(bool update) { if (update || filteredDisplayConfigs == null) { @@ -488,7 +370,6 @@ public override void OnAwake() { techNodes = new ConfigNode(); configs = new List(); - InitializeB9PSReflection(); } public override void OnLoad(ConfigNode node) @@ -540,11 +421,14 @@ public override void OnStart(StartState state) Fields[nameof(showRFGUI)].guiActiveEditor = isMaster; if (HighLogic.LoadedSceneIsEditor) + { GameEvents.onPartActionUIDismiss.Add(OnPartActionGuiDismiss); + GameEvents.onPartActionUIShown.Add(OnPartActionUIShown); + } ConfigSaveLoad(); - LoadB9PSModules(); + Integrations.LoadB9PSModules(); LoadDefaultGimbals(); @@ -558,7 +442,7 @@ public override void OnStart(StartState state) public override void OnStartFinished(StartState state) { - HideB9PSVariantSelectors(); + Integrations.HideB9PSVariantSelectors(); if (pModule is ModuleRCS mrcs) RelocateRCSPawItems(mrcs); } #endregion @@ -588,7 +472,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) @@ -634,12 +518,12 @@ 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")) / 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 @@ -669,6 +553,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")) @@ -730,7 +632,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"); @@ -814,11 +716,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")) @@ -833,7 +735,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); } @@ -876,9 +778,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(); } @@ -913,82 +815,8 @@ 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) + internal Dictionary ExtractGimbals(ConfigNode cfg) { Gimbal ExtractGimbalKeys(ConfigNode c) { @@ -1070,7 +898,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); } @@ -1121,11 +949,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) { @@ -1155,14 +983,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 @@ -1178,7 +1006,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. @@ -1189,11 +1017,11 @@ 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) - cost = scale * CostTL(cost, cfg); + cost = scale * TechLevels.CostTL(cost, cfg); } else { @@ -1263,97 +1091,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 @@ -1364,10 +1101,38 @@ private float CostTL(float cost, ConfigNode cfg = null) [NonSerialized] public bool showRFGUI; + 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() @@ -1375,345 +1140,170 @@ 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); - } - - private static Vector3 mousePos = Vector3.zero; - private Rect guiWindowRect = new Rect(0, 0, 0, 0); - private string myToolTip = string.Empty; - private int counterTT; - private bool editorLocked = false; + GameEvents.onPartActionUIShown.Remove(OnPartActionUIShown); - private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300; - private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth); + // 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. + } - public void OnGUI() + // Tech level management - lazy initialization + private EngineConfigTechLevels _techLevels; + protected EngineConfigTechLevels TechLevels { - 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) + get { - 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)); + if (_techLevels == null) + _techLevels = new EngineConfigTechLevels(this); + return _techLevels; } + } - 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)) + // Integration with B9PartSwitch and TestFlight - lazy initialization + private EngineConfigIntegrations _integrations; + internal EngineConfigIntegrations Integrations + { + get { - int offset = inPartsEditor ? -222 : 440; - GUI.Label(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, toolTipHeight), myToolTip, Styles.styleEditorTooltip); + if (_integrations == null) + _integrations = new EngineConfigIntegrations(this); + return _integrations; } - - guiWindowRect = GUILayout.Window(unchecked((int)part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", part.partInfo.title), Styles.styleEditorPanel); // "Configure " + part.partInfo.title } - private void EditorLock() + // GUI rendering - lazy initialization + private EngineConfigGUI _gui; + private EngineConfigGUI GUI { - if (!editorLocked) + get { - EditorLogic.fetch.Lock(false, false, false, "RFGUILock"); - editorLocked = true; - KSP.UI.Screens.Editor.PartListTooltipMasterController.Instance?.HideTooltip(); + if (_gui == null) + _gui = new EngineConfigGUI(this); + return _gui; } } - private void EditorUnlock() + /// + /// Struct for passing configuration row data to GUI + /// + public struct ConfigRowDefinition { - if (editorLocked) - { - EditorLogic.fetch.Unlock("RFGUILock"); - editorLocked = false; - } + public ConfigNode Node; + public string DisplayName; + public bool IsSelected; + public bool Indent; + public Action Apply; } - protected string GetCostString(ConfigNode node) + /// + /// Builds the list of configuration rows to display in the GUI. + /// Virtual so derived classes can customize the row structure. + /// + public virtual IEnumerable BuildConfigRows() { - string costString = string.Empty; - if (node.HasValue("cost")) + foreach (var node in FilteredDisplayConfigs(false)) { - float curCost = scale * float.Parse(node.GetValue("cost")); - - if (techLevel != -1) + string configName = node.GetValue("name"); + yield return new ConfigRowDefinition { - curCost = CostTL(curCost, node) - CostTL(0f, node); // get purely the config cost difference - } - costString = $" ({((curCost < 0) ? string.Empty : "+")}{curCost:N0}f)"; + Node = node, + DisplayName = GetConfigDisplayName(node), + IsSelected = configName == configuration, + Indent = false, + Apply = () => GUIApplyConfig(configName) + }; } - return costString; } - /// Normal apply action for the 'select ' button. - protected void GUIApplyConfig(string configName) + /// + /// 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) { - SetConfiguration(configName, true); - UpdateSymmetryCounterparts(); - GameEvents.onEditorShipModified.Fire(EditorLogic.fetch.ship); - MarkWindowDirty(); + // Default implementation - just draw the table + // Derived classes can override to add custom UI } - protected void DrawSelectButton(ConfigNode node, bool isSelected, Action apply) + /// + /// Internal callback for GUI to apply a selected configuration. + /// + internal void GUIApplyConfig(string configName) { - 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 (!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 = 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}f)", 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 (!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 (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}f)"; - 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); - } - } - } + SetConfiguration(configName); + UpdateSymmetryCounterparts(); } - virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes) + /// + /// 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. + /// + internal void DrawSelectButton(ConfigNode node, bool isSelected, Action applyCallback) { - foreach (ConfigNode node in availableConfigNodes) - DrawSelectButton(node, node.GetValue("name") == configuration, GUIApplyConfig); - } + // 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"; - virtual protected void DrawPartInfo() - { - // show current info, cost - if (pModule != null && part.partInfo != null) - { - GUILayout.BeginHorizontal(); - string ratedBurnTime = string.Empty; - if (config.HasValue("ratedBurnTime")) - { - 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 - } - 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(); - } + // Invoke the callback while we're still inside this method (before RP-1's postfix clears techNode) + applyCallback?.Invoke(configName); } - protected void DrawTechLevelSelector() + private bool lastShowRFGUI = false; + + public void OnGUI() { - // NK Tech Level - if (techLevel != -1) + if (isMaster && HighLogic.LoadedSceneIsEditor) { - 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) + // If the user clicked the PAW button to show the GUI, clear the userClosedWindow flag + if (showRFGUI && !lastShowRFGUI) { - 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 + userClosedWindow = false; + + // Close any previously open GUI before opening this one + if (currentlyOpenGUI != null && currentlyOpenGUI != this) { - 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") + "f"; - 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; - } + currentlyOpenGUI.showRFGUI = false; } + + // Track this as the currently open GUI + currentlyOpenGUI = this; } - if (GUILayout.Button(plusStr) && (canPlus || canBuy)) + // If the user clicked the PAW button to hide the GUI + else if (!showRFGUI && lastShowRFGUI) { - if (!canBuy || EntryCostManager.Instance.PurchaseTL(tlName, techLevel + 1, tlIncrMult)) - { - techLevel++; - SetConfiguration(); - UpdateSymmetryCounterparts(); - MarkWindowDirty(); - } + // Clear the currently open GUI tracker if it's this instance + if (currentlyOpenGUI == this) + currentlyOpenGUI = null; } - GUILayout.EndHorizontal(); + + lastShowRFGUI = showRFGUI; + + GUI.OnGUI(); } } - private void EngineManagerGUI(int WindowID) + internal void CloseWindow() { - GUILayout.Space(20); - - GUILayout.BeginHorizontal(); - GUILayout.Label(EditorDescription); - GUILayout.EndHorizontal(); - - DrawConfigSelectors(FilteredDisplayConfigs(false)); - - DrawTechLevelSelector(); - DrawPartInfo(); - - 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; - } + showRFGUI = false; + userClosedWindow = true; - GUI.DragWindow(); + // Clear the currently open GUI tracker if it's this instance + if (currentlyOpenGUI == this) + currentlyOpenGUI = null; } + #endregion #region Helpers @@ -1785,7 +1375,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; @@ -1857,10 +1447,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/Engines/UI/ChartMath.cs b/Source/Engines/UI/ChartMath.cs new file mode 100644 index 00000000..3092e1ab --- /dev/null +++ b/Source/Engines/UI/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/UI/EngineConfigChart.cs b/Source/Engines/UI/EngineConfigChart.cs new file mode 100644 index 00000000..9cac06f1 --- /dev/null +++ b/Source/Engines/UI/EngineConfigChart.cs @@ -0,0 +1,442 @@ +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"; + private string _sliderTimeInput = "100.0"; + private bool _includeIgnition = false; + + // 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 string SliderTimeInput { get => _sliderTimeInput; set => _sliderTimeInput = value; } + public bool IncludeIgnition { get => _includeIgnition; set => _includeIgnition = 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, ref float sliderTime) + { + _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) + { + // 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; + 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); + + // 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, + 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; + 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); + 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); + + // Draw info panel + DrawInfoPanel(infoRect, configNode, ratedBurnTime, testedBurnTime, hasTestedBurnTime, + cycleReliabilityStart, cycleReliabilityEnd, hasCurrentData, cycleReliabilityCurrent, + dataPercentage, currentDataValue, maxDataValue, realCurrentData, realMaxData, + cycleCurve, ref sliderTime, maxTime); + + // Sync back slider time input for consistency + _sliderTimeInput = $"{sliderTime:F1}"; + } + + #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); + } + + + #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 Slider Time Line + + private void DrawSliderTimeLine(Rect plotArea, float sliderTime, float maxTime) + { + if (Event.current.type != EventType.Repaint) return; + + float x = ChartMath.TimeToXPosition(sliderTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX); + + // Clamp to plot area + if (x < plotArea.x || x > plotArea.x + plotArea.width) return; + + // 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); + } + + private Texture2D MakeTex(int width, int height, Color col) + { + 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; + } + + #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 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) + { + 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 + { + GUI.DrawTexture(new Rect(legendX, legendY + 25, 15, 3), _textures.ChartGreenLine); + GUI.Label(new Rect(legendX + 18, legendY + 18, 80, 18), "Max Data", legendStyle); + } + } + + #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, + FloatCurve cycleCurve, ref float sliderTime, float maxTime) + { + 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, ref sliderTime, ref _sliderTimeInput, + ref _includeIgnition, cycleCurve, maxTime); + } + + #endregion + } +} diff --git a/Source/Engines/UI/EngineConfigGUI.cs b/Source/Engines/UI/EngineConfigGUI.cs new file mode 100644 index 00000000..cb8454c0 --- /dev/null +++ b/Source/Engines/UI/EngineConfigGUI.cs @@ -0,0 +1,1315 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +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; + + // 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); + private static uint lastPartId = 0; + 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 static bool compactView = true; // Default to compact view + 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, 220, 500); + 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 float sliderTime = 100f; + private string sliderTimeInput = "100.0"; + private bool includeIgnition = false; + + private const int ConfigRowHeight = 22; + private const int ConfigMaxVisibleRows = 16; + private float[] ConfigColumnWidths = new float[18]; + + private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 320 : 380; + + 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 + || showBottomSection != lastShowBottomSection; + + 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; + lastShowBottomSection = showBottomSection; + } + + mousePos = Input.mousePosition; + mousePos.y = Screen.height - mousePos.y; + if (guiWindowRect.Contains(mousePos)) + EditorLock(); + else + 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)) + { + // 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 = false, // Disable word wrap for button tooltips to get natural width + normal = { background = _textures.ChartTooltipBg } + }; + + var content = new GUIContent(displayText); + + // 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 maximum priority depth (most negative = on top) + int oldDepth = GUI.depth; + 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; + } + } + + #endregion + + #region GUI Windows + + private void EngineManagerGUI(int WindowID) + { + GUILayout.BeginVertical(GUILayout.ExpandHeight(false)); + 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), + 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))) + { + 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(7); + DrawConfigSelectors(_module.FilteredDisplayConfigs(false)); + + if (showBottomSection) + { + 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.SliderTimeInput = sliderTimeInput; + Chart.IncludeIgnition = includeIgnition; + + Chart.Draw(_module.config, guiWindowRect.width - 10, 375, ref sliderTime); + + useLogScaleX = Chart.UseLogScaleX; + useLogScaleY = Chart.UseLogScaleY; + useSimulatedData = Chart.UseSimulatedData; + simulatedDataValue = Chart.SimulatedDataValue; + clusterSize = Chart.ClusterSize; + clusterSizeInput = Chart.ClusterSizeInput; + dataValueInput = Chart.DataValueInput; + sliderTimeInput = Chart.SliderTimeInput; + includeIgnition = Chart.IncludeIgnition; + + 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) + { + // 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(); // Allow dragging from anywhere in the window + } + + #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; + const float minWindowWidthCompact = 550f; + // Only enforce full minimum width when bottom section is visible (chart needs the width) + // Use smaller minimum for compact view + guiWindowRect.width = showBottomSection + ? Mathf.Max(requiredWindowWidth, minWindowWidth) + : Mathf.Max(requiredWindowWidth, minWindowWidthCompact); + + 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) + // 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); + + 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 Reliability", "Burn No Data", "Burn Max Data", + "Survival @ Time", + 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 (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" + }; + + 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, 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)); + + 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 = GUI.skin.button; + + 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")); + + // 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; + if (GUI.Button(switchRect, switchLabel, smallButtonStyle)) + { + if (!unlocked && cost <= 0) + { + // 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; + if (GUI.Button(purchaseRect, new GUIContent(purchaseLabel, purchaseTooltip), smallButtonStyle)) + { + // 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; + } + + 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 Rel.", "Burn No Data", "Burn Max Data", + "Survival", "Tech", "Cost", "Actions" + }; + + float yPos = menuRect.y + 5; + float leftX = menuRect.x + 8; + + GUIStyle headerStyle = new GUIStyle(GUI.skin.label) + { + 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 + 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 - 16, menuRect.height - 28); + + GUI.BeginGroup(scrollRect); + float itemY = 0; + + for (int i = 0; i < columnNames.Length; i++) + { + GUI.Label(new Rect(0, itemY, 75, 18), columnNames[i], labelStyle); + + 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(140, itemY + 1, 18, 18), columnsVisibleCompact[i], ""); + if (newCompactVisible != columnsVisibleCompact[i]) + { + columnsVisibleCompact[i] = newCompactVisible; + } + + itemY += 20; + } + + GUI.EndGroup(); + } + + 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, 7, 8, 11, 14, 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), + GetIgnitionReliabilityString(row.Node), + GetCycleReliabilityStartString(row.Node), + GetCycleReliabilityEndString(row.Node), + GetSurvivalAtTimeString(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; + } + } + } + + // Calculate dynamic width for Actions column (index 17) based on button labels + float maxActionWidth = 0f; + var buttonStyle = GUI.skin.button; + foreach (var row in rows) + { + string configName = row.Node.GetValue("name"); + bool unlocked = EngineConfigTechLevels.UnlockedConfig(row.Node, _module.part); + double cost = EntryCostManager.Instance.ConfigEntryCost(configName); + + // Calculate Switch button width (must match DrawActionCell padding) + string switchLabel = "Switch"; + float switchWidth = buttonStyle.CalcSize(new GUIContent(switchLabel)).x + 10f; // Add padding to match DrawActionCell + + // Calculate Purchase button width (can vary based on cost) + string purchaseLabel; + if (cost > 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 + 10f; // Add padding to match DrawActionCell + + // Total width = both buttons + spacing between them (must match DrawActionCell) + float totalWidth = switchWidth + purchaseWidth + 4f; // 4px spacing to match DrawActionCell + 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); + ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f); + + // Fix survival column (index 14) to maximum width to prevent window resizing during slider use + // Calculate width based on "100.0% / 100.0%" (the widest possible value) + float maxSurvivalWidth = cellStyle.CalcSize(new GUIContent("100.0% / 100.0%")).x + 10f; + ConfigColumnWidths[14] = maxSurvivalWidth; + } + + #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 GetIgnitionReliabilityString(ConfigNode node) + { + if (!node.HasValue("ignitionReliabilityStart") || !node.HasValue("ignitionReliabilityEnd")) + return "-"; + + if (!float.TryParse(node.GetValue("ignitionReliabilityStart"), out float valStart)) return "-"; + if (!float.TryParse(node.GetValue("ignitionReliabilityEnd"), out float valEnd)) return "-"; + + return $"{valStart:P1} / {valEnd:P1}"; + } + + 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 GetSurvivalAtTimeString(ConfigNode node) + { + // Check if we have reliability data + if (!node.HasValue("cycleReliabilityStart") || !node.HasValue("cycleReliabilityEnd") || !node.HasValue("ratedBurnTime")) + return "-"; + + if (!float.TryParse(node.GetValue("cycleReliabilityStart"), out float cycleReliabilityStart)) return "-"; + if (!float.TryParse(node.GetValue("cycleReliabilityEnd"), out float cycleReliabilityEnd)) return "-"; + if (!float.TryParse(node.GetValue("ratedBurnTime"), out float ratedBurnTime)) return "-"; + + if (cycleReliabilityStart <= 0f || cycleReliabilityEnd <= 0f || ratedBurnTime <= 0f) return "-"; + + // Build cycle curve + float testedBurnTime = 0f; + bool hasTestedBurnTime = node.TryGetValue("testedBurnTime", ref testedBurnTime) && testedBurnTime > 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")) + 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(); + + // 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")); + + 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); + + if (resource != null) + { + // 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}"; + } + } + + 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 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() + { + 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/UI/EngineConfigInfoPanel.cs b/Source/Engines/UI/EngineConfigInfoPanel.cs new file mode 100644 index 00000000..ca93afd4 --- /dev/null +++ b/Source/Engines/UI/EngineConfigInfoPanel.cs @@ -0,0 +1,410 @@ +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, ref float sliderTime, ref string sliderTimeInput, + ref bool includeIgnition, FloatCurve cycleCurve, float maxGraphTime) + { + // Draw background + if (Event.current.type == EventType.Repaint) + { + GUI.DrawTexture(rect, _textures.InfoPanelBg); + } + + float yPos = rect.y + 4; + + // 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) + { + 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, 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 cycleReliabilityStart, float cycleReliabilityEnd, float cycleReliabilityCurrent, + float ignitionReliabilityStart, float ignitionReliabilityEnd, float ignitionReliabilityCurrent, + bool hasCurrentData, FloatCurve cycleCurve, + int clusterSize, float sliderTime, bool includeIgnition) + { + // Color codes + string orangeColor = "#FF8033"; + string blueColor = "#7DD9FF"; + string greenColor = "#4DE64D"; + + // 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) + { + surviveStart *= ignitionReliabilityStart; + surviveEnd *= ignitionReliabilityEnd; + if (hasCurrentData) surviveCurrent *= ignitionReliabilityCurrent; + } + + // Apply cluster math + if (clusterSize > 1) + { + surviveStart = Mathf.Pow(surviveStart, clusterSize); + surviveEnd = Mathf.Pow(surviveEnd, clusterSize); + if (hasCurrentData) surviveCurrent = Mathf.Pow(surviveCurrent, clusterSize); + } + + // 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; + + float currentX = startX; + + // 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; + + // Draw Starting DU section + DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Starting DU", orangeColor, surviveStart, sliderTime, clusterSize, igniteStart, includeIgnition); + currentX += sectionWidth; + + // Draw Current DU section (if applicable) + if (hasCurrentData) + { + DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Current DU", blueColor, surviveCurrent, sliderTime, clusterSize, igniteCurrent, includeIgnition); + currentX += sectionWidth; + } + + // Draw Max DU section + DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Max DU", greenColor, surviveEnd, sliderTime, clusterSize, igniteEnd, includeIgnition); + + yPos += sectionHeight + 12; + + return yPos; + } + + 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) + { + 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); + } + } + + #endregion + + #region Side-by-Side Section + + 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 float sliderTime, ref string sliderTimeInput, ref bool includeIgnition) + { + 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, + 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) + { + 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 maxGraphTime, float maxDataValue, + float realCurrentData, float realMaxData, + ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize, + ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput, ref bool includeIgnition) + { + bool hasRealData = realCurrentData >= 0f && realMaxData > 0f; + + GUIStyle sectionStyle = EngineConfigStyles.InfoSection; + sectionStyle.normal.textColor = Color.white; + 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; + + // 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 + string resetButtonText = hasRealData ? $"Set to Current du ({realCurrentData:F0})" : "Set to Current du (0)"; + if (GUI.Button(new Rect(x + 8, yPos, btnWidth, 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 + 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 + } +} diff --git a/Source/Engines/UI/EngineConfigStyles.cs b/Source/Engines/UI/EngineConfigStyles.cs new file mode 100644 index 00000000..9e43c643 --- /dev/null +++ b/Source/Engines/UI/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/UI/EngineConfigTextures.cs b/Source/Engines/UI/EngineConfigTextures.cs new file mode 100644 index 00000000..687a5c5f --- /dev/null +++ b/Source/Engines/UI/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/RealFuels.csproj b/Source/RealFuels.csproj index be97f524..bf8bf89f 100644 --- a/Source/RealFuels.csproj +++ b/Source/RealFuels.csproj @@ -90,6 +90,15 @@ + + + + + + + + + diff --git a/Source/Utilities/Styles.cs b/Source/Utilities/Styles.cs index ecff70a6..0fc950dc 100644 --- a/Source/Utilities/Styles.cs +++ b/Source/Utilities/Styles.cs @@ -35,9 +35,9 @@ 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(10, 10, 10, 10); + styleEditorPanel.padding = new RectOffset(5, 5, 6, 0); styleEditorPanel.normal.textColor = new Color32(147,161,161,255); styleEditorPanel.fontSize = 12; 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); + } } }