Jump to content

The Lazy Coder's Guide to KSP UI Design - a tutorial


Recommended Posts

 

Introduction

I tried. I tried really hard to follow @DMagic's tutorial. I really did.  I have one problem though. I'm old.  How old am I?  Old enough that the first programming language I learned was on a TRS-80 model 1 (with the cassette drive).  I'm old enough that I know at least a dozen flavors of BASIC, to include GW Basic, Basic A, Commodore Basic, Amiga Basic, Visual Basic... and the list goes on.  Being that old, I come from the school of line numbers. Everything should happen in linear sequence with gosub's and goto's.   When look at what people today are calling object oriented and this callback and that callback and this get : set and... well... when I look at code like that I feel like I'm on a perpetual pogo stick and I can't get the hell off.  Unfortunately, when I tried DMagic's tutorial I found that pogo stick and it dropped me on my head.  Sorry D, you're way above my skills in coding. I needed something simple

But, I really wanted a UI.  I first tried the popup thing with a few buttons on it.  And yea, that works, but it's not a "REAL" UI with cool things like sliders and buttons and checkboxes.  And I know that Unity and KSP can do it. I've seen it.  But the god awful examples I found were telling me I was doomed.  I had to create these UI's from positions, and coordinates and lines and a gobbledygook of code that I couldn't even begin to visualize.

Surely, there had to be an easier way!?

And here it is.  Proof that if you hate hard work, you can alway find a way to avoid it.

The Lazy Coder's Guide to KSP UI Design

This is no way a comprehensive tutorial on UI design. This tutorial is to give you the very basics.  If you follow these steps, you should be able to create a working UI within KSP without creating any scripts in Unity.  This is purely a Unity design-it, KSP execute-it method.  With this working example I hope you will have the basics for adding a few controls from the Unity UI and be able to read from and manipulate them at run-time.  This tutorial does make a few assumptions. That you have a basic working knowledge of Unity, that you have PartTools installed and working and that hopefully, you've created a few parts and a small plugin and you're ready to advance to UI's.

In Unity

In Unity, create a File/New Scene..  Up at the top of the scene, click on 2D.  In the hierarchy panel in an empty spot, right click to bring up the pop-up menu.  Scroll down to UI and in the pop-out menu, select Canvas.  The Canvas is simply a container to hold the UI you're going to be working on.  It's size is unimportant.  You should also have seen that it created a new item called EventSystem.  The default settings for both of these game objects is fine.

Next, right click on the Canvas in the hierarchy and again, scroll down to UI and select Panel.  This panel is going to be the background for your UI.   Before we get into changing the background, we first need to set the size for our UI.  By default, Unity sets the anchors for the panel at Stretch/Stretch.  With the panel selected, look in the inspector and you'll see the anchors at the top left of the “Rect Transform”  With stretch/stretch as the anchor method, Unity expect you to size your UI by the screen size. The settings it allows you to change are top, left, bottom and right.  Those measurements are the distance from the edge of the canvas you created.  For this tutorial we're going to make a UI of a fixed size.  With the panel selected in the hierarchy, in the inspector click on the square with the blue plus symbol in it (one of the green arrows in the image points to it).  This will create a popup that will allow you to change the anchor method for the panel.  At the top left of that popup, select the image under the column 'top' and in the row 'left'.  This now tells the Panel that you want it to align to the top left of the canvas. More importantly, it changes the rect transform to allow you to put in a height and width.  There are two ways to adjust the height and width.  One is to manually enter the numbers in the rect transform height and width boxes.  The other is by using the mouse.  Up above the hierarchy, click on the icon of the square with the dots in each corner.  This will allow you to use the mouse and drag the corners to get the proper size.

To change the background, with the panel selected in the hierarchy you should see in the inspector an 'Image (Script)'.  The two important parts of this Image are the Source Image and the Color.  By changing and adjusting those, you can choose the type of background you want for your UI, including how transparent it is by setting the color's alpha.

This image shows some of the controls I've mentioned up till now and where to find them.


6OjImnR.png

A quick note. There's one very good reason to get in the habit of setting your anchors at least, to the same location.  Once you get into more complex UI's it's becomes real handy to place groups of components onto even more panels.  By anchoring to say, top left, you can always drag those panels around and all of the components under them stay in the same position.

Next, we're going to add some controls to the panel.  Select the panel in the hierarchy.  Right click, scroll down to UI and select “Text”.  This should put a new text object in the center of your panel.  Let's change it's anchor to the same we used for the panel. With the text selected, in the inspector for the rect transform, open the anchor pop-up and select top left.  Now you can either drag your text object around to where you want it placed or you can enter a Pos X and a Pos Y in the rect transform to place it. 

Following that basic procedure, place a toggle and a slider.  Be sure to set the anchors to top left and make sure they're all under the panel in the  hierarchy.   Once you have them place, we need to change the names on these game objects.  Rename the panel to “MyCoolUIPanel”.  Rename the Text to “ImportantText.”  While you're there and have it selected in inspector you'll see a “Text (Script)”  In the box named “Text” change that to “We need to change this.”   Next, rename the Toggle to “CheckThisOut” and rename the slider to “YouMoveMe”. 

At this point, your scene should look a lot like this:

XyCY69n.png

