Skip to content

Instantly share code, notes, and snippets.

@gotmachine
Last active December 5, 2020 04:11
Show Gist options
  • Save gotmachine/5b6fa92012bc6b545319c1117d9afcd1 to your computer and use it in GitHub Desktop.
Save gotmachine/5b6fa92012bc6b545319c1117d9afcd1 to your computer and use it in GitHub Desktop.
PhysicsHold : KSP proof of concept mod for making landed vessels physicsless.
using KSP.UI.Screens.Flight;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using UnityEngine;
using Debug = UnityEngine.Debug;
/*
This add a "Landed physics hold" PAW button on all command parts (and root part if no command part found),
available when the vessel is landed and has a surface speed less than 1 m/s (arbitrary, could more/less).
When enabled, all rigibodies on the vessel are made kinematic by forcing the stock "packed" (or "on rails")
state normally used during "physics easing" and non-physics timewarp.
When enabled, all joint/force/torque physics are disabled, making the vessel an unmovable object fixed at
a given altitude/longitude/latitude. You can still collide with it, but it will not react to collisions.
Working and tested :
- Undocking : the dominant vessel will stay on physics hold, the undocking vessel will be physics enabled
- Decoupling : works by insta-restoring physics, so far no issues detected
- Initizalization issue with wheels taken care of
- EVAing / boarding (hack in place to enable the kerbal portraits UI)
- Control input (rotation, translation, throttle...) is forced to zero by the stock vessel.packed check
- KIS attaching parts *seems* to work but they currently aren't made kinematic until a scene reload
Not working :
- KAS definitely doesn't work. It probably can, since it is able to handle things in timewarp, but that
will require modifications on its side.
Untested / likely to have issues :
- ModuleGrappleNode (Klaw)
- Stock robotics have some packed-dependant initialization code.
*/
namespace PhysicsHold
{
public class PhysicsHold : VesselModule
{
private static string cacheAutoLOC_459494;
private static FieldInfo framesAtStartupFieldInfo;
private static MethodInfo KerbalPortrait_CanEVA;
private static FieldInfo physicsHoldField;
private static bool initDone = false;
[KSPField(isPersistant = true)] public bool physicsHold;
private List<CommandPart> commandParts;
private Vessel lastDecoupledPartVessel;
private bool hasEverBeenUnpacked;
private bool isEnabled = true;
public override bool ShouldBeActive()
{
return
HighLogic.LoadedSceneIsFlight
&& vessel.loaded
&& !vessel.isEVA
&& vessel.id != Guid.Empty // exclude flags
&& isEnabled;
}
#region LIFECYCLE
protected override void OnAwake()
{
if (!initDone)
{
initDone = true;
physicsHoldField = GetType().GetField(nameof(physicsHold));
try
{
cacheAutoLOC_459494 = (string)typeof(KerbalPortrait).GetField("cacheAutoLOC_459494", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null);
}
catch (Exception e)
{
Debug.LogWarning($"Cant find the EVA available tooltip AUTOLOC\n{e}");
cacheAutoLOC_459494 = string.Empty;
}
try
{
framesAtStartupFieldInfo = typeof(Vessel).GetField("framesAtStartup", BindingFlags.Instance | BindingFlags.NonPublic);
}
catch (Exception e)
{
Debug.LogError($"Cant find the Vessel.framesAtStartup field\n{e}");
initDone = false;
}
try
{
KerbalPortrait_CanEVA = typeof(KerbalPortrait).GetMethod("CanEVA", BindingFlags.Instance | BindingFlags.NonPublic);
}
catch (Exception e)
{
Debug.LogError($"Cant find the KerbalPortrait.CanEVA method\n{e}");
initDone = false;
}
if (!initDone)
{
isEnabled = enabled = false;
}
}
}
protected override void OnStart()
{
// not allowed for EVA kerbals and flags (note : vessel.IsEVA isn't always set in OnStart())
if (vessel.isEVA || vessel.id == Guid.Empty)
{
isEnabled = enabled = false;
return;
}
hasEverBeenUnpacked = !physicsHold;
SetupCommandParts();
GameEvents.onPartCouple.Add(OnPartCouple); // before docking/coupling
GameEvents.onPartCoupleComplete.Add(OnPartCoupleComplete); // after docking/coupling
GameEvents.onPartDeCouple.Add(OnPartDeCouple); // before coupling
GameEvents.onPartDeCoupleComplete.Add(OnPartDeCoupleComplete); // after coupling
GameEvents.onPartUndock.Add(OnPartUndock); // before docking
GameEvents.onVesselsUndocking.Add(OnVesselsUndocking); // after docking
GameEvents.onPartDestroyed.Add(OnPartDestroyed);
}
private void ClearEvents()
{
GameEvents.onPartCouple.Remove(OnPartCouple);
GameEvents.onPartCoupleComplete.Remove(OnPartCoupleComplete);
GameEvents.onPartDeCouple.Remove(OnPartDeCouple);
GameEvents.onPartDeCoupleComplete.Remove(OnPartDeCoupleComplete);
GameEvents.onPartUndock.Remove(OnPartUndock);
GameEvents.onVesselsUndocking.Remove(OnVesselsUndocking);
GameEvents.onPartDestroyed.Remove(OnPartDestroyed);
}
public void OnDestroy()
{
ClearEvents();
}
#endregion
#region UPDATE
// could use BaseField.OnValueModified instead, but a
// polling pattern is easier.
public void FixedUpdate()
{
if (physicsHold)
{
if (vessel.Landed)
{
if (!vessel.packed)
{
vessel.GoOnRails();
}
}
else
{
physicsHold = false;
hasEverBeenUnpacked = true;
}
}
}
public void Update()
{
// physics holding is only allowed while landed, and moving at less than 1 m/s
bool holdAllowed = vessel.Landed && vessel.srfSpeed < 1.0;
foreach (CommandPart commandPart in commandParts)
{
commandPart.field.guiActive = holdAllowed;
}
if (physicsHold)
{
// keep the vessel forever in "physics hold" mode by resetting the "last off rails" frame to the current one.
framesAtStartupFieldInfo.SetValue(vessel, Time.frameCount);
// remove the "physics hold" control lock
// note that we don't set the private Vessel.physicsHoldLock field to false, resulting in the Vessel.HoldPhysics property staying true
// The only impact (in stock) is that g-force / dynamic pressure checks for breaking deployable parts (solar panels/radiators/antennas)
// won't run, which is good.
if (vessel.isActiveVessel)
{
InputLockManager.RemoveControlLock("physicsHold");
}
}
}
// KerbalPortrait will prevent the "go to EVA" button from working if Part.packed is true (no such limitation on the "crew transfer" PAW UI)
// This is done in Update(), so we un-do it in LateUpdate()
public void LateUpdate()
{
if (!vessel.isActiveVessel || !physicsHold || KerbalPortraitGallery.Instance == null)
return;
foreach (KerbalPortrait kp in KerbalPortraitGallery.Instance.Portraits)
{
if (kp.hoverArea.Hover && !kp.evaButton.interactable && kp.crewMember != null)
{
kp.crewMember.InPart.packed = false;
try
{
kp.evaButton.interactable = (bool)KerbalPortrait_CanEVA.Invoke(kp, null);
}
catch (Exception) { }
kp.crewMember.InPart.packed = true;
if (kp.evaButton.interactable)
kp.evaTooltip.textString = cacheAutoLOC_459494;
}
}
}
#endregion
#region GAMEEVENTS
// Called when a docking/coupling action is about to happen. Gives access to old and new vessel
// Remove PAW buttons from the command parts and disable ourselves when the vessel
// is about to be removed following a docking / coupling operation.
private void OnPartCouple(GameEvents.FromToAction<Part, Part> data)
{
LogDebug($"OnPartCouple on {vessel.vesselName}, docked vessel : {data.from.vessel.vesselName}, dominant vessel : {data.to.vessel.vesselName}");
// in the case of KIS-adding parts, from / to vessel are the same : we ignore the event
if (data.from.vessel == data.to.vessel)
return;
// "from" is the part on the vessel that will be removed following coupling/docking
if (data.from.vessel == vessel)
{
foreach (CommandPart commandPart in commandParts)
{
commandPart.ClearBaseField();
}
commandParts.Clear();
ClearEvents();
isEnabled = enabled = false;
}
}
// Called after a docking/coupling action has happend. All parts now belong to the same vessel.
private void OnPartCoupleComplete(GameEvents.FromToAction<Part, Part> data)
{
LogDebug($"OnPartCoupleComplete on {vessel.vesselName} from {data.from.vessel.vesselName} to {data.to.vessel.vesselName}");
if (data.from.vessel != vessel)
return;
// add any command part we don't already know about
foreach (Part part in vessel.Parts)
{
if (part.HasModuleImplementing<ModuleCommand>() && !commandParts.Exists(p => p.part == part))
{
commandParts.Add(new CommandPart(this, part));
}
}
}
// called before a new vessel is created following intentional decoupler use or a joint failure
// the part.vessel reference is still the old, non separated vessel
private void OnPartDeCouple(Part part)
{
if (part.vessel != vessel)
return;
foreach (CommandPart commandPart in commandParts)
{
commandPart.ClearBaseField();
}
commandParts.Clear();
lastDecoupledPartVessel = vessel; // see why in OnPartDeCoupleComplete
if (physicsHold)
{
physicsHold = false;
if (!hasEverBeenUnpacked)
{
hasEverBeenUnpacked = true;
SetupWheels();
}
framesAtStartupFieldInfo.SetValue(vessel, Time.frameCount - 100);
vessel.GoOffRails();
}
}
// called after a new vessel is created following intentional decoupler use or a joint failure
// we have no way to identify the "old" vessel from which the part comes from, so we have saved
// the vessel reference in OnPartCouple. Note that OnPartDeCouple/OnPartDeCoupleComplete are called
// at the begining/end of Part.decouple(), so it's safe to do.
// Also, GameEvents.onPartDeCoupleNewVesselComplete with access to both old and new vessel has been
// introduced in KSP 1.10 but for the sake of making this work in 1.8 - 1.9 we don't use it
private void OnPartDeCoupleComplete(Part newVesselPart)
{
if (lastDecoupledPartVessel == null || lastDecoupledPartVessel != vessel)
return;
lastDecoupledPartVessel = null;
SetupCommandParts();
}
// called before undocking, all parts still belong to the original vessel
private void OnPartUndock(Part part)
{
if (part.vessel != vessel)
return;
if (physicsHold)
{
if (!hasEverBeenUnpacked)
{
hasEverBeenUnpacked = true;
SetupWheels();
}
// Things will go awfully wrong for the undocked vessel if the initial vessel is still packed when Part.Undock() is called.
// So we immediately unpack it by calling GoOffRails() while setting "framesAtStartup" in order to have PhysicsHoldExpired() return true.
framesAtStartupFieldInfo.SetValue(vessel, Time.frameCount - 100);
vessel.GoOffRails();
}
}
// called after a new vessel is created following undocking
// here we do have to do the huge mess we do for uncoupling. Since we have access to the old vessel, we can
// just remove all parts that are now on the new vessel.
private void OnVesselsUndocking(Vessel oldVessel, Vessel newVessel)
{
LogDebug($"OnVesselsUndocking called on {vessel.vesselName}, oldVessel {oldVessel.vesselName}, newVessel {newVessel.vesselName}");
if (vessel != oldVessel)
return;
foreach (Part part in newVessel.Parts)
{
int commandPartIndex = commandParts.FindIndex(p => p.part == part);
if (commandPartIndex >= 0)
{
commandParts[commandPartIndex].ClearBaseField();
commandParts.RemoveAt(commandPartIndex);
}
}
// Force the dominant vessel to stay packed.
// Landed has been reset by the GoOffRails() call in OnPartUndock(), but by forcing Landed, since we didn't
// set physicsHold to false, the next FixedUpdate() will call GoOnRails() and immediately re-pack the vessel.
if (physicsHold)
{
vessel.Landed = true;
}
}
private void OnPartDestroyed(Part part)
{
int partIndex = commandParts.FindIndex(p => p.part == part);
if (partIndex >= 0)
{
commandParts[partIndex].ClearBaseField();
commandParts.RemoveAt(partIndex);
}
}
#endregion
#region PAW UI BUTTONS
/// <summary>
/// Add our PAW button to every command part, or to the root part if no command part is found.
/// </summary>
private void SetupCommandParts()
{
if (commandParts == null)
commandParts = new List<CommandPart>();
foreach (Part part in vessel.Parts)
{
if (part.HasModuleImplementing<ModuleCommand>())
{
commandParts.Add(new CommandPart(this, part));
}
}
if (commandParts.Count == 0)
{
commandParts.Add(new CommandPart(this, vessel.rootPart));
}
}
private class CommandPart
{
public Part part;
public BaseField field;
public CommandPart(PhysicsHold instance, Part part)
{
this.part = part;
field = new BaseField(new UI_Toggle(), physicsHoldField, instance);
part.Fields.Add(field);
field.uiControlFlight = new UI_Toggle();
field.guiName = "Landed physics hold";
field.guiActive = part.vessel.Landed; // don't really care, this is updated in Update()
field.guiActiveUnfocused = true;
field.guiUnfocusedRange = 500f;
field.uiControlFlight.requireFullControl = false;
}
public void ClearBaseField()
{
bool hasRemovedFields = false;
try
{
List<BaseField> fields = (List<BaseField>)typeof(BaseFieldList).GetField("_fields", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(part.Fields);
for (int i = fields.Count - 1; i >= 0; i--)
{
if (fields[i] == field)
{
fields.RemoveAt(i);
hasRemovedFields = true;
}
}
}
catch (Exception e)
{
Debug.LogWarning($"Error removing basefield {field.name} on part {part.name}\n{e}");
hasRemovedFields = true;
}
finally
{
if (hasRemovedFields && UIPartActionController.Instance != null)
{
// Ideally we should remove the UI item corresponding to the basefield,
// but that isn't so easy. Destroying the PAWs is good enough given
// how unfrequently this is called.
for (int i = UIPartActionController.Instance.windows.Count - 1; i >= 0; i--)
{
if (UIPartActionController.Instance.windows[i].part == part)
{
UIPartActionController.Instance.windows[i].gameObject.DestroyGameObjectImmediate();
UIPartActionController.Instance.windows.RemoveAt(i);
}
}
for (int i = UIPartActionController.Instance.hiddenWindows.Count - 1; i >= 0; i--)
{
if (UIPartActionController.Instance.hiddenWindows[i].part == part)
{
UIPartActionController.Instance.hiddenWindows[i].gameObject.DestroyGameObject();
UIPartActionController.Instance.hiddenWindows.RemoveAt(i);
}
}
}
}
}
}
#endregion
#region SPECIFIC HACKS
/// <summary>
/// Wheels have a wheelSetup() method being called by a coroutine launched from OnStart(). That coroutine is waiting indefinitely
/// for part.packed to become false, which won't happen if the vessel is in hold since the scene start. This is an issue if we want
/// to undock the vessel, as wheels have a onVesselUndocking callback that will nullref if the setup isn't done.
/// So, when we undock a packed vessel, if that vessel has never been unpacked, we manually call wheelSetup(), and cancel the
/// coroutine (it will nullref if called twice).
/// </summary>
private void SetupWheels()
{
foreach (ModuleWheelBase wheel in vessel.FindPartModulesImplementing<ModuleWheelBase>())
{
// TODO : cache the fieldinfo / methodinfo
if (!((bool)typeof(ModuleWheelBase).GetField("setup", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(wheel)))
{
wheel.StopAllCoroutines();
typeof(ModuleWheelBase).GetMethod("wheelSetup", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(wheel, null);
}
}
}
// ModuleDockingNode FSM hacking, not needed but keeping it for reference in case we want to try enabling
// docking a packed vessel
private IEnumerator SetupDockingNodesHoldState()
{
foreach (Part part in vessel.Parts)
{
// part.dockingPorts is populated from Awake()
foreach (PartModule partModule in part.dockingPorts)
{
if (!(partModule is ModuleDockingNode dockingNode))
continue;
while (dockingNode.on_undock == null)
{
yield return null;
}
dockingNode.on_undock.OnCheckCondition += (KFSMState st) => part.vessel.FindVesselModuleImplementing<PhysicsHold>().physicsHold == false;
}
}
yield break;
}
#endregion
#region UTILS
[Conditional("DEBUG")]
private static void LogDebug(string message)
{
Debug.Log(message);
}
#endregion
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment