diff --git a/GameData/KSPCommunityPartModules/KSPCommunityPartModules.ckan b/GameData/KSPCommunityPartModules/KSPCommunityPartModules.ckan index 6ae6250..2089ac0 100644 --- a/GameData/KSPCommunityPartModules/KSPCommunityPartModules.ckan +++ b/GameData/KSPCommunityPartModules/KSPCommunityPartModules.ckan @@ -1,6 +1,6 @@ identifier: KSPCommunityPartModules name: KSP Community Part Modules -license: MIT +license: GPLv3 abstract: A collection of part modules that are frequently used, Exists as a dependency for other mods. description: >- This mod is a collection of part modules that are frequently used by multiple mods. diff --git a/LICENSE.md b/LICENSE.md index cb582fd..32bd985 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +**Exceptions:** +The following files are derived from GPLv3 sources and are licensed under the GNU General Public License v3.0: +- Source/Modules/NameTagWindow.cs +- Source/Modules/ModuleNameTag.cs + +See the license headers in those files for additional details. diff --git a/README.md b/README.md index 5f9590b..d9135bd 100644 --- a/README.md +++ b/README.md @@ -29,5 +29,7 @@ Compatible with **KSP 1.12.3** and up - Available on [CKAN] - **ModuleDepthMask**
This module allows for parts to have hollow insets that dont clip into other parts, ideal for engine nozzles, landing gear, air intakes, solar panel bays, and more. +- **ModuleNameTag**
This module adds a user-editable name tag to a part, set through an in-game window. Shared by kOS (part:TAG) and kRPC (Part.Tag) so a tag assigned by one is visible to the other. Consuming mods add the module to parts with their own ModuleManager patch; legacy KOSNameTag tags from older kOS/kRPC saves are migrated automatically. + [CKAN]: https://forum.kerbalspaceprogram.com/topic/197082-ckan-the-comprehensive-kerbal-archive-network-v1332-laplace-ksp-2-support/ diff --git a/Source/HarmonyPatches/NameTagMigrationPatch.cs b/Source/HarmonyPatches/NameTagMigrationPatch.cs new file mode 100644 index 0000000..2491437 --- /dev/null +++ b/Source/HarmonyPatches/NameTagMigrationPatch.cs @@ -0,0 +1,41 @@ +using HarmonyLib; +using KSPCommunityPartModules.Modules; + +namespace KSPCommunityPartModules.HarmonyPatches +{ + /// + /// Migrates legacy name-tag save data onto the renamed . + /// + /// kOS and kRPC each used to ship a part module named "KOSNameTag"; that module now lives here as + /// ModuleNameTag. Craft and saves made with the old mods store their tag in a + /// MODULE { name = KOSNameTag, nameTag = "..." } node. KSP binds a saved MODULE node to a prefab + /// module by matching the node's "name", so without this patch an old node would not match the + /// ModuleNameTag prefab module and the tag would be lost. Both the editor craft path + /// (ShipConstruct.LoadShip) and the flight/persistence path (ProtoPartModuleSnapshot.Load) funnel + /// through Part.LoadModule, so rewriting the node's module name there covers both. The nameTag + /// value key is unchanged, so it binds directly once the module name matches. + /// + /// This patch is applied by the assembly-wide Harmony.PatchAll() bootstrap in + /// ModuleParachuteEvents.ModuleManagerPostLoad; it deliberately has no bootstrap of its own so the + /// parachute patches are not applied twice. + /// + [HarmonyPatch(typeof(Part), nameof(Part.LoadModule))] + public static class NameTagMigrationPatch + { + private const string LegacyModuleName = "KOSNameTag"; + + [HarmonyPrefix] + static void Prefix(ConfigNode node) + { + if (node == null || node.GetValue("name") != LegacyModuleName) + return; + + // If a real KOSNameTag PartModule type is still loaded (an old kOS/kRPC, or the retired + // standalone NameTag mod), leave the node alone so we don't misroute it to ModuleNameTag. + if (AssemblyLoader.GetClassByName(typeof(PartModule), LegacyModuleName) != null) + return; + + node.SetValue("name", ModuleNameTag.MODULENAME); + } + } +} diff --git a/Source/KSPCommunityPartModules.csproj b/Source/KSPCommunityPartModules.csproj index 134a31c..00c6e67 100644 --- a/Source/KSPCommunityPartModules.csproj +++ b/Source/KSPCommunityPartModules.csproj @@ -31,10 +31,13 @@ 4 - + + + + diff --git a/Source/Modules/ModuleNameTag.cs b/Source/Modules/ModuleNameTag.cs new file mode 100644 index 0000000..7965669 --- /dev/null +++ b/Source/Modules/ModuleNameTag.cs @@ -0,0 +1,108 @@ +/* + Usecase: A user-editable name tag on any part, readable and writable by other mods. + Example: kOS's part:TAG suffix and kRPC's Part.Tag both read and write this module's value. + Originally By: kOS contributors (Dunbaratu et al.) + Originally For: kOS + License: GNU General Public License v3.0, see https://www.gnu.org/licenses/gpl-3.0.html +*/ +using UnityEngine; + +namespace KSPCommunityPartModules.Modules +{ + public class ModuleNameTag : PartModule + { + public const string MODULENAME = nameof(ModuleNameTag); + + private const string PAWGroup = "Name Tag"; + + private NameTagWindow typingWindow; + + [KSPField(isPersistant = true, + guiActive = true, + guiActiveEditor = true, + guiName = "name tag", + groupName = PAWGroup, + groupDisplayName = PAWGroup)] + public string nameTag = ""; + + [KSPEvent(guiActive = true, + guiActiveEditor = true, + guiName = "Change Name Tag", + groupName = PAWGroup, + groupDisplayName = PAWGroup)] + public void PopupNameTagChanger() + { + if (typingWindow != null) + typingWindow.Close(); + if (HighLogic.LoadedSceneIsEditor) + { + EditorFacility whichEditor = EditorLogic.fetch.ship.shipFacility; + if (!CanTagInEditor(whichEditor)) + { + var formattedString = string.Format("The {0} requires an upgrade to assign name tags", whichEditor); + ScreenMessages.PostScreenMessage(formattedString, 6, ScreenMessageStyle.UPPER_CENTER); + return; + } + } + // Make a new instance of typingWindow, replacing the existing one if there was one: + NameTagWindow oldTypingWindow = gameObject.GetComponent(); + if (oldTypingWindow != null) + Destroy(oldTypingWindow); + typingWindow = gameObject.AddComponent(); + typingWindow.Invoke(this, nameTag); + } + + // For kOS issue #2764, this enforces a rule that says regardless of what ModuleManager + // rules end up doing, there shall only ever be one name tag module per part: + public override void OnAwake() + { + // If other instances of me exist in this part, remove them. I am replacing them: + for (int i = part.Modules.Count - 1; i >= 0; --i) + { + PartModule pm = part.Modules[i]; + if (pm != this && pm is ModuleNameTag) + { + Debug.Log(string.Format( + "[{0}] Removing duplicate name tag PartModule from {1}. Only one tag per part is supported.", + MODULENAME, part.name)); + part.RemoveModule(pm); + } + } + } + + public void TypingDone(string newValue) + { + nameTag = newValue; + TypingCancel(); + } + + public void TypingCancel() + { + typingWindow.Close(); + Destroy(typingWindow); + typingWindow = null; + } + + /// + /// Whether the current editor building is upgraded enough to allow assigning a name tag. + /// In flight there is no such restriction. Tagging unlocks at the same point the game starts + /// unlocking basic action groups. + /// + private static bool CanTagInEditor(EditorFacility whichEditor) + { + float buildingLevel; + switch (whichEditor) + { + case EditorFacility.VAB: + buildingLevel = ScenarioUpgradeableFacilities.GetFacilityLevel(SpaceCenterFacility.VehicleAssemblyBuilding); + break; + case EditorFacility.SPH: + buildingLevel = ScenarioUpgradeableFacilities.GetFacilityLevel(SpaceCenterFacility.SpaceplaneHangar); + break; + default: + return false; + } + return GameVariables.Instance.UnlockedActionGroupsStock(buildingLevel, false); + } + } +} diff --git a/Source/Modules/NameTagWindow.cs b/Source/Modules/NameTagWindow.cs new file mode 100644 index 0000000..fa22d88 --- /dev/null +++ b/Source/Modules/NameTagWindow.cs @@ -0,0 +1,203 @@ +/* + Usecase: The in-editor / in-flight IMGUI window used to edit a part's name tag. + Originally By: kOS contributors (Dunbaratu et al.) + Originally For: kOS + License: GNU General Public License v3.0, see https://www.gnu.org/licenses/gpl-3.0.html +*/ +using UnityEngine; + +namespace KSPCommunityPartModules.Modules +{ + public class NameTagWindow : MonoBehaviour + { + private ModuleNameTag attachedModule; + private Rect windowRect; + private string tagValue; + // ReSharper disable RedundantDefaultFieldInitializer + private bool wasFocusedOnce = false; // "explicit", not "redundant". + private int numberOfRepaints = 0; // "explicit", not "redundant". + private bool gameEventHooksExist = false; // "explicit", not "redundant". + private int myWindowId; // must be unique for Unity to not mash two nametag windows togehter. + private string lockId; // per-window editor input lock, so two open windows don't unlock each other. + + // ReSharper enable RedundantDefaultFieldInitializer + + public void Invoke(ModuleNameTag module, string oldValue) + { + attachedModule = module; + tagValue = oldValue; + myWindowId = GetInstanceID(); // Use the Id of this MonoBehaviour to guarantee unique window ID. + lockId = "NameTagLock" + myWindowId; // unique per window, so closing one doesn't unlock another. + + Vector3 screenPos = GetViewportPosFor(attachedModule.part.transform.position); + + // screenPos is in coords from 0 to 1, 0 to 1, not screen pixel coords. + // Transform it to pixel coords: + float xPixelPoint = screenPos.x * UnityEngine.Screen.width; + float yPixelPoint = (1-screenPos.y) * UnityEngine.Screen.height; + const float WINDOW_WIDTH = 200; + + windowRect = new Rect(xPixelPoint, yPixelPoint, WINDOW_WIDTH, 130); + + SetEnabled(true); + + if (HighLogic.LoadedSceneIsEditor) + attachedModule.part.SetHighlight(false, false); + + } + + /// + /// Catch the event of the part disappearing, from crashing or + /// from unloading from distance or scene change, and ensure + /// the window closes if it was open when that happens: + /// + /// The callback is called for EVERY part + /// that ever goes away, so we have to check if it's the right one + public void GoAwayEventCallback(Part whichPartWentAway) + { + if (whichPartWentAway != attachedModule.part) + return; + + Close(); + } + + /// + /// If you try to set a Unity.Behaviour.enabled to false when it already IS false, + /// and Unity hasn't fully finished configuring the MonoBehaviour yet, the Property's + /// "set" code throws a null ref error. How lame is that? + /// That's why I wrapped every attempt to set enabled's value with this check, because KSP + /// tries running my hooks in this class before Unity's ready for them. + /// + private void SetEnabled(bool newVal) + { + // ReSharper disable once RedundantCheckBeforeAssignment + if (newVal != enabled) + enabled = newVal; + + if (enabled) + { + if (! gameEventHooksExist) + { + GameEvents.onPartDestroyed.Add(GoAwayEventCallback); + GameEvents.onPartDie.Add(GoAwayEventCallback); + gameEventHooksExist = true; + } + } + else + { + if (gameEventHooksExist) + { + GameEvents.onPartDestroyed.Remove(GoAwayEventCallback); + GameEvents.onPartDie.Remove(GoAwayEventCallback); + gameEventHooksExist = false; + } + } + } + + /// + /// Get the position in screen coordinates that the 3d world coordinates + /// project onto, abstracting the two different ways KSP has to access + /// the camera depending on view mode. + /// Returned coords are in a system where the screen viewport goes from + /// (0,0) to (1,1) and the Z coord is how far from the screen it is + /// (-Z means behind you). + /// + private Vector3 GetViewportPosFor( Vector3 v ) + { + return GetCurrentCamera().WorldToViewportPoint(v); + } + + private static Camera GetCurrentCamera() + { + return HighLogic.LoadedSceneIsEditor ? + EditorLogic.fetch.editorCamera : + (MapView.MapIsEnabled ? + PlanetariumCamera.Camera : FlightCamera.fetch.mainCamera); + } + + public void OnGUI() + { + if (Event.current.type != EventType.Repaint) + ++numberOfRepaints; + + if (! enabled) + return; + if (HighLogic.LoadedSceneIsEditor) + // Lock the whole editor (including part pick/place) while the window is up. The old + // EditorLogic.fetch.Lock(false, false, false, ...) passed all-false flags, so it never + // locked pick/place and clicks on Accept/Cancel fell through and dragged the part + // behind the window. SetControlLock is keyed by lockId and is safe to call every frame. + InputLockManager.SetControlLock(ControlTypes.EDITOR_LOCK, lockId); + + GUI.skin = HighLogic.Skin; + GUILayout.Window(myWindowId, windowRect, DrawWindow,"Name tag"); + + // Ensure that the first time the window is made, it gets keybaord focus, + // but allow the focus to leave the window after that: + // The reason for the "number of repaints" check is that OnGUI has to run + // through several initial passes before all the components are present, + // and if you call FocusControl on the first few passes, it has no effect. + if (numberOfRepaints >= 2 && ! wasFocusedOnce) + { + GUI.FocusControl("NameTagField"); + wasFocusedOnce = true; + } + } + + public void DrawWindow( int windowID ) + { + if (! enabled) + return; + + Event e = Event.current; + if (e.type == EventType.KeyDown) + { + if (e.keyCode == KeyCode.Return || + e.keyCode == KeyCode.KeypadEnter) + { + e.Use(); + attachedModule.TypingDone(tagValue); + Close(); + } + } + GUILayout.Label(attachedModule.part.name); + GUI.SetNextControlName("NameTagField"); + tagValue = GUILayout.TextField( tagValue, GUILayout.MinWidth(160f)); + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Cancel")) + { + e.Use(); + Close(); + } + if (GUILayout.Button("Accept")) + { + e.Use(); + attachedModule.TypingDone(tagValue); + Close(); + } + GUILayout.EndHorizontal(); + + // Before going any further, suppress any remaining unprocessed clicks + // so they don't end up causing the editor to detach parts: + if (e.type == EventType.MouseDown || e.type == EventType.MouseUp || e.type == EventType.MouseDrag) + e.Use(); + } + + public void Close() + { + if (HighLogic.LoadedSceneIsEditor) + InputLockManager.RemoveControlLock(lockId); + + SetEnabled(false); + + if (HighLogic.LoadedSceneIsEditor) + attachedModule.part.SetHighlight(false, false); + } + + public void OnDestroy() + { + SetEnabled(false); + } + } +}