Next, in your assets folder, create 2 new folders.  Call one UI Save and the other UI Export.  Now, file/save your scene into the UI Save folder.  You can name it whatever you like.

In the project list, click on the UI Export folder which should be empty.  Click and hold on the MyCoolUIPanel and drag it down to that empty folder.  It should create a blue cube in that folder.  That's the prefab for the UI.  With that blue cube selected, at the very bottom right where it says “AssetBundle None”, click on the None and then select “New”  It will expect a name so call it mycoolui.  Your screen should look like this. (minus  the day-glow arrows and such).

t5ZE6aV.png

A brief explanation.  The reasons we put the saved scene and the prefab into separate folders is to prevent a collision.  You CAN give the folder an Asset Bundle name and, in theory, it will try to take everything in that folder and create an asset. If you have the saved scene in there, it will promptly crash and burn.  Therefore, the safest way I've found is to create two separate folders and everything that needs to get bundled goes into a specific folder.  If this doesn't make sense now, just trust me.  I'm saving you much pain.

So, now we have our UI created and we've assigned it an asset bundle name. It's time to export it.

In the main menu, select KSPAssets/Asset Compiler.  A new window should pop up with some buttons.  With any luck, you should see one that says mycoolui with a button that says “Create” beside it.  Click on the create button.  That hopefully made some new buttons available.  If you want to see what files are being included in the prefab you can click on view.  Click on Show All to get back to this menu.  The two important buttons here are Update and Build.  First, get in the habit of clicking the update first and then, click the build.

qQlbqDO.png

A brief explanation.  What we just did was we created an asset bundle that KSP will recognize.  Any time you make changes to your UI or you add new items like graphics, you need to place them in the folder that holds the export and assign the asset bundle name.  For example, if you change an item in the UI you'll need to delete the old panel prefab, drag the panel back down to the export folder to create a new prefab, set the asset bundle name and then, in the asset compiler, click update then build.

Update: DMagic, the master of the KSP UI has informed me that replacing the prefab as I have been doing isn't necessary.  His comments on how to update the prefabs are here:

Once you click on the build, it will likely take a second or two.  First, make sure that nothing went wrong.  Click on the console tab at the bottom of Unity and make sure that you don't have any red exclamation marks.  If you do, click clear on the console and try to build again.  I'm not going to go into the possible errors but if you do have one, I warn you, they will often be cryptic and I wish you luck figuring out what you did wrong.

So, assuming everything went right, you have a new bundle.  The problem is, you're not going to be able to find it in Unity.  So, close out Unity and open your favorite file explorer/manager.  Navigate to the folder where your project is and along with the Assets folder, you should see a folder called AssetBundles.  Open that folder and, if you were successful, you have a new file there called mycoolui.ksp.  That my dear pupil, is your UI ready for use.

The Code

Now we're going to dive into the ugly part.  The coding.  But before we do, we need to get a few things set up.  We're going to need to create a new plugin and, we're going to create a very simple partmodule.  So, in your GameData directory for your KSP install, create a new folder and call it “mycoolplugin.”  Open that folder and create two more folders,  Parts and Plugins.   Go back to your Unity project / AssetBundles folder and copy the mycoolui.ksp file into the Plugins folder you just created.  Now, change the file extension on the mycoolui from .ksp to something else, like .dat.  More on the reason for that in a bit.

Now, let's get busy coding.  (At the end of this tutorial is the full code needed for both the partmodule and the monobehaviour.)

I'm not going to go into depth on how to set up to code.  This is a tutorial about making a UI so some knowledge of your IDE and how to set up references is assumed.  The only thing out of the ordinary that you'll need to add is the UnityEngine.UI as a reference.


First we'll need a part.  I have conveniently appropriated one from the Squad repository and repurposed it for our needs.  Here's the .cfg info.  Copy this and save it into the part folder as... oh... part.cfg.  

PART
{
	name = CoolUITestPart
	module = Part
	author = RoverDude
	rescaleFactor = 1.0
	node_attach = 0.3, 0.0, 0.0, 1.0, 0.0, 0.0, 0
	TechRequired = precisionPropulsion
	entryCost = 1200
	cost = 50
	category = Propulsion
	subcategory = 0
	title = Cool UI Test Part
	manufacturer = #autoLOC_501633 //#autoLOC_501633 = Probodobodyne Inc
	description = #autoLOC_501812  //#autoLOC_501812 = Often, and mistakenly used for playing ball games.
	attachRules = 0,1,0,1,0
	mass = 0.01375
	dragModelType = default
	maximum_drag = 0.2
	minimum_drag = 0.3
	angularDrag = 2
	crashTolerance = 5
	breakingForce = 50
	breakingTorque = 50
	maxTemp = 2000 // = 3000
	fuelCrossFeed = True
	bulkheadProfiles = srf
	tags = #autoLOC_501813  //#autoLOC_501813 = fueltank ?lfo liquid oxidizer propellant rocket
	MODEL
	{
		model = Squad/Parts/FuelTank/FoilTanks/RadialTank_Round
	}
	MODULE
	{
		name = CoolUIPM
	}
}

You probably noticed that I chopped out most of what we won't need and added in a couple of lines to call our plugin.

