Unity Tutorial: Forschungsbaum-Editor (1/3)

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

Forschungsbäume sind aus heutigen PC- und Konsolen-Spielen kaum mehr wegzudenken. Diese reichen von recht linearen Bäumen mit einfachen Fähigkeitsboni bis zu weit verzweigten Bäumen, die bei jedem erforschten Knoten neue und einmalige Fähigkeiten oder Technologien freischalten. Oftmals sind die Bäume auf einer zoom- und verschiebbaren Ebene dargestellt, wobei Abhängigkeiten zwischen den Knoten in Form von Linien dargestellt werden. Für die Spieler ist diese Form der Darstellung absoluter Standard. Leider haben Entwickler während der Entwicklungsphase eines Spiels nicht immer das Glück, den Forschungsbaum mit einem graphischen Tool bearbeiten zu können.

Dabei ist das Erstellen der Inhalte eines Forschungsbaums ohne grafische Oberfläche sehr herausfordernd. Man könnte die Forschungsknoten auch händisch in eine JSON-Datei schreiben, aber spätestens beim mittelgroßen Forschungsbäumen mit vielen Verknüpfungen zwischen den Knoten wird es sehr schnell unübersichtlich, was sich negativ auf die Entwicklungszeit und die Qualität der abgelieferten Arbeit auswirkt. Warum also nicht ein Tool direkt in Unity entwickeln, mit dem sich ein Forschungsbaum visualisieren und bearbeiten lässt?

Dieses mehrteilige Tutorial erläutert die Implementierung eines Editor-Fensters in Unity 2020.2, der gezeigte Code ist jedoch auch mit älteren Unity-Versionen kompatibel. Die Erstellung von Forschungsbäumen ist hierbei nur ein Beispiel vieler Anwendungsmöglichkeiten, z.B. lässt sich der Editor auch sehr einfach als Grundlage für einen Dialog-Editor verwenden.

Zunächst soll definiert werden, was wir von einem Forschungsbaum-Editor erwarten:

  • Alle Forschungsknoten (die einzelnen Technologien im Forschungsbaum) sollen als verschiebbare Fenster im Editor-Fenster angezeigt werden.
  • Die Forschungsknoten sollen editierbar sein (Name, Beschreibung, Forschungskosten, …).
  • Neue Forschungsknoten sollen angelegt und vorhandene gelöscht werden können.
  • Forschungsknoten sollen miteinander verknüpfbar sein, um Abhängigkeiten zwischen den Knoten zu definieren. Beispielsweise soll der Forschungsknoten “Kochen” erst erforscht werden können, wenn “Feuer” bereits erforscht wurde. Es sollen mehrere Verknüpfungen pro Forschungsknoten (eingehend wie ausgehend) anlegbar und Verknüpfungen auch wieder löschbar sein. Die Verknüpfungen sollen visuell klar erkennbar sein.
  • Der Forschungsbaum soll persistent als JSON-Datei speicher- und ladbar sein.
  • Das Editor-Fenster soll zusätzliche quality of life Features beinhalten, z.B. ein Zoom, ein Grid-Raster im Hintergrund und eine Snap-to-Grid Funktion beim Verschieben von Fenstern.

Projekt anlegen

Zunächst wird ein neues, leeres Projekt angelegt, in dem der Forschungsbaum-Editor implementiert wird. Natürlich kann der Code auch direkt in existierende Projekte eingepflegt werden, zu Demonstrations- und Übungszwecken ist eine leere Leinwand jedoch besser geeignet. Das zur Projektanlage verwendete Template kann beliebig gewählt werden.

Nachdem das Projekt angelegt und die Unity-Oberfläche geladen ist, müssen einige Ordnerstrukturen und die .cs-Datei erstellt werden, die wir für die Implementierung benötigen. Stellt sicher, dass die auf der Abbildung gezeigte Ordnerstruktur vorliegt (erstellt die Ordner einfach, falls notwendig) und legt im Editor-Verzeichnis eine neue Script-Datei namens “EditorExample” an. Das ist alles, was an Vorbereitungen benötigt wird. Ein Doppelklick auf die neu angelegte Datei öffnet euren bevorzugen Codeeditor.

Dem Editor-Verzeichnis kommt in Unity eine spezielle Rolle zu. Alle Scripts und Dateien, die dort abgelegt werden, sind nur im Unity-Editor, aber nicht für das Spiel selbst verfügbar. Führt man also einen Build des Spiels aus, sind alle hier abgelegten Dateien nicht im Build enthalten.

Grundlagen

Zuerst soll einfach nur ein leeres Editor-Fenster erzeugt werden. Hierzu muss die EditorExample-Class von EditorWindow erben. In dieser muss eine statische Methode mit einem MenuItem-Attribut definiert werden. Der Namen der Methode kann beliebig gewählt werden, wichtig ist allein das MenuItem-Attribut. Dadurch erkennt Unity, welche Methode beim Aufrufen des Editor-Fensters zur Initialisierung ausgeführt werden soll. Zusätzlich kann ein Pfad definiert werden, unter dem der Editor anschließend im Window-Menu der Unity-Oberfläche gefunden werden kann.

Innerhalb der Methode wird das eigentliche Fenster instanziert. Weiterhin kann diese Methode zum Laden beliebiger Daten verwendet werden. Für den Anfang reicht es, dem Editor-Fenster einen exemplarischen Namen zu geben.

public class EditorExample : EditorWindow
{
    /// <summary>
    /// Adds a Menu Item to the Window Menu.
    /// </summary>
    [MenuItem("Window/My Project/Research Editor Window")]
    static void InitEditorWindow()
    {
        //Show existing Window Instance. If one doesn't exist, make one
        EditorExample editor = EditorWindow.GetWindow<EditorExample>();

        //Give the Editor a nice Caption
        GUIContent titleContent = new GUIContent("Research Editor");
        editor.titleContent = titleContent;
    }
}

Buttons

Um den Editor mit Inhalten zu füllen, müssen UI-Elemente definiert werden. Hierfür stellt Unity die OnGUI-Methode bereit, die ähnlich der Update-Methode der MonoBehaviour-Klasse regelmäßig ausgeführt wird. In dieser kann exemplarisch ein Button definiert werden. Dies geschieht durch Aufruf von GUILayout.Button. Wie dem aufmerksamen Leser aufgefallen sein mag, wird dadurch der Button bei jedem Aufruf von OnGUI neu erstellt. Das ist korrekt und stellt die grundlegende Funktionsweise des Editor-UIs dar, d.h. alle definierten UI-Elemente müssen stetig neu erzeugt werden – ansonsten verschwinden sie beim nächsten Aufruf von OnGUI vom Editor-Fenster.

Die Button-Definition erfolgt in einer If-Abfrage, da GUILayout.Button einen boolschen Wert zurückgibt. Sofern der Button seit dem letzten Aufruf von OnGUI durch den Benutzer geklickt wurde, gibt diese true zurück. Das bedeutet, dass innerhalb der If-Abfrage der bei einem Button-Klick auszuführende Code implementiert werden muss.

Der exemplarische Button kann mit einem Text, sowie einfachen Layout-Informationen versehen werden. Im zweiten Codebeispiel wird hingegen ein Button ohne Text, dafür mit einem Icon angezeigt. Beide Arten von Buttons werden in folgenden Codebeispielen häufig verwendet, es lohnt sich also, ein wenig mit diesen Beispielen zu experimentieren. Das Icon muss im Assets/Resources-Unterverzeichnis abgelegt und als Sprite-Typ im Unity-Editor deklariert werden. Im Codebeispiel befindet sich das Icon im Verzeichnis Assets/Resources/Textures und wurde als ButtonIcon.png hinterlegt.

    /// <summary>
    /// This Method is called whenever the UI is updated.
    /// </summary>
    void OnGUI()
    {
        //Generate a new Button, showing the provided GUIContent as Text
        if (GUILayout.Button(new GUIContent("Add a custom Button Text here"), GUILayout.Width(200), GUILayout.Height(30)))
        {
            //Put Code here, which is executed whenever the Button is clicked
        }
    }
    /// <summary>
    /// An Icon shown on the Button.
    /// </summary>
    private static Texture buttonIcon = ((Sprite)Resources.Load("Textures/ButtonIcon", typeof(Sprite))).texture;

    /// <summary>
    /// This Method is called whenever the UI is updated.
    /// </summary>
    void OnGUI()
    {
        //Generate a new Button, showing the provided GUIContent as Button-Icon and attaches a Tooltip to the Button
        if (GUILayout.Button(new GUIContent(buttonIcon, "Add a custom Tooltip here"), GUILayout.Width(30), GUILayout.Height(30)))
        {
            //Put Code here, which is executed whenever the Button is clicked
        }
    }

Datenstrukturen und Testdaten

Damit der Forschungsbaum-Editor mit Inhalten gefüllt werden kann, muss eine entsprechende Datengrundlage geschaffen werden. Hierfür reicht zunächst ein einfaches Datenmodell, das später beliebig erweitert werden kann. Im Projekt wird eine neue Klasse ResearchNode im Editor-Verzeichnis erstellt, die als Model für einen einzelnen Forschungsknoten verwendet werden soll. Darin werden die eindeutige ID des Knotens, sein Anzeigenamen, seine Forschungskosten, sowie die Nachfolgeknoten, die nach Erforschung des Knotens freigeschaltet werden, gespeichert.

/// <summary>
/// A container, encapsulating Data for a single Research Node.
/// </summary>
public class ResearchNode
{
    /// <summary>
    /// The Nodes unique ID.
    /// </summary>
    public Guid researchNodeID;

    /// <summary>
    /// The Nodes Display Name.
    /// </summary>
    public string researchNodeName;

    /// <summary>
    /// The Amount of Research Points needed to research this Node.
    /// </summary>
    public int researchNodeCost;

    /// <summary>
    /// The Successor Nodes, which depend on this Node.
    /// </summary>
    public List<ResearchNode> researchNodeUnlockedResearchNodes = new List<ResearchNode>();
}

Nachdem das Datenmodell der Forschungsknoten implementiert wurde, können jetzt Testdaten angelegt werden. Hierfür lohnt es sich, eine Methode zur Erzeugung der Testdaten zu definieren, die in der InitEditorWindow-Methode aufgerufen werden kann. Innerhalb der Methode sollten zunächst alte Testdaten aufgeräumt und anschließend neue Testdaten erzeugt werden. Das unten gezeigte Codebeispiel fügt 5 Forschungsknoten mit eindeutiger ID, einem Namen und Forschungskosten hinzu. Anschließend werden Nachfolgeknoten definiert, im konkreten Beispiel wird die Erforschung von “Fire” die Forschungsknoten “Masonry” und “Writing” freischalten, und “Masonry” wiederum die Erfoschung von “Architecture” und “Culture” ermöglichen.

    /// <summary>
    /// Contains all Research Nodes shown in the Editor.
    /// </summary>
    private static List<ResearchNode> researchNodes = new List<ResearchNode>();    

    /// <summary>
    /// Generates some example Research Nodes, which we will use to test the Editor Tools.
    /// </summary>
    private static void GenerateExampleResearchNodes()
    {
        //Reset existing Data
        researchNodes.Clear();

        //Define some Research Nodes with unique IDs, Display Names and Costs
        researchNodes.Add(new ResearchNode() { researchNodeID = 0, researchNodeName = "Fire", researchNodeCost = 10 });
        researchNodes.Add(new ResearchNode() { researchNodeID = 1, researchNodeName = "Masonry", researchNodeCost = 25 });
        researchNodes.Add(new ResearchNode() { researchNodeID = 2, researchNodeName = "Writing", researchNodeCost = 25 });
        researchNodes.Add(new ResearchNode() { researchNodeID = 3, researchNodeName = "Architecture", researchNodeCost = 50 });
        researchNodes.Add(new ResearchNode() { researchNodeID = 4, researchNodeName = "Culture", researchNodeCost = 75 });

        //Link the Research Nodes together
        researchNodes[0].researchNodeUnlockedResearchNodes = new List<ResearchNode> { researchNodes[1], researchNodes[2] };
        researchNodes[1].researchNodeUnlockedResearchNodes = new List<ResearchNode> { researchNodes[3], researchNodes[4] };
    }

Visualisierung der Forschungsknoten

Jetzt wird es Zeit, die Forschungsknoten im Editor-Fenster darzustellen. Das Ziel ist es, für jeden Knoten ein verschieb- und editierbares Fenster innerhalb des Editor-Fenster zu erzeugen. Beginnen wir zunächst mit den Grundlagen. Das unten gezeigte Codebeispiel erzeugt ein einzelnes Fenster, noch ohne konkreten Bezug zu einem Forschungsknoten. Es kann jedoch frei innerhalb des Editor-Fensters per drag-n-drop verschoben werden. Die aus vorherigen Codebeispielen verwendete OnGUI-Methode kann durch den unten gezeigten Code ersetzt werden.

Fenster müssen immer innerhalb eines Bereichs zwischen BeginWindows und EndWindows definiert werden. Mittels GUILayout.Window wird hierbei ein neues Fenster erzeugt. Diesem muss eine eindeutige ID (im Beispiel hardcodiert 0), sowie eine als Rect definierte Position und Größe übergeben werden. Weiterhin wird eine DrawResearchNodeWindow-Methode definiert und übergeben. Innerhalb dieser können ähnlich der OnGUI-Methode UI-Elemente definiert werden, die dann im Fenster angezeigt werden. Exemplarisch enthält dieses Fenster ein Button mit einem Icon, das bei einem Klick eine Konsolenausgabe in Unity erzeugt. Weiterhin muss in der Methode das Fenster mittels GUI.DragWindow als drag-n-drop Fenster deklariert werden.

