diff --git a/Astrogator.netkan b/Astrogator.netkan index 43ce698..4766498 100644 --- a/Astrogator.netkan +++ b/Astrogator.netkan @@ -15,6 +15,9 @@ "bugtracker": "https://github.com/HebaruSan/Astrogator/issues", "repository": "https://github.com/HebaruSan/Astrogator" }, + "depends": [ + { "name": "ModuleManager" } + ], "suggests": [ { "name": "GravityTurn" }, { "name": "Trajectories" }, @@ -23,6 +26,7 @@ { "name": "PreciseManeuver" }, { "name": "LandingHeight" }, { "name": "NavballDockingIndicator" }, - { "name": "WarpEverywhere" } + { "name": "WarpEverywhere" }, + { "name": "RasterPropMonitor" } ] } diff --git a/Astrogator.version b/Astrogator.version index d4d54b3..b05d72b 100644 --- a/Astrogator.version +++ b/Astrogator.version @@ -10,7 +10,7 @@ "VERSION": { "MAJOR": 0, "MINOR": 5, - "PATCH": 1, + "PATCH": 2, "BUILD": 0 }, "KSP_VERSION_MIN": { diff --git a/Makefile b/Makefile index fa58016..42015ff 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ SOURCEDIR=src SOURCE=$(wildcard $(SOURCEDIR)/*.cs) ASSETDIR=assets ICONS=$(wildcard $(ASSETDIR)/*.png) +CONFIGS=$(wildcard $(ASSETDIR)/*.cfg) README=README.md GAMELINK=$(SOURCEDIR)/KSP_x64_Data DEFAULTGAMEDIR="$(HOME)/.local/share/Steam/SteamApps/common/Kerbal Space Program" @@ -31,7 +32,7 @@ $(DEBUGDLL): $(SOURCE) $(GAMELINK) $(RELEASEDLL): $(SOURCE) $(GAMELINK) cd $(SOURCEDIR) && xbuild /p:Configuration=Release -$(RELEASEZIP): $(DEBUGDLL) $(ICONS) $(README) $(DLLDOCS) $(DLLSYMBOLS) $(LICENSE) $(VERSION) +$(RELEASEZIP): $(DEBUGDLL) $(ICONS) $(README) $(DLLDOCS) $(DLLSYMBOLS) $(LICENSE) $(VERSION) $(CONFIGS) mkdir -p $(DISTDIR) cp $^ $(DISTDIR) zip -r $@ $(DISTDIR) diff --git a/TODO.md b/TODO.md index 7a2f9f1..06530ba 100644 --- a/TODO.md +++ b/TODO.md @@ -10,10 +10,6 @@ - Only rule out a range once the entire thing is overdue - [ ] i18n / l10n (once SQUAD releases their version of it) -## [Kottabos](https://www.youtube.com/watch?v=bcl9sy6CJAY) feedback - -- [ ] RasterPropMonitor integration - ## More transfer types - [ ] Launch to orbit diff --git a/assets/AstrogatorRPM.cfg b/assets/AstrogatorRPM.cfg new file mode 100644 index 0000000..a342a27 --- /dev/null +++ b/assets/AstrogatorRPM.cfg @@ -0,0 +1,25 @@ +// Config format based on NavInstruments +// https://github.com/kujuman/NavInstruments/blob/master/GameData/KerbalScienceFoundation/NavInstruments/MFD/addToRPM018.cfg +@PROP[RasterPropMonitorBasicMFD]:Final +{ + @MODULE[RasterPropMonitor] + { + PAGE + { + // https://github.com/Mihara/RasterPropMonitor/wiki/Page-handlers + name = astrogator + // Share the autopilot button with MechJeb + button = button_D + PAGEHANDLER + { + name = AstrogatorMenu + method = ShowMenu + pageActiveMethod = PageActive + buttonClickMethod = ButtonClick + buttonReleaseMethod = ButtonRelease + pageTitle = Astrogator + } + textureURL = JSI/RasterPropMonitor/Library/Textures/bg01 + } + } +} diff --git a/screenshots/rpm-display.png b/screenshots/rpm-display.png new file mode 100644 index 0000000..bc629c3 Binary files /dev/null and b/screenshots/rpm-display.png differ diff --git a/src/AstrogationLoadBehaviorette.cs b/src/AstrogationLoadBehaviorette.cs new file mode 100644 index 0000000..683a0ff --- /dev/null +++ b/src/AstrogationLoadBehaviorette.cs @@ -0,0 +1,272 @@ +using System; +using System.Threading; +using System.ComponentModel; + +namespace Astrogator { + + using static DebugTools; + using static KerbalTools; + + /// + /// The logic of loading our model got too complicated to stay embedded in the main class. + /// Also, we want to share it with the RasterPropMonitor display screen. + /// This isn't a full "behavior" object, but it can be used by one to take care of a lot of tasks. + /// + /// This class is responsible for loading the main model object and providing functions to + /// refresh it as needed. + /// It defers some operations to the background and keeps track of when it's OK to do that. + /// + /// There's no point in calculating if the window isn't open, so we suppress calculations in that case. + /// We also abort any load request that happens while another load is already in progress. + /// We call it an "open display" so our Raster Prop Monitor widget can get in on the act. + /// + /// We need to refresh the data when the orbit changes, but if you do a ten-minute burn, + /// we can't churn the CPU constantly for that entire time. + /// So we require that at least 5 seconds have passed since the last calculation. + /// + /// However, if the user toggles the window rapidly, or switches focus in the tracking station, + /// we need to calculate regardless of whether 5 seconds have passed! + /// The same goes for if another calculation is in progress. + /// + /// Burns can expire if their burn time elapses into the past, so we need to check them once per second. + /// + public class AstrogationLoadBehaviorette { + + /// + /// Construct a loader object for the given model + /// + /// Model object for us to manage + /// Function to call if we trigger our own refresh without being asked (usually because a burn expired) + public AstrogationLoadBehaviorette(AstrogationModel m, LoadDoneCallback unReqNotif) + { + model = m; + unrequestedLoadNotification = unReqNotif; + + // Watch for expiring burns + StartBurnTimePolling(); + } + + private AstrogationModel model { get; set; } + private bool loading { get; set; } + private static readonly object bgLoadMutex = new object(); + private const double minSecondsBetweenLoads = 5; + private double lastUpdateTime { get; set; } + private LoadDoneCallback unrequestedLoadNotification { get; set; } + private int numOpenDisplays { get; set; } = 0; + + /// + /// Tell the loader that we are currently displaying the data. + /// If it thinks we aren't, it won't calculate anything. + /// + public void OnDisplayOpened() { ++numOpenDisplays; } + + /// + /// Tell the loader that we're closing a display. + /// If it thinks they're all gone, it won't calculate anything. + /// + public void OnDisplayClosed() { --numOpenDisplays; } + + private bool AllowStart(ITargetable newOrigin) { + return model != null + && numOpenDisplays > 0 + && ( + // If you've switched origins, we have to update now + newOrigin != model.origin + // Otherwise we only update if there isn't already one in progress + // and the minimum refresh interval has elapsed since the last one. + || (!loading && lastUpdateTime + minSecondsBetweenLoads < Planetarium.GetUniversalTime()) + ); + } + + /// + /// Callback type for notifications of load completion or failure. + /// + public delegate void LoadDoneCallback(); + + /// + /// Request a refresh of the data, intended to be called by event handlers. + /// Will often refuse to refresh to save CPU time! + /// Note that the callbacks may be called from background jobs, and so they + /// should never do any Unity UI manipulation (unless you like hard crashes). + /// Setting member variables seems to be safe. + /// This is supposed to be the single entry point when any other class needs to + /// load data for the plug-in. + /// + /// Body for which to calculate; can override some throttling behaviors if it's different from the last one + /// Function to call when we have enough data for a simple display, but not quite complete + /// Function to call on successful completion of the load + /// Function to call if we decide not to load + /// + /// True if we kicked off an actual refresh, false otherwise. + /// + public bool TryStartLoad(ITargetable newOrigin, LoadDoneCallback partialLoaded, LoadDoneCallback fullyLoaded, LoadDoneCallback aborted) + { + if (newOrigin != null) { + // 1. Check whether we should even do anything, if not call aborted() and return + if (!AllowStart(newOrigin)) { + if (aborted != null) { + aborted(); + } + return false; + } else { + // 2. Start background job + DbgFmt("Starting background job"); + BackgroundWorker bgworker = new BackgroundWorker(); + bgworker.DoWork += Load; + bgworker.RunWorkerAsync(new BackgroundParameters() { + origin = newOrigin, + partialLoaded = partialLoaded, + fullyLoaded = fullyLoaded, + aborted = aborted + }); + return true; + } + } else { + DbgFmt("Somebody tried to load with a null origin"); + if (aborted != null) { + aborted(); + } + return false; + } + } + + private class BackgroundParameters { + public ITargetable origin { get; set; } + public LoadDoneCallback partialLoaded { get; set; } + public LoadDoneCallback fullyLoaded { get; set; } + public LoadDoneCallback aborted { get; set; } + } + + /// + /// Main driver for calculations. + /// Always runs in a background thread! + /// + /// Standard parameter from BackgroundWorker + /// Params from main thread, e.Argument is a BackgroundParameters object with our actual info in it + private void Load(object sender, DoWorkEventArgs e) + { + BackgroundParameters bp = e.Argument as BackgroundParameters; + if (bp != null) { + lock (bgLoadMutex) { + loading = true; + + // 3. In background, load the first pass of stuff + model.Reset(bp.origin); + + if (PlaneChangesEnabled) { + + // Ejection burns are relatively cheap to calculate and needed for the display to look good + RecalculateEjections(); + + // 4. Call partialLoaded() (window can open, view can sort) + if (bp.partialLoaded != null) { + bp.partialLoaded(); + } + + // 5. Load everything else + RecalculatePlaneChanges(); + + // 6. Call fullyLoaded() (sort again) + if (bp.fullyLoaded != null) { + bp.fullyLoaded(); + } + + } else { + + // Ejection burns are all we need + RecalculateEjections(); + + // 6. Call fullyLoaded() (sort again) + if (bp.fullyLoaded != null) { + bp.fullyLoaded(); + } + + } + + lastUpdateTime = Planetarium.GetUniversalTime(); + loading = false; + } + } else { + if (bp.aborted != null) { + bp.aborted(); + } + } + } + + /// + /// Check once per second if any of the transfers are out of date. + /// Note, this could mean we need to re-sort the view! + /// That's what the unrequestedLoadNotification is for. + /// + private void StartBurnTimePolling() + { + System.Timers.Timer t = new System.Timers.Timer() { Interval = 1000 }; + t.Elapsed += BurnTimePoll; + t.Start(); + } + + private void BurnTimePoll(object source, System.Timers.ElapsedEventArgs e) + { + if (numOpenDisplays > 0 && !loading) { + bool found = false; + lock (bgLoadMutex) { + double now = Planetarium.GetUniversalTime(); + for (int i = 0; i < model.transfers.Count; ++i) { + if (model.transfers[i].ejectionBurn != null + && model.transfers[i].ejectionBurn.atTime < now) { + + DbgFmt("Recalculating expired burn"); + found = true; + + model.transfers[i].CalculateEjectionBurn(); + if (PlaneChangesEnabled) { + model.transfers[i].CalculatePlaneChangeBurn(); + } + } + } + } + // Tell the main behavior object to refresh the view if the time of any of the burns is changed + if (found && unrequestedLoadNotification != null) { + unrequestedLoadNotification(); + } + } + } + + private void RecalculateEjections() + { + for (int i = 0; i < model.transfers.Count; ++i) { + try { + model.transfers[i].CalculateEjectionBurn(); + } catch (Exception ex) { + DbgExc("Problem with load of ejection burn", ex); + } + } + } + + private bool PlaneChangesEnabled { + get { + return Settings.Instance.GeneratePlaneChangeBurns + && Settings.Instance.AddPlaneChangeDeltaV; + } + } + + private void RecalculatePlaneChanges() + { + if (PlaneChangesEnabled) { + for (int i = 0; i < model.transfers.Count; ++i) { + try { + Thread.Sleep(200); + model.transfers[i].CalculatePlaneChangeBurn(); + } catch (Exception ex) { + DbgExc("Problem with background load of plane change burn", ex); + + // If a route calculation crashes, it can leave behind a temporary node. + ClearManeuverNodes(); + } + } + } + } + + } + +} diff --git a/src/AstrogationModel.cs b/src/AstrogationModel.cs index ae7f02a..5d980ff 100644 --- a/src/AstrogationModel.cs +++ b/src/AstrogationModel.cs @@ -25,6 +25,14 @@ public AstrogationModel(ITargetable org) } } + /// + /// We need to allow an empty object to be valid so we can load most of it in the background. + /// + public AstrogationModel() + { + transfers = new List(); + } + /// /// The vessel or body that we're starting from. /// diff --git a/src/Astrogator.cs b/src/Astrogator.cs index 0322e7a..e00ed65 100644 --- a/src/Astrogator.cs +++ b/src/Astrogator.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Threading; using KSP.UI.Screens; namespace Astrogator { @@ -29,6 +27,13 @@ public class SpaceCenterAstrogator : Astrogator { } /// Our main plugin behavior. public class Astrogator : MonoBehavior { + public Astrogator() + : base() + { + model = new AstrogationModel(); + loader = new AstrogationLoadBehaviorette(model, ResetViewBackground); + } + private bool VesselMode { get; set; } /// @@ -58,10 +63,6 @@ public void Start() // This event fires when switching focus in the tracking station GameEvents.onPlanetariumTargetChanged.Add(TrackingStationTargetChanged); - // This is called in the flight scene when the vessel is fully loaded. - // We need that to be able to calculate plane changes. - GameEvents.onFlightReady.Add(OnFlightReady); - // Reset the view when we take off or land, etc. GameEvents.onVesselSituationChange.Add(OnSituationChanged); @@ -94,10 +95,6 @@ public void OnDisable() // This event fires when switching focus in the tracking station GameEvents.onPlanetariumTargetChanged.Remove(TrackingStationTargetChanged); - // This is called in the flight scene when the vessel is fully loaded. - // We need that to be able to calculate plane changes. - GameEvents.onFlightReady.Remove(OnFlightReady); - // Reset the view when we take off or land, etc. GameEvents.onVesselSituationChange.Remove(OnSituationChanged); @@ -183,12 +180,16 @@ private void onAppLaunchHoverOut() /// private void onAppLaunchToggleOn() { - DbgFmt("Ready for action"); - if (model == null) { - StartLoadingModel((ITargetable)FlightGlobals.ActiveVessel - ?? (ITargetable)FlightGlobals.getMainBody()); - } - ShowMainWindow(); + // TryStartLoad aborts if no displays are open, so we don't have to track that from every event handler + loader.OnDisplayOpened(); + + // Begin loading, open window when partially complete, refresh it when fully complete, also open on abort. + loader.TryStartLoad( + (ITargetable)FlightGlobals.ActiveVessel ?? (ITargetable)FlightGlobals.getMainBody(), + () => { needViewOpen = true; }, + () => { needViewOpen = true; }, + () => { needViewOpen = true; } + ); } /// @@ -197,106 +198,21 @@ private void onAppLaunchToggleOn() private void onAppLaunchToggleOff() { DbgFmt("Returning to hangar"); - HideMainWindow(); - } - - #endregion App launcher - - private bool flightReady { get; set; } + HideMainWindow(true); - #region Background loading - - private void OnFlightReady() - { - flightReady = true; - if (Settings.Instance.GeneratePlaneChangeBurns - && Settings.Instance.AddPlaneChangeDeltaV) { - StartLoadingModel(model.origin); - ResetView(); - } + // Tell the loader the window is closed so it can stop processing + loader.OnDisplayClosed(); } - private void StartLoadingModel(ITargetable origin, bool fromScratch = false) - { - // Set up the very basics of the model so the view has something to display during load - if (fromScratch || model == null) { - DbgFmt("Assembling model"); - model = new AstrogationModel(origin); - DbgFmt("Model assembled"); - } else { - model.Reset(origin); - } - - // Do the easy calculations in the foreground so the view can sort properly right away - CalculateEjectionBurns(); - - if (Settings.Instance.GeneratePlaneChangeBurns - && Settings.Instance.AddPlaneChangeDeltaV) { - - DbgFmt("Delegating load to background"); - - BackgroundWorker bgworker = new BackgroundWorker(); - bgworker.DoWork += bw_LoadModel; - bgworker.RunWorkerCompleted += bw_DoneLoadingModel; - bgworker.RunWorkerAsync(); - - DbgFmt("Launched background"); - } - } - - private static readonly object bgLoadMutex = new object(); - - private void bw_LoadModel(object sender, DoWorkEventArgs e) - { - lock (bgLoadMutex) { - DbgFmt("Beginning background model load"); - CalculatePlaneChangeBurns(); - DbgFmt("Finished background model load"); - } - } - - private void CalculateEjectionBurns() - { - // Blast through the ejection burns so the popup has numbers ASAP - for (int i = 0; i < model.transfers.Count; ++i) { - try { - model.transfers[i].CalculateEjectionBurn(); - } catch (Exception ex) { - DbgExc("Problem with load of ejection burn", ex); - } - } - } - - private void CalculatePlaneChangeBurns() - { - if (flightReady - && Settings.Instance.GeneratePlaneChangeBurns - && Settings.Instance.AddPlaneChangeDeltaV) { - for (int i = 0; i < model.transfers.Count; ++i) { - try { - Thread.Sleep(200); - model.transfers[i].CalculatePlaneChangeBurn(); - } catch (Exception ex) { - DbgExc("Problem with background load of plane change burn", ex); - - // If a route calculation crashes, it can leave behind a temporary node. - ClearManeuverNodes(); - } - } - } - } - - private void bw_DoneLoadingModel(object sender, RunWorkerCompletedEventArgs e) - { - DbgFmt("Background load complete"); - } - - #endregion Background loading + #endregion App launcher #region Main window - private AstrogationModel model { get; set; } - private AstrogationView view { get; set; } + private AstrogationModel model { get; set; } + private AstrogationLoadBehaviorette loader { get; set; } + private AstrogationView view { get; set; } + private bool needViewClose { get; set; } + private bool needViewOpen { get; set; } /// /// Open the main window listing transfers. @@ -318,7 +234,7 @@ private void ShowMainWindow() /// /// Close the main window. /// - private void HideMainWindow(bool userInitiated = true) + private void HideMainWindow(bool userInitiated) { if (view != null) { view.Dismiss(); @@ -335,14 +251,23 @@ private void HideMainWindow(bool userInitiated = true) private void ResetView(bool resetModel = false) { if (resetModel) { - StartLoadingModel(model.origin); - } - if (view != null) { - HideMainWindow(); + loader.TryStartLoad(model.origin, null, ResetViewBackground, null); + } else if (view != null) { + HideMainWindow(false); ShowMainWindow(); } } + /// + /// Unity completely freaks out, sometimes with a hard crash, + /// if you try to open a window from a background thread when it's not ready. + /// So instead we'll just make a note and do it in the next Update() call. + /// + private void ResetViewBackground() + { + needViewClose = needViewOpen = true; + } + private static void AdjustManeuver(BurnModel burn, Vector3d direction, double fraction = 1.0) { const double DELTA_V_INCREMENT_LARGE = 0.5, @@ -449,6 +374,17 @@ private static void AdjustManeuver(BurnModel burn, Vector3d direction, double fr /// public void Update() { + // Close the window if a background thread asked us to. + if (view != null && (needViewClose || needViewOpen)) { + HideMainWindow(false); + needViewClose = false; + } + // Open the window if a background thread asked us to. + if (needViewOpen) { + ShowMainWindow(); + needViewOpen = false; + } + CheckIfNodesDisappeared(); if (Settings.Instance.TranslationAdjust @@ -504,8 +440,7 @@ private void OnTargetChanged() if (model != null) { if (!model.HasDestination(FlightGlobals.fetch.VesselTarget)) { DbgFmt("Reloading model and view on target change"); - StartLoadingModel(model.origin); - ResetView(); + loader.TryStartLoad(model.origin, null, ResetViewBackground, null); } } } @@ -520,31 +455,14 @@ private bool OrbitChanged() private void OnOrbitChanged() { - if (prevOrbit == null) { - DbgFmt("No previous orbit."); - } else { - DbgFmt(prevOrbit.ComparisonDescription(FlightGlobals.ActiveVessel.orbit)); - } - - if (model != null) { - - // Just recalculate the ejection burns since those are relatively simple - for (int i = 0; i < model.transfers.Count; ++i) { - try { - model.transfers[i].CalculateEjectionBurn(); - } catch (Exception ex) { - DbgExc("Problem after orbit change", ex); - } - } - } + loader.TryStartLoad(model.origin, null, ResetViewBackground, null); } private void OnSituationChanged(GameEvents.HostedFromToAction e) { if (model != null && view != null && e.host == FlightGlobals.ActiveVessel) { DbgFmt("Situation of {0} changed from {1} to {2}", TheName(e.host), e.from, e.to); - StartLoadingModel(e.host); - ResetView(); + loader.TryStartLoad(e.host, null, ResetViewBackground, null); } } @@ -556,8 +474,7 @@ private void SOIChanged(CelestialBody newBody) if (model != null && view != null) { DbgFmt("Entered {0}'s sphere of influence", newBody.theName); // The old list no longer applies because reachable bodies depend on current SOI - StartLoadingModel(model.origin ?? (ITargetable)FlightGlobals.ActiveVessel); - ResetView(); + loader.TryStartLoad(model.origin, null, ResetViewBackground, null); } } @@ -573,9 +490,10 @@ private void TrackingStationTargetChanged(MapObject target) && target != null) { DbgFmt("Tracking station changed target to {0}", target); - StartLoadingModel((ITargetable)target.vessel - ?? (ITargetable)target.celestialBody); - ResetView(); + loader.TryStartLoad( + (ITargetable)target.vessel ?? (ITargetable)target.celestialBody, + null, ResetViewBackground, null + ); } } diff --git a/src/Astrogator.csproj b/src/Astrogator.csproj index ff3e62d..4a84a55 100644 --- a/src/Astrogator.csproj +++ b/src/Astrogator.csproj @@ -58,9 +58,11 @@ + + diff --git a/src/AstrogatorMenu.cs b/src/AstrogatorMenu.cs new file mode 100644 index 0000000..ab06262 --- /dev/null +++ b/src/AstrogatorMenu.cs @@ -0,0 +1,270 @@ +using System; +using System.Text; +using System.Globalization; +using System.Collections.Generic; +using UnityEngine; + +namespace Astrogator { + + using static DebugTools; + using static KerbalTools; + using static ViewTools; + + /// + /// https://github.com/Mihara/RasterPropMonitor/wiki/Page-handlers + /// + public class AstrogatorMenu : InternalModule { + + AstrogatorMenu() + : base() + { + model = new AstrogationModel( + (ITargetable)FlightGlobals.ActiveVessel + ?? (ITargetable)FlightGlobals.getMainBody()); + loader = new AstrogationLoadBehaviorette(model, null); + timeToWait = new List(); + cursorTransfer = 0; + + loader.TryStartLoad(model.origin, null, null, null); + } + + [KSPField] + public int buttonUp = 0; + + [KSPField] + public int buttonDown = 1; + + [KSPField] + public int buttonEnter = 2; + + [KSPField] + public int buttonEsc = 3; + + [KSPField] + public int buttonHome = 4; + + private AstrogationModel model { get; set; } + private AstrogationLoadBehaviorette loader { get; set; } + private List timeToWait { get; set; } + private double lastUniversalTime { get; set; } + private int cursorTransfer { get; set; } + private bool cursorMoved { get; set; } + private string menu { get; set; } + private int? activeButton { get; set; } + + private void addHeaders(StringBuilder sb) + { + bool firstCol = true; + for (int i = 0; i < Columns.Length; ++i) { + ColumnDefinition col = Columns[i]; + int width = 0; + for (int span = 0; span < col.headerColSpan; ++span) { + width += Columns[i + span].monospaceWidth; + } + if (width > 0) { + width += (col.headerColSpan - 1); + if (firstCol) { + firstCol = false; + width += 2; + } + switch (col.headerStyle.alignment) { + case TextAnchor.LowerLeft: + sb.AppendFormat( + string.Format("{0}0,-{1}{2}", "{", width, "}"), + col.header + ); + break; + case TextAnchor.LowerCenter: + sb.Append(centerString(col.header, width)); + break; + case TextAnchor.LowerRight: + sb.AppendFormat( + string.Format("{0}0,{1}{2}", "{", width, "}"), + col.header + ); + break; + } + sb.Append(" "); + } + } + } + + private string colContentFormat(ColumnDefinition col) + { + switch (col.contentStyle.alignment) { + case TextAnchor.MiddleLeft: + return string.Format("{0}0,-{1}{2}", "{", col.monospaceWidth, "}"); + break; + case TextAnchor.MiddleRight: + return string.Format("{0}0,{1}{2}", "{", col.monospaceWidth, "}"); + break; + } + return "{0}"; + } + + private void addRow(StringBuilder sb, TransferModel m, DateTimeParts dt, bool selected) + { + string destLabel = CultureInfo.InstalledUICulture.TextInfo.ToTitleCase(TheName(m.destination)); + + sb.Append(Environment.NewLine); + sb.Append(selected ? "> " : " "); + for (int i = 0; i < Columns.Length; ++i) { + ColumnDefinition col = Columns[i]; + // TODO - check style's text color and convert to [#rrggbbaa] + switch (col.content) { + case ContentEnum.PlanetName: + sb.AppendFormat( + colContentFormat(col), + (destLabel.Length > col.monospaceWidth ? destLabel.Substring(0, col.monospaceWidth) : destLabel) + ); + break; + + case ContentEnum.YearsTillBurn: + sb.AppendFormat( + colContentFormat(col), + TimePieceString("{0}y", dt.years, dt.needYears) + ); + break; + + case ContentEnum.DaysTillBurn: + sb.AppendFormat( + colContentFormat(col), + TimePieceString("{0}d", dt.days, dt.needDays) + ); + break; + + case ContentEnum.HoursTillBurn: + sb.AppendFormat( + colContentFormat(col), + TimePieceString("{0}h", dt.hours, dt.needHours) + ); + break; + + case ContentEnum.MinutesTillBurn: + sb.AppendFormat( + colContentFormat(col), + TimePieceString("{0}m", dt.minutes, dt.needMinutes) + ); + break; + + case ContentEnum.SecondsTillBurn: + sb.AppendFormat( + colContentFormat(col), + TimePieceString("{0}s", dt.seconds, true) + ); + break; + + case ContentEnum.DeltaV: + sb.AppendFormat( + colContentFormat(col), + FormatSpeed( + ((m.planeChangeBurn == null || !Settings.Instance.AddPlaneChangeDeltaV) + ? m.ejectionBurn?.totalDeltaV + : (m.ejectionBurn?.totalDeltaV + m.planeChangeBurn.totalDeltaV)) ?? 0, + Settings.Instance.DisplayUnits) + ); + break; + + } + sb.Append(" "); + } + } + + public string ShowMenu(int columns, int rows) + { + if ((Refresh() || cursorMoved) && model.transfers.Count == timeToWait.Count) { + StringBuilder sb = new StringBuilder(); + sb.Append(centerString(" " + AstrogationView.DisplayName + " ", columns, '-')); + sb.Append(Environment.NewLine); + sb.Append("[#a0a0a0ff]"); + sb.Append(centerString(String.Format("Transfers from {0}", TheName(model.origin)), columns)); + sb.Append(Environment.NewLine); + sb.Append(Environment.NewLine); + + // [#rrggbbaa] + sb.Append("[#22ff22ff]"); + addHeaders(sb); + + // Wrap the cursor around the edges now because it only tells us dimensions here. + while (cursorTransfer < 0) { + cursorTransfer += model.transfers.Count; + } + while (cursorTransfer >= model.transfers.Count) { + cursorTransfer -= model.transfers.Count; + } + // TODO - handle multiple pages of transfers + + for (int i = 0; i < model.transfers.Count && i < rows - 4; ++i) { + addRow(sb, model.transfers[i], timeToWait[i], (cursorTransfer == i)); + } + menu = sb.ToString(); + cursorMoved = false; + } + return menu; + } + + public void PageActive(bool pageActive, int pageNumber) + { + if (pageActive) { + loader.OnDisplayOpened(); + loader.TryStartLoad(model.origin, null, null, null); + } else { + loader.OnDisplayClosed(); + } + } + + private string centerString(string val, int columns, char padding = ' ') + { + int numPads = columns - val.Length; + return val.PadLeft(columns - numPads/2, padding).PadRight(columns, padding); + } + + public void ButtonClick(int buttonNumber) + { + DbgFmt("ButtonClick: {0}", buttonNumber); + activeButton = buttonNumber; + + if (activeButton == buttonUp) { + --cursorTransfer; + cursorMoved = true; + } else if (activeButton == buttonDown) { + ++cursorTransfer; + cursorMoved = true; + } else if (activeButton == buttonEnter) { + model.transfers[cursorTransfer].CreateManeuvers(); + } else if (activeButton == buttonEsc) { + ClearManeuverNodes(); + } else if (activeButton == buttonHome) { + model.transfers[cursorTransfer].WarpToBurn(); + } + } + + public void ButtonRelease(int buttonNumber) + { + DbgFmt("ButtonRelease: {0}", buttonNumber); + activeButton = null; + } + + private bool Refresh() + { + double now = Math.Floor(Planetarium.GetUniversalTime()); + if (lastUniversalTime != now) { + timeToWait = new List(); + for (int i = 0; i < model.transfers.Count; ++i) { + + if (model.transfers[i].ejectionBurn != null) { + timeToWait.Add(new DateTimeParts(model.transfers[i].ejectionBurn.atTime - Planetarium.GetUniversalTime())); + } else { + timeToWait.Add(new DateTimeParts(0)); + } + + } + lastUniversalTime = now; + return true; + } + return false; + } + + } + +} diff --git a/src/Properties/AssemblyInfo.cs b/src/Properties/AssemblyInfo.cs index fb41a4e..66ee228 100644 --- a/src/Properties/AssemblyInfo.cs +++ b/src/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: -[assembly: AssemblyVersion("0.5.1.0")] -[assembly: AssemblyFileVersion("0.5.1.0")] +[assembly: AssemblyVersion("0.5.2.0")] +[assembly: AssemblyFileVersion("0.5.2.0")] diff --git a/src/TransferModel.cs b/src/TransferModel.cs index 6b0c71f..f8deca3 100644 --- a/src/TransferModel.cs +++ b/src/TransferModel.cs @@ -41,7 +41,7 @@ public TransferModel(ITargetable org, ITargetable dest) /// True if the transfer portion of this trajectory is retrograde, false otherwise. /// So for a retrograde Kerbin orbit, this is true for Mun and false for Duna. /// - public bool retrogradeTransfer { get; private set; } + public bool retrogradeTransfer { get; private set; } /// /// The body we're transferring from. @@ -86,6 +86,11 @@ private BurnModel GenerateEjectionBurn(Orbit currentOrbit) // Sanity check just in case something unexpected happens. return null; + } else if (destination.GetOrbit().eccentricity > 1) { + DbgFmt("{0} is on an escape trajectory; bailing", TheName(destination)); + + return null; + } else if (Landed) { // TODO - Launch to orbit @@ -305,7 +310,8 @@ public void CalculatePlaneChangeBurn() { if (FlightGlobals.ActiveVessel?.patchedConicSolver?.maneuverNodes != null && transferDestination != null - && transferParent != null) { + && transferParent != null + && destination.GetOrbit().eccentricity < 1) { bool ejectionAlreadyActive = false; @@ -412,42 +418,121 @@ public bool HaveEncounter() return false; } - /// Returns true if UI needs an update - public bool Refresh() + /// + /// Check whether the user opened any manuever node editing gizmos since the last tick. + /// There doesn't seem to be event-based notification for this, so we just have to poll. + /// + public void CheckIfNodesDisappeared() + { + ejectionBurn?.CheckIfNodeDisappeared(); + planeChangeBurn?.CheckIfNodeDisappeared(); + } + + /// + /// Turn this transfer's burns into user visible maneuver nodes. + /// This is the behavior for the maneuver node icon. + /// + public void CreateManeuvers() { - if (ejectionBurn != null) { - if (ejectionBurn.atTime < Planetarium.GetUniversalTime()) { - CalculateEjectionBurn(); + if (FlightGlobals.ActiveVessel != null) { - // Apply the same filters we do everywhere else to suppress phantom nodes - if (Settings.Instance.GeneratePlaneChangeBurns - && Settings.Instance.AddPlaneChangeDeltaV) { + // Remove all maneuver nodes because they'd conflict with the ones we're about to add + ClearManeuverNodes(); - try { - CalculatePlaneChangeBurn(); - } catch (Exception ex) { - DbgExc("Problem with plane change at expiration", ex); - ClearManeuverNodes(); - } + if (Settings.Instance.AutoTargetDestination) { + // Switch to target mode, targeting the destination body + FlightGlobals.fetch.SetVesselTarget(destination); + } + + // Create a maneuver node for the ejection burn + ejectionBurn.ToActiveManeuver(); + + if (Settings.Instance.GeneratePlaneChangeBurns) { + if (planeChangeBurn == null) { + DbgFmt("Calculating plane change on the fly"); + CalculatePlaneChangeBurn(); + } + + if (planeChangeBurn != null) { + planeChangeBurn.ToActiveManeuver(); + } else { + DbgFmt("No plane change found"); } - return true; } else { - return false; + DbgFmt("Plane changes disabled"); + } + + if (Settings.Instance.AutoEditEjectionNode) { + // Open the initial node for fine tuning + ejectionBurn.EditNode(); + } else if (Settings.Instance.AutoEditPlaneChangeNode) { + if (planeChangeBurn != null) { + planeChangeBurn.EditNode(); + } + } + + if (Settings.Instance.AutoFocusDestination) { + if (HaveEncounter()) { + // Move the map to the target for fine-tuning if we have an encounter + FocusMap(destination); + } else if (transferParent != null) { + // Otherwise focus on the parent of the transfer orbit so we can get an encounter + // Try to explain why this is happening with a screen message + ScreenFmt("Adjust maneuvers to establish encounter"); + FocusMap(transferParent, transferDestination); + } + } + + if (Settings.Instance.AutoSetSAS + && FlightGlobals.ActiveVessel != null + && FlightGlobals.ActiveVessel.Autopilot.CanSetMode(VesselAutopilot.AutopilotMode.Maneuver)) { + // The API for SAS is ... peculiar. + // http://forum.kerbalspaceprogram.com/index.php?/topic/153420-enabledisable-autopilot/ + try { + if (FlightGlobals.ActiveVessel.Autopilot.Enabled) { + FlightGlobals.ActiveVessel.Autopilot.SetMode(VesselAutopilot.AutopilotMode.Maneuver); + } else { + DbgFmt("Not enabled, trying to enable"); + FlightGlobals.ActiveVessel.ActionGroups.SetGroup(KSPActionGroup.SAS, true); + FlightGlobals.ActiveVessel.Autopilot.Enable(VesselAutopilot.AutopilotMode.Maneuver); + } + } catch (Exception ex) { + DbgExc("Problem setting SAS to maneuver mode", ex); + } } - } else { - return false; } } /// - /// Check whether the user opened any manuever node editing gizmos since the last tick. - /// There doesn't seem to be event-based notification for this, so we just have to poll. + /// Warp to (near) the burn. + /// Since you usually need to start burning before the actual node, + /// we use some simple padding logic to determine how far to warp. + /// If you're more than five minutes from the burn, then we warp + /// to that five minute mark. This should allow for most of the long burns. + /// If you're closer than five minutes from the burn, then we warp + /// right up to the moment of the actual burn. + /// If you're _already_ warping, cancel the warp (suggested by Kottabos). /// - public void CheckIfNodesDisappeared() + public void WarpToBurn() { - ejectionBurn?.CheckIfNodeDisappeared(); - planeChangeBurn?.CheckIfNodeDisappeared(); + if (TimeWarp.CurrentRate > 1) { + DbgFmt("Warp button clicked while already in warp, cancelling warp"); + TimeWarp.fetch?.CancelAutoWarp(); + TimeWarp.SetRate(0, false); + } else { + DbgFmt("Attempting to warp to burn from {0} to {1}", Planetarium.GetUniversalTime(), ejectionBurn.atTime); + if (Planetarium.GetUniversalTime() < ejectionBurn.atTime - BURN_PADDING ) { + DbgFmt("Warping to burn minus offset"); + TimeWarp.fetch.WarpTo(ejectionBurn.atTime - BURN_PADDING); + } else if (Planetarium.GetUniversalTime() < ejectionBurn.atTime) { + DbgFmt("Already within offset; warping to burn"); + TimeWarp.fetch.WarpTo(ejectionBurn.atTime); + } else { + DbgFmt("Can't warp to the past!"); + } + } } + } } diff --git a/src/TransferView.cs b/src/TransferView.cs index e782333..119ca33 100644 --- a/src/TransferView.cs +++ b/src/TransferView.cs @@ -86,12 +86,12 @@ private void CreateLayout() case ContentEnum.CreateManeuverNodeButton: AddChild(iconButton(maneuverIcon, - col.contentStyle, "Create maneuver", CreateManeuvers)); + col.contentStyle, "Create maneuver", model.CreateManeuvers)); break; case ContentEnum.WarpToBurnButton: AddChild(iconButton(warpIcon, - col.contentStyle, "Warp to window", WarpToBurn)); + col.contentStyle, "Warp to window", model.WarpToBurn)); break; } @@ -106,15 +106,9 @@ private void CreateLayout() /// public bool Refresh() { - bool modelNeedsUIUpdate = model.Refresh(); double now = Math.Floor(Planetarium.GetUniversalTime()); - if (modelNeedsUIUpdate) { - // We have a new ejection burn, so we might need a totally new view - // because the sort could be wrong now. - resetCallback(); - return true; - } else if (lastUniversalTime != now && model.ejectionBurn != null) { + if (lastUniversalTime != now && model.ejectionBurn != null) { timeToWait = new DateTimeParts(model.ejectionBurn.atTime - Planetarium.GetUniversalTime()); lastUniversalTime = now; return true; @@ -124,13 +118,19 @@ public bool Refresh() private const string LoadingText = "---"; + private bool showLoadingText { + get { + return timeToWait == null || model.ejectionBurn.atTime < Planetarium.GetUniversalTime(); + } + } + /// /// String representing years till burn. /// public string getYearValue() { Refresh(); - if (timeToWait == null) { + if (showLoadingText) { return LoadingText; } else { return TimePieceString("{0}y", timeToWait.years, timeToWait.needYears); @@ -143,7 +143,7 @@ public string getYearValue() public string getDayValue() { Refresh(); - if (timeToWait == null) { + if (showLoadingText) { return LoadingText; } else { return TimePieceString("{0}d", timeToWait.days, timeToWait.needDays); @@ -156,7 +156,7 @@ public string getDayValue() public string getHourValue() { Refresh(); - if (timeToWait == null) { + if (showLoadingText) { return LoadingText; } else { return TimePieceString("{0}h", timeToWait.hours, timeToWait.needHours); @@ -169,7 +169,7 @@ public string getHourValue() public string getMinuteValue() { Refresh(); - if (timeToWait == null) { + if (showLoadingText) { return LoadingText; } else { return TimePieceString("{0}m", timeToWait.minutes, timeToWait.needMinutes); @@ -182,7 +182,7 @@ public string getMinuteValue() public string getSecondValue() { Refresh(); - if (timeToWait == null) { + if (showLoadingText) { return LoadingText; } else { return TimePieceString("{0}s", timeToWait.seconds, true); @@ -209,106 +209,6 @@ public string getDeltaV() } } - /// - /// Turn this transfer's burns into user visible maneuver nodes. - /// This is the behavior for the maneuver node icon. - /// - public void CreateManeuvers() - { - if (FlightGlobals.ActiveVessel != null) { - - // Remove all maneuver nodes because they'd conflict with the ones we're about to add - ClearManeuverNodes(); - - if (Settings.Instance.AutoTargetDestination) { - // Switch to target mode, targeting the destination body - FlightGlobals.fetch.SetVesselTarget(model.destination); - } - - // Create a maneuver node for the ejection burn - model.ejectionBurn.ToActiveManeuver(); - - if (Settings.Instance.GeneratePlaneChangeBurns) { - if (model.planeChangeBurn == null) { - DbgFmt("Calculating plane change on the fly"); - model.CalculatePlaneChangeBurn(); - } - - if (model.planeChangeBurn != null) { - model.planeChangeBurn.ToActiveManeuver(); - } else { - DbgFmt("No plane change found"); - } - } else { - DbgFmt("Plane changes disabled"); - } - - if (Settings.Instance.AutoEditEjectionNode) { - // Open the initial node for fine tuning - model.ejectionBurn.EditNode(); - } else if (Settings.Instance.AutoEditPlaneChangeNode) { - if (model.planeChangeBurn != null) { - model.planeChangeBurn.EditNode(); - } - } - - if (Settings.Instance.AutoFocusDestination) { - if (model.HaveEncounter()) { - // Move the map to the target for fine-tuning if we have an encounter - FocusMap(model.destination); - } else if (model.transferParent != null) { - // Otherwise focus on the parent of the transfer orbit so we can get an encounter - // Try to explain why this is happening with a screen message - ScreenFmt("Adjust maneuvers to establish encounter"); - FocusMap(model.transferParent, model.transferDestination); - } - } - - if (Settings.Instance.AutoSetSAS - && FlightGlobals.ActiveVessel != null - && FlightGlobals.ActiveVessel.Autopilot.CanSetMode(VesselAutopilot.AutopilotMode.Maneuver)) { - try { - if (FlightGlobals.ActiveVessel.Autopilot.Enabled) { - FlightGlobals.ActiveVessel.Autopilot.SetMode(VesselAutopilot.AutopilotMode.Maneuver); - } else { - FlightGlobals.ActiveVessel.Autopilot.Enable(VesselAutopilot.AutopilotMode.Maneuver); - } - } catch (Exception ex) { - DbgExc("Problem setting SAS to maneuver mode", ex); - } - } - } - } - - /// - /// Warp to (near) the burn. - /// Since you usually need to start burning before the actual node, - /// we use some simple padding logic to determine how far to warp. - /// If you're more than five minutes from the burn, then we warp - /// to that five minute mark. This should allow for most of the long burns. - /// If you're closer than five minutes from the burn, then we warp - /// right up to the moment of the actual burn. - /// If you're _already_ warping, cancel the warp (suggested by Kottabos). - /// - public void WarpToBurn() - { - if (TimeWarp.CurrentRate > 1) { - DbgFmt("Warp button clicked while already in warp, cancelling warp"); - TimeWarp.fetch?.CancelAutoWarp(); - TimeWarp.SetRate(0, false); - } else { - DbgFmt("Attempting to warp to burn from {0} to {1}", Planetarium.GetUniversalTime(), model.ejectionBurn.atTime); - if (Planetarium.GetUniversalTime() < model.ejectionBurn.atTime - BURN_PADDING ) { - DbgFmt("Warping to burn minus offset"); - TimeWarp.fetch.WarpTo(model.ejectionBurn.atTime - BURN_PADDING); - } else if (Planetarium.GetUniversalTime() < model.ejectionBurn.atTime) { - DbgFmt("Already within offset; warping to burn"); - TimeWarp.fetch.WarpTo(model.ejectionBurn.atTime); - } else { - DbgFmt("Can't warp to the past!"); - } - } - } } } diff --git a/src/ViewTools.cs b/src/ViewTools.cs index 231859a..1cad96d 100644 --- a/src/ViewTools.cs +++ b/src/ViewTools.cs @@ -571,6 +571,11 @@ public class ColumnDefinition { /// Sort order to use when the user clicks the header. /// public SortEnum sortKey { get; set; } + + /// + /// How wide the column is when rendering in a fixed-width font text screen. + /// + public int monospaceWidth { get; set; } } /// @@ -584,7 +589,8 @@ public class ColumnDefinition { headerStyle = leftHdrStyle, contentStyle = planetStyle, content = ContentEnum.PlanetName, - sortKey = SortEnum.Position + sortKey = SortEnum.Position, + monospaceWidth = 7 }, new ColumnDefinition() { header = "Time Till Burn", width = 30, @@ -592,35 +598,40 @@ public class ColumnDefinition { headerStyle = midHdrStyle, contentStyle = numberStyle, content = ContentEnum.YearsTillBurn, - sortKey = SortEnum.Time + sortKey = SortEnum.Time, + monospaceWidth = 4 }, new ColumnDefinition() { header = "", width = 30, headerColSpan = 0, headerStyle = rightHdrStyle, contentStyle = numberStyle, - content = ContentEnum.DaysTillBurn + content = ContentEnum.DaysTillBurn, + monospaceWidth = 4 }, new ColumnDefinition() { header = "", width = 20, headerColSpan = 0, headerStyle = rightHdrStyle, contentStyle = numberStyle, - content = ContentEnum.HoursTillBurn + content = ContentEnum.HoursTillBurn, + monospaceWidth = 2 }, new ColumnDefinition() { header = "", width = 25, headerColSpan = 0, headerStyle = rightHdrStyle, contentStyle = numberStyle, - content = ContentEnum.MinutesTillBurn + content = ContentEnum.MinutesTillBurn, + monospaceWidth = 3 }, new ColumnDefinition() { header = "", width = 25, headerColSpan = 0, headerStyle = rightHdrStyle, contentStyle = numberStyle, - content = ContentEnum.SecondsTillBurn + content = ContentEnum.SecondsTillBurn, + monospaceWidth = 3, }, new ColumnDefinition() { header = "Δv", width = 60, @@ -628,7 +639,8 @@ public class ColumnDefinition { headerStyle = rightHdrStyle, contentStyle = numberStyle, content = ContentEnum.DeltaV, - sortKey = SortEnum.DeltaV + sortKey = SortEnum.DeltaV, + monospaceWidth = 9 }, new ColumnDefinition() { header = "", width = buttonIconWidth, @@ -638,13 +650,15 @@ public class ColumnDefinition { content = ContentEnum.CreateManeuverNodeButton, vesselSpecific = true, requiresPatchedConics = true, + monospaceWidth = 0, }, new ColumnDefinition() { header = "", width = buttonIconWidth, headerColSpan = 1, headerStyle = rightHdrStyle, contentStyle = warpStyle, - content = ContentEnum.WarpToBurnButton + content = ContentEnum.WarpToBurnButton, + monospaceWidth = 0 }, };