The partmodule is going to be really simple.  We only really need it to do one thing. Open the UI.  But, I'm also going to add in a progress bar and a few lines of code that we can play around with.

The first bit we'll add in are two KSP fields. One is our toggle button to open the main UI and the other is the progress bar.

namespace mycoolplugin
{
    public class CoolUIPM : PartModule
    {

        //Create a copule of cool things for the part UI that we can play with
        //a button to toggle the UI on and off
        [KSPField(guiName = "Open UI", guiActiveEditor = true, guiActive = true, isPersistant = false),
        UI_Toggle(controlEnabled = true, disabledText = "Closed", enabledText = "Open", invertButton = false, scene = UI_Scene.All)]
        public bool openUI = false;

        //and a progress bar that we can play with
        [KSPField(guiName = "Cool Slider", guiActiveEditor = true, guiActive = true, isPersistant = true),
        UI_ProgressBar(minValue = 0f, maxValue = 1f, scene = UI_Scene.All)]
        public float PartUICoolSlider = 0.0f;

In the OnStart, we need to create a callback.  These few lines of code are going to perform a critical function.  Any time the part UI toggle button is clicked, it's going to call the function to either show the UI or destroy it.

Wait? Destroy it?  Why would we want to do that?  Simple. The UI's are very persistent ... really persistent.  If you open a UI in the editor and then launch, it stays open. If you open one then switch from the SPH, to the VAB, it stays open.  It'll even stay open when you exit out to KSC.  For our purposes, we don't want that.  We only want it to show the UI when the user is where that part is.  So, whenever the user clicks on the partmodule toggle button, we want to either open or destroy the UI.  This chunk of code triggers that.

NOTICE:  This callback is set up to work in the editor only.  Note the .uiControlEditor.  That prevents that callback from working anywhere BUT the editor.  If you want your UI to be available in flight, you'll have to change that to .uiControlFlight.

        public override void OnStart(StartState state)
        {
            //This creates a callback so that whenver the UI_Toggle openUI is clicked, it either opens or closes the main UI
            Fields[nameof(openUI)].uiControlEditor.onFieldChanged = delegate (BaseField a, System.Object b)
                {
                    if (CoolUI.CoolUICanvas == null)
                    {
                        CoolUI.ShowGUI(); //if the UI doesn't exist, create one and show it.
                    }
                    else
                    {
                        CoolUI.Destroy(); //if it does exist. they're closing it so get rid of it.
                    }
                };
        }

Next, the FixedUpdate.

The first thing we need to do here is check and see if the UI is already open.  The reason we do that is because we may have a bunch of parts on the vessel with our same partmodule on them.  If the UI is open, then that means someone clicked on the button to open it.  We need to set all the UI_Toggle buttons on all of our parts to true.

For this example if the UI isn't open (it's null) then leave FixedUpdate.  Otherwise, trying to get info from a non-existent UI will create a null reference error.

In the final 2 lines are were we're going to manipulate things on the UI.  First, we're going to set the value of our progress bar to the same value as the UI slider.  Next, we're going to update the UI text so that it shows us a text value of the slider's position.

        public void FixedUpdate()
        {
            //This checks to see if the UI is being shown.  If so, it will update any other CoolUIPm partmodules so that they show the button as being clicked.
            if (CoolUI.CoolUICanvas != null)
            { openUI = true; }
            else
            { return; }  //if not, we don't want to call UI functions because they'll create a null ref.

            //This is going to take the partmodule UI_ProgressBar and make it the same as the UI slider.
            PartUICoolSlider = CoolUI.CoolSliderPosition();

            //This calls the procedure to update the main UI text give a number representation of the slider position.
            CoolUI.UpdateText(PartUICoolSlider.ToString());
        }
    }
}

Ok, that's it for the partmodule. See, it was easy.  Now for the harder part, the Monobehavour.

The first thing we need to do is to create a loader.  This monobehaviour, CoolUILoader, has the sole responsibility of finding the asset bundle and loading it up as a gameobject.  We're going to do this in StartupInstantly so that it gets loaded early.  This code is looking for the bundle in the same directory that the plugin is in. So hopefully, when you create this plugin, you put both it and the bundle in the same folder.

Update: DMagic, the master of the KSP UI, has informed me that loading the prefab manually as I do in the code below and with the .ksp extension may not be necessary and may even cause issues.  He also suggest renaming the extension from .ksp to something else which makes perfect sense.  KSP loads a number of files automatically, like .cfg files.  By changing the extension of the file name, you can guarantee that KSP won't load it before you do and thus, create a problem when your code tries to load it and can't because it's already loaded.   Therefore, if you remember, we renamed the bundle file to mycoolui.dat.   Here's where we load it. 

You can read his comments here:

using System.Collections.Generic;
using System.IO;
using System.Reflection;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace mycoolplugin
{
    [KSPAddon(KSPAddon.Startup.Instantly, true)]
    public class CoolUILoader : MonoBehaviour
    {
        private static GameObject panelPrefab;

        public static GameObject PanelPrefab
        {
            get { return panelPrefab; }
        }

        private void Awake()
        {
          	 //The way I was doing this which does seem to work.
          	//AssetBundle prefabs = AssetBundle.LoadFromFile(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "mycoolui.ksp"));
          	//DMagic's method without the .ksp file extension
          	AssetBundle prefabs = AssetBundle.LoadFromFile(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "mycoolui.dat"));
          	panelPrefab = prefabs.LoadAsset("MyCoolUIPanel") as GameObject;
        }
    }

