Unity Tutorial: Forschungsbaum-Editor (3/3)

Dies ist der dritte Teil des Forschungsbaum-Editor Tutorials. Der vollständige Quellcode des Tutorials kann hier heruntergeladen werden:

In den vorhergehenden Tutorials wurden die Grundlagen des Forschungsbaum-Editors gelegt. Im Folgenden werden nun Funktionen zum Anlegen neuer und löschen existierender Forschungsknoten, sowie zum Verknüpfen von Forschungsknoten implementiert. Zuletzt sollen nun auch die Positionen der Fenster persistent gespeichert und geladen werden können.

Neue Forschungsknoten einfügen

Das Anlegen neuer Forschungsknoten lässt sich mit der bereits implementierten Lösung einach umsetzen. Zunächst muss die OnGUI-Methode um einen Button in der oberen Menüleiste erweitert werden. Dieser ruft die Methode auf, die den neuen Forschungsknoten erzeugt.

/// <summary>
/// This Method is called whenever the UI is updated.
/// </summary>
void OnGUI()
{
    EditorGUILayout.BeginHorizontal();
    {
        [...]

        //Define NewResearchNode-Button
        if (GUILayout.Button(new GUIContent("New Research Node"), GUILayout.Width(150.0f)))
        {
            AddNewResearchNode();
        }
    }
    EditorGUILayout.EndHorizontal();

    [...]
}

Die AddNewResearchNode-Methode erzeugt den eigentlichen neuen, leeren Forschungsknoten. Dieses erscheint nach Ausführung in der linken oberen Ecke des Editors und kann anschließend an eine beliebige andere Stelle verschoben werden. Da jeder Forschungsknoten eine eigene, eindeutige ID benötigt, lohnt es sich an dieser Stelle, eine weitere Methode zu implementieren, die die nächste freie ID sucht und zurückgibt. Dies ist umso nützlicher, da später auch Knoten gelöscht werden können, wodurch Lücken in den durchlaufenden IDs erzeugt werden, die dadurch mit neuen Knoten geschlossen werden können.

/// <summary>
/// Adds a new empty Research Node.
/// </summary>
private void AddNewResearchNode()
{
    researchNodes.Add(new ResearchNode() { researchNodeID = GetNextAvailableFreeResearchNodeID(), researchNodeName = "", researchNodeCost = 0 });

    windowPositionAndSizes.Add(new Rect(new Vector2(100.0f, 100.0f), new Vector2(150.0f, 200.0f)));
}

/// <summary>
/// Returns the next unused Research Node ID.
/// Trys to fill Gaps in the continuous Node IDs, if any Nodes have been deleted previously.
/// </summary>
/// <returns>A Research Node ID.</returns>
private int GetNextAvailableFreeResearchNodeID()
{
    //Look for a free Research Node ID
    for (int i = 0; i < int.MaxValue; i++)
    {
        if (researchNodes.Any(x => i == x.researchNodeID) == false)
        {
            return i;
        }
    }

    //Will never occur, but to be safe
    throw new Exception("Unable to find any free ID for new ResearchNode!");
}

Forschungsknoten löschen

Das Löschen von Forschungsknoten ist ähnlich einfach wie das Anlegen neuer Knoten. Um die Methode zum Löschen eines Knotens ausführen zu können, benötigt jeder Knoten einen Löschen-Button, der direkt in der DrawResearchNodeWindow-Methode hinzugefügt wird.

/// <summary>
/// This Method defines UI-Elements within the Window.
/// </summary>
/// <param name="id">The Windows unique ID, which equals the ResearchNodes ID.</param>
private void DrawResearchNodeWindow(int id)
{
    //Get Array Index by ResearchNode ID
    int index = researchNodes.FindIndex(x => x.researchNodeID == id);

    //Define all UI-Elements for a single Research Node, stacked vertically
    GUILayout.BeginVertical();
    {
        //Button to delete the Research Node
        if (GUILayout.Button(new GUIContent("X"), GUILayout.Width(20.0f), GUILayout.Height(20.0f)))
        {
            DeleteResearchNode(id);
            return; //Return here to prevent remaining Code to cause Exceptions
        }

        [...]
    }
    GUILayout.EndVertical();

    //Set the Window as dragable
    GUI.DragWindow();
}

Die DeleteResearchNode-Methode wird vom oben implementierten Button aufgerufen und löscht den Forschungsknoten mittels seiner eindeutigen ID. Wichtig ist hierbei, sowohl den Forschungsknoten, als auch seine Window-Position zu entfernen. Weiterhin müssen anschließend alle noch existierenden Forschungsknoten untersucht werden, ob diese eine Verknüpfung auf den gelöschten Knoten besitzen. Ist dies der Fall, muss die Verknüpfung entfernt werden.

/// <summary>
/// Deletes the Research Node with the provided Research Node ID.
/// </summary>
/// <param name="id">The Research Nodes ID.</param>
private void DeleteResearchNode(int id)
{
    //Try to find the Research Node to delete
    int index = researchNodes.IndexOf(researchNodes.FirstOrDefault(x => id == x.researchNodeID));
    
    //Only proceed, if the Node was found
    if (index >= 0)
    {
        //Save the Node for now
        ResearchNode researchNode = researchNodes[index];

        //Remove Node and Window-Layout-Information
        researchNodes.RemoveAt(index);
        windowPositionAndSizes.RemoveAt(index);

        //Remove any Links from other Research Nodes
        for (int i = 0; i < researchNodes.Count; i++)
        {
            researchNodes[i].researchNodeUnlockedResearchNodes.Remove(researchNode);
        }

        //Done
        return;
    }

    //Will never occur, but to be safe
    throw new Exception($"Unable to delete ResearchNode with ID {id}!");
}

Verknüpfungen anlegen und löschen

Das Anlegen einer Verknüpfung zwischen zwei Forschungsknoten ist ein 2-stufiger Prozess. Zunächst muss der Forschungsknoten vom Nutzer selektiert werden, der als Eltern-Knoten dienen soll, und anschließend der Zielknoten zugewiesen werden. Dies soll folgendermaßen umgesetzt werden:

  • Jeder Forschungsknoten erhält einen Button, mit dem der Knoten als Eltern-Knoten festgelegt wird. Dies startet den 2-stufigen Prozess. Nach einem Klick sollen diese Buttons in allen Forschungsknoten ausgeblendet werden.
  • Stattdessen wird auf allen Forschungsknoten (ausgenommen dem Eltern-Knoten) ein weiterer Button eingeblendet. Wird dieser geklickt, werden die beiden Forschungsknoten verknüpft.
  • Bereits existierende Verknüpfungen sollen ignoriert werden.

Im gleichen Anlauf soll es auch möglich sein, Verknüpfungen zu löschen. Hierfür soll ein weiterer Button für jede Verknüpfung in den Forschungsknoten-Fenstern erzeugt werden. Wird dieser geklickt, wird die entsprechende Verknüpfung zu einem anderen Knoten entfernt.

Um den 2-stufigen Prozess zur Erzeugung neuer Verknüpfungen umsetzen zu können, benötigen wir zunächst eine neue Variable, in der der Eltern-Knoten gespeichert wird, sobald der Nutzer den Button zur Erzeugung einer neuen Verknüpfung drückt.

/// <summary>
/// When setting a new Relation between two Research Nodes, the Source Node is saved here.
/// </summary>
private static ResearchNode linkResearchNodeSource = null;

Anschließend wird die Visualisierung der Forschungsknoten-Fenster erweitert. Jetzt sollen alle bereits aus unseren Testdaten existierenden Verknüpfungen auch in den Fenstern tabellarisch aufgelistet werden. Neben jedem Eintrag wird ein Button erzeugt, welche eine UnlinkTwoResearchNodes-Methode aufruft, die die Verknüpfung löscht. Zuletzt soll unter der Auflistung der existierenden Verknüpfungen der Button zur Erzeugung einer neuen Verknüpfung visualisiert werden – aber nur dann, wenn der 2-stufige Prozess noch nicht gestartet wurde. Wurde der Prozess gestartet, so ist die linkResearchNodeSource-Variable gesetzt und ein anderer Button zum finalen Erzeugen der Verknüpfung wird angezeigt.

/// <summary>
/// This Method defines UI-Elements within the Window.
/// </summary>
/// <param name="id">The Windows unique ID, which equals the ResearchNodes ID.</param>
private void DrawResearchNodeWindow(int id)
{
    //Get Array Index by ResearchNode ID
    int index = researchNodes.FindIndex(x => x.researchNodeID == id);

    //Define all UI-Elements for a single Research Node, stacked vertically
    GUILayout.BeginVertical();
    {
        [...]
		
        GUILayout.Space(10);

        //Nodes related ResearchNodes
        GUILayout.Label("Related Research Nodes:", GUILayout.Width(220));
        for (int i = 0; i < researchNodes[index].researchNodeUnlockedResearchNodes.Count; i++)
        {
            GUILayout.BeginHorizontal();
            {
                //ResearchNode Name Label
                GUILayout.Label(researchNodes[index].researchNodeUnlockedResearchNodes[i].researchNodeName, GUILayout.Width(200));

                //Button to delete Relation
                if (GUILayout.Button("X", GUILayout.Width(20.0f), GUILayout.Height(20.0f)))
                {
                    UnlinkTwoResearchNodes(researchNodes[index], researchNodes[index].researchNodeUnlockedResearchNodes[i]);
                    return; //Return here to prevent remaining Code to cause Exceptions
                }
            }
            GUILayout.EndHorizontal();
        }

        //Button to add new Relation
        if (linkResearchNodeSource == null)
        {
            if (GUILayout.Button("Add new related Research Node", GUILayout.Width(220.0f), GUILayout.Height(20.0f)))
            {
                //Start Linking Process by saving the Source Node
                linkResearchNodeSource = researchNodes[index];
                return; //Return here to prevent remaining Code to cause Exceptions
            }
        }

        //Button to finish the new Relation Process. Only show, if the Linking Process has been started and this is not the Source Node
        if (linkResearchNodeSource != null && linkResearchNodeSource != researchNodes[index])
        {
            if (GUILayout.Button("Link to this Node", GUILayout.Width(220.0f), GUILayout.Height(20.0f)))
            {
                //Add Link
                LinkTwoResearchNodes(linkResearchNodeSource, researchNodes[index]);

                //Reset Process
                linkResearchNodeSource = null;
                return; //Return here to prevent remaining Code to cause Exceptions
            }
        }
    }
    GUILayout.EndVertical();

    //Set the Window as dragable
    GUI.DragWindow();
}

Zum Zwecke der Übersichtlichkeit und der Reduktion von redundantem Code kann die Logik zum Erzeugen und Löschen einer Verknüpfung in eigene Methoden ausgegliedert werden.

/// <summary>
/// Links two Research Nodes.
/// </summary>
/// <param name="source">The Source Research Node.</param>
/// <param name="destination">The Destination Research Node.</param>
private void LinkTwoResearchNodes(ResearchNode source, ResearchNode destination)
{
    //Don't add the Relation twice
    if (source.researchNodeUnlockedResearchNodes.Contains(destination) == false)
    {
        source.researchNodeUnlockedResearchNodes.Add(destination);
    }
}

/// <summary>
/// Unlinks two Research Nodes.
/// </summary>
/// <param name="source">The Source Research Node.</param>
/// <param name="destination">The Destination Research Node.</param>
private void UnlinkTwoResearchNodes(ResearchNode source, ResearchNode destination)
{
    //Only unlink, if available
    if (source.researchNodeUnlockedResearchNodes.Contains(destination))
    {
        source.researchNodeUnlockedResearchNodes.Remove(destination);
    }
}

Persistente Fenster-Positionen

Bislang können wir unseren Forschungsbaum problemlos speichern, doch werden die Positionen der Fenster bei jedem Start des Editor-Tools und beim Laden eines Forschungsbaumes zurückgesetzt. Es macht also Sinn, diese ebenfalls persistent zu speichern. Da es sich jedoch um Editor-spezifische Informationen handelt, die wir im Spiel später nicht benötigen, sollen diese Informationen in eine eigene Datei ausgelagert werden. Auch hier soll die Datenhaltung als JSON-Datei abgebildet werden. Wir fügen also zwei neue Methoden hinzu, über die die Fensterpositionen gespeichert und geladen werden können.