Damit das Fenster zwischen den Aufrufen von OnGUI allerdings bei einem drag-n-drop seine neue Position übernimmt, muss diese zwischengespeichert werden. Hierfür gibt GUILayout.Window ein Rect-Objekt zurück, das die Position nach dem drag-n-drop darstellt und beim nächsten Aufruf von OnGUI wiederum an GUILayout.Window als Parameter übergeben werden muss.

    /// <summary>
    /// The Windows Position and Size. Has to be saved between OnGUI()-Calls in order to make the Window draggable.
    /// </summary>
    private static Rect windowPositionAndSize = new Rect(new Vector2(100.0f, 100.0f), new Vector2(200.0f, 300.0f));

    /// <summary>
    /// This Method is called whenever the UI is updated.
    /// </summary>
    void OnGUI()
    {
        //Define all your Windows between BeginWindows() and EndWindows(). The { } Brackets are not needed, but make the Code easiert to read
        BeginWindows();
        {
            windowPositionAndSize = GUILayout.Window(0, windowPositionAndSize, DrawResearchNodeWindow, "Enter Heading here");
        }
        EndWindows();
    }

    /// <summary>
    /// This Method defines UI-Elements within the Window.
    /// </summary>
    /// <param name="id">The Windows unique ID.</param>
    private void DrawResearchNodeWindow(int id)
    {
        //Add a single Button for Testing-Purposes
        GUILayout.BeginVertical();
        {
            if (GUILayout.Button(new GUIContent(buttonIcon, "Add a custom Tooltip here"), GUILayout.Width(30), GUILayout.Height(30)))
            {
                Debug.Log($"A Button within Window {id} has been clicked.");
            }
        }
        GUILayout.EndVertical();

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

Nachdem nun ein erstes Fenster erzeugt wurde, kann man einen Schritt weitergehen und für jeden Forschungsknoten ein eigenes Fenster erzeugen. Hierfür wird der existierende Code ein wenig überarbeitet. Die GenerateWindowsForResearchNodes-Methode definiert hierbei die initialen Positionen der Fenster und versetzt diese ein wenig zueinander, sodass diese sich nicht vollständig überlappen. Das Ergebnis sind 5 einzelne Fenster, jeweils eins für jeden Forschungsknoten, inklusive dem Anzeigenamen des Knotens als Überschrift des Fensters.

    /// <summary>
    /// The Windows Position and Size. Has to be saved between OnGUI()-Calls in order to make the Window draggable.
    /// </summary>
    private static List<Rect> windowPositionAndSizes = new List<Rect>();

    /// <summary>
    /// This Method is called whenever the UI is updated.
    /// </summary>
    void OnGUI()
    {
        //Define all your Windows between BeginWindows() and EndWindows(). The { } Brackets are not needed, but make the Code easiert to read
        BeginWindows();
        {
            //Show a Window for all Research Nodes
            for (int i = 0; i < windowPositionAndSizes.Count; i++)
            {
                windowPositionAndSizes[i] = GUILayout.Window(researchNodes[i].researchNodeID, windowPositionAndSizes[i], DrawResearchNodeWindow, researchNodes[i].researchNodeName);
            }
        }
        EndWindows();
    }

    /// <summary>
    /// Initializes the Window-Sizes and Positions for all Research Nodes.
    /// </summary>
    private static void GenerateWindowsForResearchNodes()
    {
        //Reset existing Data
        windowPositionAndSizes.Clear();

        //Set initial Window Locations
        for (int i = 0; i < researchNodes.Count; i++)
        {
            windowPositionAndSizes.Add(new Rect(new Vector2(100.0f * i + 50.0f, 50.0f * i + 50.0f), new Vector2(150.0f, 200.0f)));
        }
    }

Damit die Testdaten und zugehörigen Fenster erzeugt werden, muss die InitEditorWindow-Methode ergänzt werden.

    static void InitEditorWindow()
    {
        ...

        //Init Test Data
        GenerateExampleResearchNodes();
        GenerateWindowsForResearchNodes();

        ...
    }

Bis hierhin haben wir gelernt, wie ein neues Editor-Fenster in Unity erstellt und dynamisch mit verschiebbaren Fenstern gefüllt wird. Im folgenden Blog-Beitrag wird erläutert, wie UI-Elemente zu den einzelnen Forschungsknoten-Fenstern hinzugefügt und die Forschungsknoten editiert werden können.

Tagged , , .Speichere in deinen Favoriten diesen permalink.

Schreibe einen Kommentar

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