Next, the meat, the UI code itself.

Taking a look at the code, the first thing you should notice is that it's a Startup.Editor only.  This matches the code in the partmodule that indicates we only want to show our UI in the editor.  You'll need to change this if you want users to see it anywhere else.

First, we declare some variables.  I'll explain them as we get to them.

The first function is the Awake function.  This has one line but an important one.  This creates a callback so that any time the KSP scene changes, our code can be notified.

The next function is what that callback calls and it's really simple. If the UI exists, call the Destroy function.  This ensures that any time we switch scenes in KSP, the UI gets destroyed and the canvas gets set to null.

The Destroy function also does one other small task.  It looks for all of our partmodules and sets the UI_Toggle button to off.  This makes sure that when the user opens the partmodule UI that all of the 'open ui' buttons look the way they should.

    [KSPAddon(KSPAddon.Startup.EditorAny, true)]
    public class CoolUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
    {
        public static GameObject CoolUICanvas = null;
        public static GameObject CoolUIText = null;
        public static List<CoolUIPM> CoolUIParts = new List<CoolUIPM>();
        private static Vector2 dragstart;
        private static Vector2 altstart;

        private void Awake()
        {
            //this creates a callback so that whenever the scene is changed we can destroy the UI
            GameEvents.onGameSceneSwitchRequested.Add(OnSceneChange);
        }

        //If we don't get rid of the UI, it'll stay where it is indefinitely.  So, every time the scene is changed, we need to get rid of it.
        void OnSceneChange(GameEvents.FromToAction<GameScenes, GameScenes> fromToScenes)
        {
            if (CoolUICanvas != null)
            {
                Destroy();
            }
        }

        //This actually destroys the UI.  But it also goes through the partmodule's toggle buttons and turns them all off.
        public static void Destroy()
        {
            CoolUICanvas.DestroyGameObject();
            CoolUICanvas = null;
            foreach (CoolUIPM thisPart in CoolUIParts)
            {
                thisPart.openUI = false;
            }
        }

The next function shows the UI.  The first thing we need to check is whether the UI is already on the screen. If so, we need to stop. The next 3 lines handle calls our loader and assigns the prefab to the CoolUICanvas.  The easy way to think of this, the CoolUICanvas is the main panel we created in Unity where all of the controls are placed.  The next line tells the canvas that it should make the main screen canvas it's parent.  Then, it ads the UI to the screen.

        public static void ShowGUI()
        {
            if (CoolUICanvas != null)  //if the UI is already showing, don't show another one.
                return;

            //Load up the UI and show it
            CoolUICanvas = (GameObject)Instantiate(CoolUILoader.PanelPrefab);
            CoolUICanvas.transform.SetParent(MainCanvasUtil.MainCanvas.transform);
            CoolUICanvas.AddComponent<CoolUI>();

The next 4 lines in the ShowGUI are setting some information we need.  The first one locates the text object we put on the UI and assigns it a variable, CoolUIText.   The next 3 lines create yet another callback.  This callback will fire any time the user clicks on the main UI toggle button (the check box). You'll see the words AddListener and then delegate.  Basically, any time the checkbox is clicked on, it will fire off the function OnToggleClicked and pass a variable,  isOn.  This isOn variable is read from the Unity prefab and tells us if the checkbox is checked or not.

            //Find the game objects that we gave cool names to in Unity
            CoolUIText = (GameObject)GameObject.Find("ImportantText");

            //This is a toggle so we need to create a callback for when it gets clicked on
            GameObject checkToggle = (GameObject)GameObject.Find("CheckThisOUt");
            Toggle toggleButton = checkToggle.GetComponent<Toggle>();
            toggleButton.onValueChanged.AddListener(delegate { OnToggleClicked(toggleButton.isOn); });
        }

One thing to take note of in these lines of code:  We're telling Unity to find certain game objects.  The name you pass to this has to match the name you gave them in Unity. For example, if you remember, we renamed our text object “ImportantText” in the Unity prefab.  Here, we're telling the code to find that object by name and assign it to CoolUIText.

If we look at the next few lines of code, the OnToggleClicked function, it starts to make sense.  If the checkbox in the UI is checked (ison=true) we send a screen message.  Otherwise, we send a different one.

        //this is the callback we created for when the toggle button is clicked.
        static void OnToggleClicked(bool ison)
        {
            if (ison)  //ison is determines if the checkbox is toggled.  In Unity it's in the "Toggle (Script)" as "Is On"
            {
                ScreenMessages.PostScreenMessage("Hey, you turned it on!");
            }
            else
            {
                ScreenMessages.PostScreenMessage("Bummer, you turned it off.");
            }
        }

The next small function allows us to set the text in the UI to whatever we want.  We take the text gameobject, get the text component from it, and change the .text field.

        //this function is where we update the text component on the UI.
        public static void UpdateText(string message)
        {
            CoolUIText.GetComponent<Text>().text = message;
        }

The next 3 functions are all about moving the UI around.  If you were observant, you probably notice that at the beginning of the monobehaviour declaration there were a few extras: IBeginDragHandler, IDragHandler, IendDragHandler.  These are basically telling this monobehaviour that we're going to be doing some UI dragging.  And that's handled in the next 2 functions.

OnBeginDrag sets a couple of vectors to remember where the drag started in relation to the screen and where our UI was located.

In the OnDrag event, two more vectors are calculated to determine where the UI is moving from, where it's moving to and  how far the UI should be moved.  Then, it plops the UI in the new position.  While this at first seems like an odd way to do it, that it's constantly picking up, moving and dropping the UI.  But, it happens so fast that it appears extremely smooth.

There are other drag events that you can tie into.  I included the OnEndDrag event with just a comment and give you something to think about.  If the drag event is ended, the UI position will be at the CoolUICanvas.transform.position.  The code I have here creates the new UI and essentially plops it down screen center every single time it's opened.  Could you possibly use the transform's position and the OnEndDrag event to save it's position and then, when the ShowGUI event is run again and the UI is created, it's restored it to the position where it was last dropped?

        //this event fires when a drag event begins
        public void OnBeginDrag(PointerEventData data)
        {
            dragstart = new Vector2(data.position.x - Screen.width / 2, data.position.y - Screen.height / 2);
            altstart = CoolUICanvas.transform.position;
        }

        //this event fires while we're dragging. It's constantly moving the UI to a new position
        public void OnDrag(PointerEventData data)
        {
            Vector2 dpos = new Vector2(data.position.x - Screen.width / 2, data.position.y - Screen.height / 2);
            Vector2 dragdist = dpos - dragstart;
            CoolUICanvas.transform.position = altstart + dragdist;
        }

        //this event fires when we let go of the mouse and stop dragging
        public void OnEndDrag(PointerEventData data)
        {
            //humm... this would be a good place to record the UI position after it moved
        }

Finally, a short function to handle our slider.  This function looks for a slider gameobject on the UI named “YouMoveMe” and gets the slider component.  It then returns the value, which is the position of the slider in a range from 0 to 1.

In Unity, you can change the slider range easily by changing the min and max values.  You can also make it slide right to left, return whole numbers... just a whole range of options.

        //This function grabs the position of the UI slider
        public static float CoolSliderPosition()
        {
            GameObject slider = (GameObject)GameObject.Find("YouMoveMe");
            Slider thisSlider = slider.GetComponent<Slider>();
            return thisSlider.value;
        }
    }
}