/// <summary>
/// Loads the Window Positions from a serialized JSON-Container.
/// </summary>
/// <param name="fileName">The FileName (including Path and Ending) to load the Window Positions from.</param>
private void LoadWindowPositions(string fileName)
{
    JsonParser<List<Tuple<float, float>>> jsonParser = new JsonParser<List<Tuple<float, float>>>();
    windowPositionAndSizes = jsonParser.LoadData($"{Path.GetFileNameWithoutExtension(fileName)}_pos.json").Select(x => new Rect(x.Item1, x.Item2, 150.0f, 250.0f)).ToList();
}

/// <summary>
/// Saves the Window Positions as JSON using the provided FileName.
/// </summary>
/// <param name="fileName">The FileName (including Path and Ending) to save the Window Positions at.</param>
private void SaveWindowPositions(string fileName)
{
    List<Tuple<float, float>> windowPositions = windowPositionAndSizes.Select(x => new Tuple<float, float>(x.x, x.y)).ToList();

    JsonParser<List<Tuple<float, float>>> jsonParser = new JsonParser<List<Tuple<float, float>>>();
    jsonParser.SaveData(windowPositions, $"{Path.GetFileNameWithoutExtension(fileName)}_pos.json");
}

Anschließend müssen die bereits existierenden Buttons zum Speichern und Laden des Forschungsbaumes erweitert werden. Diese müssen die neuen Methoden einfach aufrufen. Es lohnt sich jedoch, zunächst nur den Code zum Speichern der Fensterpositionen zu implementieren und den Editor anschließend einmal zu starten. Nun kann der Foschungsbaum (noch ohne persistente Fensterpositionen) geladen und einmal gespeichert werden, sodass die neue JSON-Datei erzeugt wird. Anschließend sollte erst der Laden-Code implementiert und aufgerufen werden. Würde man sowohl den Speichern-, als auch den Laden-Code zeitgleich implementieren und einen zuvor bereits designten Forschungsbaum zu laden versuchen, würde dies fehlschlagen, da die Fensterpositions-Datei nicht exitiert. Auf diese Weise kann man die gespeicherten Daten einfach und unkompliziert “migrieren”.

/// <summary>
/// This Method is called whenever the UI is updated.
/// </summary>
void OnGUI()
{
    EditorGUILayout.BeginHorizontal();
    {
        //Define Load-Button
        if (GUILayout.Button(new GUIContent("Load"), GUILayout.Width(150.0f)))
        {
            //Get the saved File
            string file = EditorUtility.OpenFilePanel("Load Techtree", "", "json");

            //Load Techtree from JSON
            if (!string.IsNullOrEmpty(file))
            {
                LoadResearchNodes(file);
                LoadWindowPositions(file);
            }
        }

        //Define Save-Button
        if (GUILayout.Button(new GUIContent("Save"), GUILayout.Width(150.0f)))
        {
            //Set the File to save
            string file = EditorUtility.SaveFilePanel("Save Techtree", "", "Techtree", "json");

            //Save Techtree as JSON
            if (!string.IsNullOrEmpty(file))
            {
                SaveResearchNodes(file);
                SaveWindowPositions(file);
            }
        }

        //Define NewResearchNode-Button
        if (GUILayout.Button(new GUIContent("New Research Node"), GUILayout.Width(150.0f)))
        {
            AddNewResearchNode();
        }
    }
    EditorGUILayout.EndHorizontal();

	[...]
}

Quellcode

Dies beendet das 3-teilige Tutorial zum Implementieren eines Forschungsbaumes als Editor-Tool für Unity. Im Folgenden kann der Quellcode heruntergeladen werden. Viel Spaß und viel Erfolg beim Testen, Ausprobieren und Erweitern!

Tagged , , .Speichere in deinen Favoriten diesen permalink.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.