As a matter of fact, you can easily create a UI to look pretty much any way you want.  Each component of the Unity UI has the ability to change the way it looks, not only by color but by adding in custom graphics.  If you do decide to play around and add your own graphics, you need to remember to add them to the asset bundle.   Which is another reason I suggested the export folder.  Drag your graphics to the export folder and add them to the asset bundle  (remember, Unity, bottom right, mycoolui).  When you update and build your asset bundle, the Asset Compiler will include those graphics in the bundle and... like magic they get included in your cool UI.

So, if you got all of your UI right and you got the asset bundle created and in the right spot, and you create this plugin and have it run flawlessly, you should see something like this in KSP

1wZfm2m.png

I hope I've done a reasonable job of explaining what I think is the easiest method for creating a Unity UI.  If you have any questions or if I didn't explain something well enough, ask me.   Be sure to @ me or quote me for a faster response.  I'll attempt to give you a reply without using line numbers.   If you see any glaring mistakes in my code. TELL me so I can fix them.   If you have an urge to update, upgrade, refactor, optimize or otherwise 'improve' my code and then tell me that I did such and such when instead I should have.... allow me to give you an old coder's axiom.

“When the only tool you have is a hammer, everything begins to look like a nail.”

I really like my hammer. It's simple. I understand it.  It works.  And it'll beat the crap out of a pogo stick. *snort*

And finally, here's an example of a UI I'm using on a current project.  These are all Unity controls but I've replace the graphics with some very simple ones I created.  But, 5 simple images that took me 15 minutes to create,  it completely changed the look of the UI from the stock controls we're all  familiar with.

I'm looking forward to seeing some of your cool UI's in the future.

 

vcozjio.png

 

Complete PartModule Code

Spoiler

namespace mycoolplugin
{
    public class CoolUIPM : PartModule
    {

        //Create a copule of cool things for the part UI that we can play with
        //a button to toggle the UI on and off
        [KSPField(guiName = "Open UI", guiActiveEditor = true, guiActive = true, isPersistant = false),
        UI_Toggle(controlEnabled = true, disabledText = "Closed", enabledText = "Open", invertButton = false, scene = UI_Scene.All)]
        public bool openUI = false;

        //and a progress bar that we can play with
        [KSPField(guiName = "Cool Slider", guiActiveEditor = true, guiActive = true, isPersistant = true),
        UI_ProgressBar(minValue = 0f, maxValue = 1f, scene = UI_Scene.All)]
        public float PartUICoolSlider = 0.0f;

        public override void OnStart(StartState state)
        {
            //This creates a callback so that whenver the UI_Toggle openUI is clicked, it either opens or closes the main UI
            Fields[nameof(openUI)].uiControlEditor.onFieldChanged = delegate (BaseField a, System.Object b)
                {
                    if (CoolUI.CoolUICanvas == null)
                    {
                        CoolUI.ShowGUI(); //if the UI doesn't exist, create one and show it.
                    }
                    else
                    {
                        CoolUI.Destroy(); //if it does exist. they're closing it so get rid of it.
                    }
                };
        }

        public void FixedUpdate()
        {
            //This checks to see if the UI is being shown.  If so, it will update any other CoolUIPm partmodules so that they show the button as being clicked.
            if (CoolUI.CoolUICanvas != null)
            { openUI = true; }
            else
            { return; }  //if not, we don't want to call UI functions because they'll create a null ref.

            //This is going to take the partmodule UI_ProgressBar and make it the same as the UI slider.
            PartUICoolSlider = CoolUI.CoolSliderPosition();

            //This calls the procedure to update the main UI text give a number representation of the slider position.
            CoolUI.UpdateText(PartUICoolSlider.ToString());
        }
    }
}

 

 

Complete MonoBehaviour code

Spoiler

using System.Collections.Generic;
using System.IO;
using System.Reflection;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace mycoolplugin
{
    [KSPAddon(KSPAddon.Startup.Instantly, true)]
    public class CoolUILoader : MonoBehaviour
    {
        private static GameObject panelPrefab;

        public static GameObject PanelPrefab
        {
            get { return panelPrefab; }
        }

        private void Awake()
        {
            //The way I was doing this which does seem to work.  But DMagic's method makes much more sense.
          	//AssetBundle prefabs = AssetBundle.LoadFromFile(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "mycoolui.ksp"));
          	//DMagic's method without the .ksp file extension
          	AssetBundle prefabs = AssetBundle.LoadFromFile(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "mycoolui.dat"));
          	 panelPrefab = prefabs.LoadAsset("MyCoolUIPanel") as GameObject;
        }
    }

    [KSPAddon(KSPAddon.Startup.EditorAny, true)]
    public class CoolUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
    {
        public static GameObject CoolUICanvas = null;
        public static GameObject CoolUIText = null;
        public static List<CoolUIPM> CoolUIParts = new List<CoolUIPM>();
        private static Vector2 dragstart;
        private static Vector2 altstart;

        private void Awake()
        {
            //this creates a callback so that whenever the scene is changed we can destroy the UI
            GameEvents.onGameSceneSwitchRequested.Add(OnSceneChange);
        }

        //If we don't get rid of the UI, it'll stay where it is indefinitely.  So, every time the scene is changed, we need to get rid of it.
        void OnSceneChange(GameEvents.FromToAction<GameScenes, GameScenes> fromToScenes)
        {
            if (CoolUICanvas != null)
            {
                Destroy();
            }
        }

        //This actually destroys the UI.  But it also goes through the partmodule's toggle buttons and turns them all off.
        public static void Destroy()
        {
            CoolUICanvas.DestroyGameObject();
            CoolUICanvas = null;
            foreach (CoolUIPM thisPart in CoolUIParts)
            {
                thisPart.openUI = false;
            }
        }

        public static void ShowGUI()
        {
            if (CoolUICanvas != null)  //if the UI is already showing, don't show another one.
                return;

            //Load up the UI and show it
            CoolUICanvas = (GameObject)Instantiate(CoolUILoader.PanelPrefab);
            CoolUICanvas.transform.SetParent(MainCanvasUtil.MainCanvas.transform);
            CoolUICanvas.AddComponent<CoolUI>();

            //Find the game objects that we gave cool names to in Unity
            CoolUIText = (GameObject)GameObject.Find("ImportantText");

            //This is a toggle so we need to create a callback for when it gets clicked on
            GameObject checkToggle = (GameObject)GameObject.Find("CheckThisOUt");
            Toggle toggleButton = checkToggle.GetComponent<Toggle>();
            toggleButton.onValueChanged.AddListener(delegate { OnToggleClicked(toggleButton.isOn); });
        }

        //this is the callback we created for when the toggle button is clicked.
        static void OnToggleClicked(bool ison)
        {
            if (ison)  //ison is determines if the checkbox is toggled.  In Unity it's in the "Toggle (Script)" as "Is On"
            {
                ScreenMessages.PostScreenMessage("Hey, you turned it on!");
            }
            else
            {
                ScreenMessages.PostScreenMessage("Bummer, you turned it off.");
            }
        }

        //this function is where we update the text component on the UI.
        public static void UpdateText(string message)
        {
            CoolUIText.GetComponent<Text>().text = message;
        }

        //this event fires when a drag event begins
        public void OnBeginDrag(PointerEventData data)
        {
            dragstart = new Vector2(data.position.x - Screen.width / 2, data.position.y - Screen.height / 2);
            altstart = CoolUICanvas.transform.position;
        }

        //this event fires while we're dragging. It's constantly moving the UI to a new position
        public void OnDrag(PointerEventData data)
        {
            Vector2 dpos = new Vector2(data.position.x - Screen.width / 2, data.position.y - Screen.height / 2);
            Vector2 dragdist = dpos - dragstart;
            CoolUICanvas.transform.position = altstart + dragdist;
        }

        //this event fires when we let go of the mouse and stop dragging
        public void OnEndDrag(PointerEventData data)
        {
            //humm... this would be a good place to record the UI position after it moved
        }

        //This function grabs the position of the UI slider
        public static float CoolSliderPosition()
        {
            GameObject slider = (GameObject)GameObject.Find("YouMoveMe");
            Slider thisSlider = slider.GetComponent<Slider>();
            return thisSlider.value;
        }
    }
}

 

 

Spoiler.  How to save and load your UI's position so that it always reappears where the user put it. 

Spoiler

Important: On dual monitor setups it's entirely possible to drag the UI off the screen. I know I've done this. Unity sees BOTH monitors as it's playground even if KSP is full screen.  This does have the hazard that the user could potentially drag the UI off the screen and it's location get saved there.  It can be reset by editing the config file this creates and setting both the x and y values back to 0.

To save your UI location and have it reappear at the same place you'll need to add a bit of code:

Add this bit of code to the CoolUI MonoBehaviour


        //These two functions basically load and save data to a file in your plugin directory.  They use ConfigNode which is the same
        //method KSP uses for loading and reading .cfg files.  We change the extension to something other than .cfg so that it 
        //doesn't get loaded when KSP loads all the other .cfg files.
        //If the save file doesn't exit, it will crete one for you.  Their pupose is to save and load 2 values, the x and y location of
        //your CoolUICanvas
        static ConfigNode GetConfig()
        {
            string filePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "CoolUILocation.dat");
            ConfigNode result = ConfigNode.Load(filePath).GetNode("CoolUI");
            return result;
        }

        private static void SetConfig(float x, float y)
        {
            string filePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "CoolUILocation.dat");
            ConfigNode thiscfg = new ConfigNode();
            var thisnode = thiscfg.AddNode("CoolUI");
            thisnode.AddValue("x", x);
            thisnode.AddValue("y", y);
            thiscfg.Save(filePath);
        }

Next, add this to code into the ShowGUI function in the CoolUI MonoBehaviour


            //This chunk of code creates a new ConfigNode and then loads it up with the data from the config file by
            //calling GetConfig.  It then sets the UI position to the one that we saved in OnEndDrag
            ConfigNode node = new ConfigNode();
            var thisnode = GetConfig();
            float xpos = float.Parse(thisnode.GetValue("x"));
            float ypos = float.Parse(thisnode.GetValue("y"));
            CoolUICanvas.transform.position = new Vector3(xpos, ypos, CoolUICanvas.transform.position.z);

And finally, replace the OnEndDrag function in the CoolUI MonoBehaviour with this:


        //This bit of code calls the SetConfig function when the user ends the dragging.
        //It stores the x and y locations of the UI into a config file.
        public void OnEndDrag(PointerEventData data)
        {
            SetConfig(CoolUICanvas.transform.position.x, CoolUICanvas.transform.position.y);
        }

 

 

 

Edited by Fengist
Link to comment
Share on other sites

Nice write-up. It is a lot simpler when you stick to just one plugin and avoid the whole moving information back-and-forth problem.

A few things I noticed:

52 minutes ago, Fengist said:

For example, if you change an item in the UI you'll need to delete the old panel prefab, drag the panel back down to the export folder to create a new prefab, set the asset bundle name and then, in the asset compiler, click update then build.

You don't actually have to delete the prefab file every time you want to change it. When you drag an object from the scene hierarchy menu to the file menu to create a prefab you'll notice that the item in the scene hierarchy turns blue. This means that the item in the scene is tied to the prefab file on disk. When you select the item in the scene hierarchy menu you'll get an additional row at the top of the Inspector tab:

2ByEuxF.png

Here this is a prefab for a KSPedia page, but the idea is the same for UI elements, parts, or anything else you can make in Unity. For KSPedia specifically deleting the prefabs each time you make changes is a real pain (I know because I also used to do that) because it resets the KSPedia entry setup in Unity.

When you make a change to the object that you want to apply to the prefab, for instance, you add a new toggle to your UI panel, you just select the object in the hierarchy menu (or select any direct child of the object) and click the Apply button at the top of the Inspector tab. That will update the prefab file on disk. Then you can go to the Asset Compiler tab, update the bundle and build again. The Select button will select the file in the folder menu, and Revert will remove any changes since the last time you applied update to the prefab file.

Sometimes you'll notice that Unity warns you about breaking the prefab link, for instance when you delete a child object, or remove a component from an object. When that happens the item in the scene hierarchy menu will turn black again, indicating that it is no longer in sync with the prefab. You can still click the Apply button in the Inspector tab and it will update the prefab file on disk and turn the item blue again.

 

52 minutes ago, Fengist said:

private void Awake() { AssetBundle prefabs = AssetBundle.LoadFromFile(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "mycoolui.ksp")); panelPrefab = prefabs.LoadAsset("MyCoolUIPanel") as GameObject; }

The KSP Part Tools Asset Compiler adds that .ksp file extension onto the end of all Asset Bundles it builds, but there is no inherent reason for it. KSP uses that extension to load all asset bundles during loading (for KSPedia pages and I think some expansion stuff).

But if you are going to load the bundle manually you should either change or remove that extension. Sometimes you might have a reason to wait until after KSP loads to load your UI, in that case you will run into problems.

If KSP gets to it first, then KSP will load the bundle into memory, then when you try to load the bundle again through your own code it will complain that an asset bundle with that name has already been loaded and it will fail. If you load it first then the worst that will happen is probably a log entry during loading.

But there is no real reason to leave the .ksp file extension. I always either add my own, or just leave off the file extension entirely.

Link to comment
Share on other sites

29 minutes ago, DMagic said:

Nice write-up. It is a lot simpler when you stick to just one plugin and avoid the whole moving information back-and-forth problem.

Thanks, coming from the resident master of the UI and me being a bumbling hack, I take that as a compliment.  I'm sorry I couldn't follow your tutorial, I did try at least 3 times and got lost all 3 of them.

29 minutes ago, DMagic said:

You don't actually have to delete the prefab file every time you want to change it. When you drag an object from the scene hierarchy menu to the file menu to create a prefab you'll notice that the item in the scene hierarchy turns blue. This means that the item in the scene is tied to the prefab file on disk. When you select the item in the scene hierarchy menu you'll get an additional row at the top of the Inspector tab:

Sometimes you'll notice that Unity warns you about breaking the prefab link, for instance when you delete a child object, or remove a component from an object. When that happens the item in the scene hierarchy menu will turn black again, indicating that it is no longer in sync with the prefab. You can still click the Apply button in the Inspector tab and it will update the prefab file on disk and turn the item blue again.

The reason I've been doing that is because I had an issue where one of my UI's, everything but the canvas vanished.  Once I restored from a backup, I got in the habit of dragging down a new copy of the prefab, deleting the old one and renaming the new one.  Now that you've explained a bit more about that blue color I've seen and that apply button I've also seen, I'll try to adjust my habits.

 

29 minutes ago, DMagic said:

The KSP Part Tools Asset Compiler adds that .ksp file extension onto the end of all Asset Bundles it builds, but there is no inherent reason for it. KSP uses that extension to load all asset bundles during loading (for KSPedia pages and I think some expansion stuff).

But if you are going to load the bundle manually you should either change or remove that extension. Sometimes you might have a reason to wait until after KSP loads to load your UI, in that case you will run into problems.

If KSP gets to it first, then KSP will load the bundle into memory, then when you try to load the bundle again through your own code it will complain that an asset bundle with that name has already been loaded and it will fail. If you load it first then the worst that will happen is probably a log entry during loading.

But there is no real reason to leave the .ksp file extension. I always either add my own, or just leave off the file extension entirely.

I know there are certain files that KSP loads. like .cfg files. I wasn't aware that it loaded the .ksp files automatically.  This method that I've stumbled into is more or less a collection bits and pieces from dozens of examples, either from the Unity docs or someone else's code.  I'm guessing that somewhere I found that code to load the prefab and that it was with the .ksp extension and loaded in the Startup.Instantly.  Thus far, and I've been checking the log frequently, I haven't had KSP hiccup or belch and it loads every time.   I know with many file types I'm loading that the extension isn't needed.  I'll start lopping it off in my code. The only odd thing I've noticed is the first time that I open the UI, it takes 2-3 second for it to appear.  After that, it loads almost instantly.

I'll make some notes in the tutorial to point to your comments.

Thanks D!

Edited by Fengist
Link to comment
Share on other sites

23 minutes ago, Fengist said:

I'm sorry I couldn't follow your tutorial, I did try at least 3 times and got lost all 3 of them.

That tutorial is in need of updating, or really just replacing with a purpose-built project, this and the several very long PM chains I've had trying to help people through the UI process have convinced me of that. It used to sort-of be in sync with the mod Basic Orbit, but the UI for that has changed fairly significantly, so the tutorial is really incomplete now.

23 minutes ago, Fengist said:

The reason I've been doing that is because I had an issue where one of my UI's, everything but the canvas vanished.

I pretty paranoid about saving the scene data. Whenever I make any changes I always try to save the scene. Even if all of the prefabs vanish, or the project gets screwed up somehow, as long as the scene file still exists somewhere you should still be able to load the scene and have everything where you left off. But yeah, saving everything you can and backing everything up is a always a good idea.

23 minutes ago, Fengist said:

I know there are certain files that KSP loads. like .cfg files. I wasn't aware that it loaded the .ksp files automatically.

It's KSPedia files mostly that use that extension (deleting all or most of those .ksp files is a great way to speed up the loading process). I assume that you can let KSP load the asset bundle and still be able to use it just. But I've never bothered figuring out how. Loading the asset bundle yourself is so simple, and you can do it whenever you want to, so it's easier to just change the extension and never have to worry about it.

Edited by DMagic
Link to comment
Share on other sites

3 minutes ago, DMagic said:

It's KSPedia files mostly that use that extension (deleting all or most of those .ksp files is a great way to speed up the loading process). I assume that you can let KSP load the asset bundle and still be able to use it just. But I've never bothered figuring out how. Loading the asset bundle yourself is so simple, and you can do it whenever you want to, so it's easier to just change the extension and never have to worry about it.

Agreed!  I do that for some of the files I use to save data with ConfigNodes so they don't get loaded as a .cfg file.  It just never dawned on me to do the same for a .ksp file. Code in the tutorial changed and notes added. Thanks for the lesson D!  Much appreciated.

Link to comment
Share on other sites

This thread is quite old. Please consider starting a new thread rather than reviving this one.

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...