IMGUI und Editor-Anpassung in der Tiefe

Seltsames Timing, könnte man meinen. Warum sollten Sie sich noch für das alte UI-System interessieren, wenn das neue bereits verfügbar ist? Nun, obwohl das neue UI-System jede Situation abdecken soll, in der Sie eine Benutzeroberfläche im Spiel benötigen, wird IMGUI immer noch verwendet, insbesondere in einer sehr wichtigen Situation: dem Unity Editor selbst. Wenn Sie den Unity-Editor um benutzerdefinierte Tools und Funktionen erweitern möchten, müssen Sie sich wahrscheinlich unter anderem mit IMGUI auseinandersetzen.
Erste Frage also: Warum heißt es 'IMGUI'? IMGUI ist die Abkürzung für Immediate Mode GUI. OK, und was ist das? Nun, es gibt zwei Hauptansätze für GUI-Systeme: 'Sofort' und 'Beibehalten'.
Bei einer GUI im beibehaltenen Modus "behält" das GUI-System Informationen über Ihre GUI: Sie richten Ihre verschiedenen GUI-Widgets ein - Beschriftungen, Schaltflächen, Schieberegler, Textfelder usw. - und diese Informationen werden vom System beibehalten und verwendet, um den Bildschirm zu rendern, auf Ereignisse zu reagieren und so weiter. Wenn Sie den Text auf einem Etikett ändern oder eine Schaltfläche verschieben möchten, dann manipulieren Sie Informationen, die irgendwo gespeichert sind, und wenn Sie Ihre Änderung vorgenommen haben, arbeitet das System in seinem neuen Zustand weiter. Wenn der Benutzer Werte ändert und Schieberegler bewegt, speichert das System einfach die Änderungen und es liegt an Ihnen, die Werte abzufragen oder auf Rückrufe zu reagieren. Das neue Unity UI-System ist ein Beispiel für eine GUI im Retained Mode. Sie erstellen Ihre UI.Labels, UI.Buttons usw. als Komponenten, richten sie ein und lassen sie dann einfach stehen, während sich das neue UI-System um den Rest kümmert.
Bei einer GUI im Sofortmodus speichert das GUI-System in der Regel keine Informationen über Ihre GUI, sondern fordert Sie immer wieder auf, die Steuerelemente neu zu spezifizieren, wo sie sich befinden usw. Da Sie jeden Teil der Benutzeroberfläche in Form von Funktionsaufrufen spezifizieren, wird er sofort verarbeitet - gezeichnet, angeklickt usw. - und die Folgen jeder Benutzerinteraktion werden sofort an Sie zurückgegeben, anstatt dass Sie sie erst abfragen müssen. Das ist für eine Spiele-UI ineffizient - und für Künstler unbequem, da alles sehr codeabhängig wird -, aber es erweist sich als sehr praktisch für Nicht-Echtzeit-Situationen (wie Editor-Panels), die stark codeabhängig sind (wie Editor-Panels) und die angezeigten Steuerelemente leicht als Reaktion auf den aktuellen Zustand ändern wollen (wie Editor-Panels!), so dass es eine gute Wahl für Dinge wie schwere Baumaschinen ist. Nein, warten Sie. Ich meinte, es ist eine gute Wahl für Editor-Panels.
Wenn Sie mehr darüber erfahren möchten, hat Casey Muratori ein großartiges Video, in dem er einige der Vorteile und Prinzipien einer GUI im Sofortmodus erläutert. Oder Sie lesen einfach weiter!
Jedes Mal, wenn der IMGUI-Code ausgeführt wird, wird ein aktuelles 'Ereignis' behandelt. Das kann etwas sein wie 'der Benutzer hat auf die Maustaste geklickt' oder etwas wie 'die GUI muss neu gezeichnet werden'. Sie können herausfinden, was das aktuelle Ereignis ist, indem Sie Event.current.type überprüfen.
Stellen Sie sich vor, wie es aussehen könnte, wenn Sie irgendwo in einem Fenster eine Reihe von Schaltflächen haben und Sie separaten Code schreiben müssten, um auf 'Benutzer hat auf die Maustaste geklickt' und 'die GUI muss neu gezeichnet werden' zu reagieren. Auf Blockebene könnte es so aussehen:

Diese Funktionen für jedes einzelne GUI-Ereignis zu schreiben, ist etwas mühsam, aber Sie werden feststellen, dass es eine gewisse strukturelle Ähnlichkeit zwischen den Funktionen gibt. Bei jedem Schritt tun wir etwas, das mit demselben Steuerelement zu tun hat (Taste 1, Taste 2 oder Taste 3). Was wir genau tun, hängt von der jeweiligen Veranstaltung ab, aber die Struktur ist die gleiche. Das bedeutet, dass wir stattdessen dies tun können:

Wir haben eine einzige OnGUI-Funktion, die Bibliotheksfunktionen wie GUI.Button aufruft, und diese Bibliotheksfunktionen tun unterschiedliche Dinge, je nachdem, welches Ereignis wir gerade behandeln. Einfach!
Es gibt 5 Ereignistypen, die am häufigsten verwendet werden:
EventType.MouseDownSet, wenn der Benutzer gerade eine Maustaste gedrückt hat.EventType.MouseUpSet, wenn der Benutzer gerade eine Maustaste losgelassen hat.EventType.KeyDownSet, wenn der Benutzer gerade eine Taste gedrückt hat.EventType.KeyUpSet, wenn der Benutzer gerade eine Taste losgelassen hat.EventType.RepaintSet, wenn IMGUI den Bildschirm neu zeichnen muss.
Diese Liste ist nicht vollständig. Weitere Informationen finden Sie in der EventType-Dokumentation.
Wie könnte ein Standard-Steuerelement, z.B. GUI.Button, auf einige dieser Ereignisse reagieren?
EventType.RepaintZeichnen Sie die Schaltfläche in dem angegebenen Rechteck.EventType.MouseDownPrüfen Sie, ob sich die Maus innerhalb des Rechtecks der Schaltfläche befindet. Wenn ja, kennzeichnen Sie die Schaltfläche als gedrückt und lösen ein Repaint aus, so dass sie neu gezeichnet wird. EventType.MouseUpUnflaggen Sie die Schaltfläche als gedrückt und lösen Sie ein Repaint aus. Prüfen Sie dann, ob sich die Maus noch innerhalb des Rechtecks der Schaltfläche befindet: Wenn ja, geben Sie true zurück, so dass der Aufrufer auf das Anklicken der Schaltfläche reagieren kann.
Die Realität ist etwas komplizierter - eine Schaltfläche reagiert auch auf Tastaturereignisse, und es gibt Code, der sicherstellt, dass nur die Schaltfläche, auf die Sie ursprünglich geklickt haben, auf MouseUp reagieren kann - aber so erhalten Sie einen Überblick. Solange Sie GUI.Button für jedes dieser Ereignisse an der gleichen Stelle in Ihrem Code aufrufen, mit der gleichen Position und dem gleichen Inhalt, arbeiten die verschiedenen Verhaltensweisen zusammen, um die gesamte Funktionalität einer Schaltfläche bereitzustellen.
Um diese verschiedenen Verhaltensweisen bei unterschiedlichen Ereignissen miteinander zu verknüpfen, gibt es in IMGUI das Konzept der 'Kontroll-ID'. Die Idee einer Kontroll-ID ist es, eine konsistente Möglichkeit zu bieten, sich auf ein bestimmtes Steuerelement in jedem Ereignistyp zu beziehen. Jeder einzelne Teil der Benutzeroberfläche, der ein nicht-triviales interaktives Verhalten aufweist, fordert eine Kontroll-ID an. Sie wird verwendet, um z.B. festzustellen, welches Steuerelement gerade den Tastaturfokus hat, oder um eine kleine Menge an Informationen zu speichern, die mit einem Steuerelement verbunden sind. Die Kontroll-IDs werden den Steuerelementen einfach in der Reihenfolge zugewiesen, in der sie danach fragen. Solange Sie also dieselben GUI-Funktionen in derselben Reihenfolge unter verschiedenen Ereignissen aufrufen, erhalten sie dieselben Kontroll-IDs und die verschiedenen Ereignisse werden synchronisiert.
Wenn Sie Ihre eigenen Editor-Klassen, Ihre eigenen EditorWindow-Klassen oder Ihre eigenen PropertyDrawer-Klassen erstellen möchten, bietet die GUI-Klasse - ebenso wie die EditorGUI-Klasse - eine Bibliothek mit nützlichen Standardsteuerelementen, die Sie überall in Unity verwenden werden.
(Es ist ein häufiger Fehler von Editor-Neulingen, die GUI-Klasse zu übersehen - aber die Steuerelemente in dieser Klasse können bei der Erweiterung des Editors genauso frei verwendet werden wie die Steuerelemente in EditorGUI. Es gibt nichts Besonderes zwischen GUI und EditorGUI - es sind einfach zwei Bibliotheken mit Steuerelementen, die Sie verwenden können. Der Unterschied besteht darin, dass die Steuerelemente in EditorGUI nicht im Spiel verwendet werden können, da der Code dafür Teil des Editors ist, während GUI ein Teil der Engine selbst ist.)
Was aber, wenn Sie etwas tun möchten, das über das hinausgeht, was in der Standardbibliothek verfügbar ist?
Sehen wir uns an, wie wir ein benutzerdefiniertes Steuerelement für die Benutzeroberfläche erstellen können. Versuchen Sie, die farbigen Kästchen in dieser kleinen Demo anzuklicken und zu verschieben:
(HINWEIS: Die hier eingebettete Original-WebGL-Anwendung funktioniert in Browsern nicht mehr)
(Sie benötigen einen Browser mit WebGL-Unterstützung, um die Demo zu sehen, z.B. aktuelle Versionen von Firefox).
Diese benutzerdefinierten Schieberegler steuern jeweils einen separaten 'Float'-Wert zwischen 0 und 1. Vielleicht möchten Sie so etwas im Inspektor verwenden, um z.B. die Integrität der Hülle für verschiedene Teile eines Raumschiffs anzuzeigen, wobei 1 für "kein Schaden" und 0 für "völlig zerstört" steht. Wenn die Balken die Werte als Farben darstellen, ist es vielleicht einfacher, auf einen Blick zu erkennen, in welchem Zustand sich das Schiff befindet. Der Code für die Erstellung eines benutzerdefinierten IMGUI-Steuerelements, das Sie wie jedes andere Steuerelement verwenden können, ist ziemlich einfach. Gehen wir ihn also durch.
Der erste Schritt besteht darin, unsere Funktionssignatur festzulegen. Um alle verschiedenen Ereignistypen abzudecken, benötigt unsere Kontrolle drei Dinge:
- ein Rect, das definiert, wo es sich selbst zeichnen soll und wo es auf Mausklicks reagieren soll.
- den aktuellen Float-Wert, den der Balken darstellt.
- einen GUIStyle, der alle notwendigen Informationen über Abstände, Schriftarten, Texturen usw. enthält, die das Steuerelement benötigt. In unserem Fall ist das die Textur, die wir zum Zeichnen des Balkens verwenden werden. Mehr zu diesem Parameter später.
Es muss auch den Wert zurückgeben, den der Benutzer durch Ziehen des Balkens eingestellt hat. Das ist nur bei bestimmten Ereignissen wie Mausereignissen sinnvoll, nicht aber bei Ereignissen wie der Wiederholung von Bildern. Wir geben also standardmäßig den Wert zurück, den der aufrufende Code übergeben hat. Die Idee ist, dass der aufrufende Code einfach "Wert = MyCustomSlider(... Wert ...)" sagen kann, ohne sich um das Ereignis zu kümmern, das gerade stattfindet. Wenn wir also keinen neuen Wert zurückgeben, der vom Benutzer festgelegt wurde, müssen wir den Wert beibehalten, der gerade steht.
Die resultierende Signatur sieht also wie folgt aus:
public static float MyCustomSlider(Rect controlRect, float value, GUIStyle style)
Jetzt beginnen wir mit der Implementierung der Funktion. Der erste Schritt besteht darin, eine Kontroll-ID abzurufen. Wir werden dies für bestimmte Dinge verwenden, wenn wir auf die Mausereignisse reagieren. Aber auch wenn das Ereignis, das behandelt wird, uns eigentlich nicht interessiert, müssen wir trotzdem eine ID anfordern, um sicherzustellen, dass sie nicht einem anderen Steuerelement für dieses spezielle Ereignis zugewiesen wird. Denken Sie daran, dass IMGUI IDs nur in der Reihenfolge vergibt, in der sie angefordert werden. Wenn Sie also nicht nach einer ID fragen, wird sie stattdessen an das nächste Steuerelement vergeben, so dass dieses Steuerelement am Ende unterschiedliche IDs für verschiedene Ereignisse hat, was es wahrscheinlich kaputt macht. Bei der Anforderung von IDs heißt es also alles oder nichts - entweder Sie fordern eine ID für jeden Ereignistyp an, oder Sie fordern sie für keinen von ihnen an (was in Ordnung sein kann, wenn Sie ein Steuerelement erstellen, das extrem einfach oder nicht interaktiv ist).
{
int controlID = GUIUtility.GetControlID (FocusType.Passive);
Der dort als Parameter übergebene FocusType.Passive teilt IMGUI mit, welche Rolle dieses Steuerelement bei der Tastaturnavigation spielt - ob es möglich ist, dass das Steuerelement das aktuelle ist, das auf Tastendrücke reagiert. Mein benutzerdefinierter Schieberegler reagiert überhaupt nicht auf Tastendruck, daher ist er auf Passiv eingestellt, aber Steuerelemente, die auf Tastendruck reagieren, können auf Nativ oder Tastatur eingestellt werden. In den FocusType-Dokumenten finden Sie weitere Informationen dazu.
Als Nächstes tun wir das, was die meisten benutzerdefinierten Steuerelemente irgendwann in ihrer Implementierung tun werden: Wir verzweigen je nach Ereignistyp mit einer Switch-Anweisung. Anstatt Event.current.type direkt zu verwenden, verwenden wir Event.current.GetTypeForControl() und übergeben unsere Kontroll-ID. Dadurch wird der Ereignistyp gefiltert, um sicherzustellen, dass z.B. Tastaturereignisse in bestimmten Situationen nicht an das falsche Steuerelement gesendet werden. Allerdings wird nicht alles gefiltert, so dass wir noch einige eigene Prüfungen durchführen müssen.
switch (Event.current.GetTypeForControl(controlID))
{
Jetzt können wir mit der Implementierung der spezifischen Verhaltensweisen für die verschiedenen Ereignistypen beginnen. Beginnen wir mit dem Zeichnen des Steuerelements:
case EventType.Repaint:
{
// Work out the width of the bar in pixels by lerping
int pixelWidth = (int)Mathf.Lerp (1f, controlRect.width, value);
// Build up the rectangle that the bar will cover
// by copying the whole control rect, and then setting the width
Rect targetRect = new Rect (controlRect){ width = pixelWidth };
// Tint whatever we draw to be red/green depending on value
GUI.color = Color.Lerp (Color.red, Color.green, value);
// Draw the texture from the GUIStyle, applying the tint
GUI.DrawTexture (targetRect, style.normal.background);
// Reset the tint back to white, i.e. untinted
GUI.color = Color.white;
break;
}
An diesem Punkt könnten Sie die Funktion beenden und Sie hätten ein funktionierendes 'Nur-Lese'-Steuerelement zur Anzeige von Float-Werten zwischen 0 und 1. Aber lassen Sie uns weitermachen und die Steuerung interaktiv gestalten.
Um ein angenehmes Mausverhalten für das Steuerelement zu implementieren, haben wir eine Anforderung: Sobald Sie auf das Steuerelement geklickt und begonnen haben, es zu ziehen, sollten Sie die Maus nicht über dem Steuerelement halten müssen. Für den Benutzer ist es viel angenehmer, wenn er sich nur darauf konzentrieren kann, wo sich sein Cursor horizontal befindet, und sich nicht um die vertikale Bewegung kümmern muss. Das bedeutet, dass sie die Maus beim Ziehen möglicherweise über andere Steuerelemente bewegen, und diese Steuerelemente müssen die Maus ignorieren, bis der Benutzer die Taste wieder loslässt.
Die Lösung für dieses Problem ist die Verwendung von GUIUtility.hotControl. Es ist nur eine einfache Variable, die die Kontroll-ID des Steuerelements enthält, das die Maus eingefangen hat. IMGUI verwendet diesen Wert in GetTypeForControl(); wenn er nicht 0 ist, werden Mausereignisse herausgefiltert, es sei denn, die übergebene Kontroll-ID ist das hotControl.
Das Setzen und Zurücksetzen von hotControl ist also ziemlich einfach:
case EventType.MouseDown:
{
// If the click is actually on us...
if (controlRect.Contains (Event.current.mousePosition)
// ...and the click is with the left mouse button (button 0)...
&& Event.current.button == 0)
// ...then capture the mouse by setting the hotControl.
GUIUtility.hotControl = controlID;
break;
}
case EventType.MouseUp:
{
// If we were the hotControl, we aren't any more.
if (GUIUtility.hotControl == controlID)
GUIUtility.hotControl = 0;
break;
}
Beachten Sie, dass diese Fälle nicht ausgeführt werden, wenn ein anderes Steuerelement das Hot Control ist - d.h. GUIUtility.hotControl ist etwas anderes als 0 und die ID unseres eigenen Steuerelements -, da GetTypeForControl() anstelle von mouseUp/mouseDown-Ereignissen 'ignore' zurückgibt.
Das Setzen von hotControl ist gut, aber wir haben immer noch nichts getan, um den Wert zu ändern, während die Maus unten ist. Der einfachste Weg, dies zu tun, besteht darin, den Schalter zu schließen und dann zu sagen, dass jedes Mausereignis (Klicken, Ziehen oder Loslassen), das eintritt, während wir das hotControl sind (und daher mitten im Klicken+Ziehen sind - allerdings nicht beim Loslassen, da wir das hotControl in diesem Fall oben auf Null gesetzt haben), dazu führen soll, dass sich der Wert ändert:
if (Event.current.isMouse && GUIUtility.hotControl == controlID) {
// Get mouse X position relative to left edge of the control
float relativeX = Event.current.mousePosition.x - controlRect.x;
// Divide by control width to get a value between 0 and 1
value = Mathf.Clamp01 (relativeX / controlRect.width);
// Report that the data in the GUI has changed
GUI.changed = true;
// Mark event as 'used' so other controls don't respond to it, and to
// trigger an automatic repaint.
Event.current.Use ();
}
Die letzten beiden Schritte - das Setzen von GUI.changed und der Aufruf von Event.current.Use() - sind besonders wichtig, nicht nur, damit sich dieses Steuerelement korrekt verhält, sondern auch, damit es mit anderen IMGUI-Steuerelementen und -Funktionen harmoniert. Wenn Sie GUI.changed auf true setzen, kann der aufrufende Code die Funktionen EditorGUI.BeginChangeCheck() und EditorGUI.EndChangeCheck() verwenden, um festzustellen, ob der Benutzer den Wert Ihres Steuerelements tatsächlich geändert hat oder nicht. Sie sollten es aber auch vermeiden, GUI.changed auf false zu setzen, da dies dazu führen könnte, dass die Tatsache verborgen bleibt, dass der Wert eines früheren Steuerelements geändert wurde.
Zum Schluss müssen wir einen Wert aus der Funktion zurückgeben. Sie erinnern sich, dass wir gesagt haben, wir würden den geänderten Float-Wert zurückgeben - oder den ursprünglichen Wert, wenn sich nichts geändert hat, was in den meisten Fällen der Fall sein wird:
return value;
}
Und wir sind fertig. MyCustomSlider ist jetzt ein einfaches, funktionierendes IMGUI-Steuerelement, das Sie in benutzerdefinierten Editoren, PropertyDrawern, Editorfenstern usw. verwenden können. Wir können noch mehr tun, um es zu verbessern - z.B. Multi-Editing unterstützen - aber darauf gehen wir weiter unten ein.
Es gibt noch eine weitere wichtige, nicht offensichtliche Sache bei IMGUI, und zwar seine Beziehung zur Szenenansicht. Sie kennen sicher alle die Hilfselemente der Benutzeroberfläche, die in der Szenenansicht gezeichnet werden, wenn Sie Objekte verschieben, drehen und skalieren möchten - die orthogonalen Pfeile, Ringe und mit Kästchen versehenen Linien, auf die Sie klicken und ziehen können, um Objekte zu manipulieren. Diese UI-Elemente werden 'Handles' genannt.
Was nicht offensichtlich ist, ist, dass Handles auch von IMGUI unterstützt werden!
Schließlich gibt es in dem, was wir bisher über IMGUI gesagt haben, nichts, was spezifisch für 2D oder Editoren/EditorWindows ist. Die Standard-Steuerelemente, die Sie in den GUI- und EditorGUI-Klassen finden, sind natürlich alle 2D, aber die grundlegenden Konzepte wie EventType und Control-IDs hängen überhaupt nicht von 2D ab. Während GUI und EditorGUI also 2D-Steuerelemente für EditorWindows und Editoren für Komponenten im Inspector bereitstellen, bietet die Klasse Handles 3D-Steuerelemente für die Verwendung in der Scene View. So wie EditorGUI.IntField ein Steuerelement zeichnet, mit dem der Benutzer eine einzelne Ganzzahl bearbeiten kann, haben wir Funktionen wie:
Vector3 PositionHandle(Vector3 Position, Quaternion Rotation);
die es dem Benutzer ermöglicht, einen Vector3-Wert visuell zu bearbeiten, indem eine Reihe von interaktiven Pfeilen in der Szenenansicht angezeigt wird. Und genau wie zuvor können Sie auch eigene Handle-Funktionen definieren, um benutzerdefinierte Elemente der Benutzeroberfläche zu zeichnen. Der Umgang mit der Mausinteraktion ist etwas komplexer, da es nicht mehr ausreicht, nur zu prüfen, ob sich die Maus innerhalb eines Rechtecks befindet oder nicht - die Klasse HandleUtility kann Ihnen dabei behilflich sein - aber die grundlegende Struktur und die Konzepte sind dieselben.
Wenn Sie in Ihrer benutzerdefinierten Editor-Klasse eine OnSceneGUI-Funktion bereitstellen, können Sie dort Handle-Funktionen verwenden, um in die Szenenansicht zu zeichnen, und sie werden wie erwartet korrekt im Weltraum positioniert. Denken Sie jedoch daran, dass es möglich ist, Handles in 2D-Kontexten wie benutzerdefinierten Editoren zu verwenden oder GUI-Funktionen in der Szenenansicht zu nutzen - Sie müssen dann lediglich Dinge wie das Einrichten von GL-Matrizen oder den Aufruf von Handles.BeginGUI() und Handles.EndGUI() tun, um den Kontext einzurichten, bevor Sie sie verwenden.
Im Fall von MyCustomSlider gab es eigentlich nur zwei Informationen, die wir im Auge behalten mussten: den aktuellen Wert des Schiebereglers (der vom Benutzer übergeben und an ihn zurückgegeben wurde) und ob der Benutzer gerade dabei war, den Wert zu ändern (was wir effektiv mit hotControl im Auge behalten haben). Was aber, wenn eine Kontrolle mehr Informationen als das benötigt?
IMGUI bietet ein einfaches Speichersystem für 'Statusobjekte', die mit einem Steuerelement verbunden sind. Sie definieren Ihre eigene Klasse zum Speichern von Werten und bitten IMGUI, eine Instanz dieser Klasse zu verwalten, die mit der ID Ihres Steuerelements verknüpft ist. Sie dürfen nur ein Zustandsobjekt pro Kontroll-ID verwenden, und Sie instanziieren es nicht selbst - IMGUI übernimmt das für Sie, indem es den Standardkonstruktor des Zustandsobjekts verwendet. Außerdem werden Statusobjekte nicht serialisiert, wenn der Editorcode neu geladen wird - was jedes Mal geschieht, wenn Ihr Code neu kompiliert wird -, so dass Sie sie nur für kurzlebige Dinge verwenden sollten. (Beachten Sie, dass dies auch dann zutrifft, wenn Sie Ihre Statusobjekte als [Serializable] markieren - der Serialisierer besucht diese spezielle Ecke des Heaps einfach nicht).
Hier ist ein Beispiel. Nehmen wir an, wir möchten eine Taste, die bei jedem Drücken true zurückgibt, aber auch rot blinkt, wenn Sie sie länger als zwei Sekunden gedrückt halten. Wir müssen die Zeit, zu der die Taste ursprünglich gedrückt wurde, im Auge behalten und speichern sie in einem Statusobjekt. Das ist also unsere State Object-Klasse:
public class FlashingButtonInfo
{
private double mouseDownAt;
public void MouseDownNow()
{
mouseDownAt = EditorApplication.timeSinceStartup;
}
public bool IsFlashing(int controlID)
{
if (GUIUtility.hotControl != controlID)
return false;
double elapsedTime = EditorApplication.timeSinceStartup - mouseDownAt;
if (elapsedTime < 2f)
return false;
return (int)((elapsedTime - 2f) / 0.1f) % 2 == 0;
}
}
Wir speichern die Zeit, zu der die Maus gedrückt wurde, in 'mouseDownAt', wenn MouseDownNow() aufgerufen wird, und verwenden dann die Funktion IsFlashing, um uns mitzuteilen, 'ob die Schaltfläche jetzt rot gefärbt werden soll' - wie Sie sehen können, wird sie definitiv nicht rot sein, wenn sie nicht das hotControl ist oder wenn weniger als 2 Sekunden vergangen sind, seit sie angeklickt wurde, aber danach lassen wir sie alle 0,1 Sekunden die Farbe wechseln.
Hier ist der Code für die eigentliche Schaltflächensteuerung selbst:
public static bool FlashingButton(Rect rc, GUIContent content, GUIStyle style)
{
int controlID = GUIUtility.GetControlID (FocusType.Native);
// Get (or create) the state object
var state = (FlashingButtonInfo)GUIUtility.GetStateObject(
typeof(FlashingButtonInfo),
controlID);
switch (Event.current.GetTypeForControl(controlID)) {
case EventType.Repaint:
{
GUI.color = state.IsFlashing (controlID)
? Color.red
: Color.white;
style.Draw (rc, content, controlID);
break;
}
case EventType.MouseDown:
{
if (rc.Contains (Event.current.mousePosition)
&& Event.current.button == 0
&& GUIUtility.hotControl == 0)
{
GUIUtility.hotControl = controlID;
state.MouseDownNow();
}
break;
}
case EventType.MouseUp:
{
if (GUIUtility.hotControl == controlID)
GUIUtility.hotControl = 0;
break;
}
}
return GUIUtility.hotControl == controlID;
}
Ziemlich einfach - Sie sollten den Code in den Fällen mouseDown/mouseUp als sehr ähnlich zu dem erkennen, was wir für die Erfassung der Maus im benutzerdefinierten Schieberegler oben getan haben. Die einzigen Unterschiede sind der Aufruf von state.MouseDownNow() beim Herunterdrücken der Maus und die Änderung von GUI.color im Repaint-Ereignis.
Den aufmerksamen Lesern unter Ihnen ist vielleicht aufgefallen, dass es einen weiteren wichtigen Unterschied beim Repaint-Ereignis gibt - den Aufruf von style.Draw(). Was soll das denn?
Als wir das benutzerdefinierte Schieberegler-Steuerelement erstellt haben, haben wir GUI.DrawTexture verwendet, um den Balken selbst zu zeichnen. Das hat gut funktioniert, aber unser FlashingButton muss zusätzlich zu dem "abgerundeten Rechteck", das die Schaltfläche selbst darstellt, eine Beschriftung haben. Wir könnten versuchen, etwas mit GUI.DrawTexture zu arrangieren, um das Bild der Schaltfläche zu zeichnen, und dann GUI.Label darüber, um die Beschriftung zu zeichnen... aber wir können es besser machen. Wir können die gleiche Technik verwenden, die GUI.Label verwendet, um sich selbst zu zeichnen, und den Zwischenhändler ausschalten.
Ein GUIStyle enthält Informationen über die visuellen Eigenschaften eines GUI-Elements - sowohl grundlegende Dinge wie die Schriftart oder die Textfarbe, die es verwenden soll, als auch subtilere Layout-Eigenschaften, wie z.B. wie viel Abstand es haben soll. All diese Informationen werden in einem GUIStyle gespeichert, zusammen mit Funktionen zur Berechnung der Breite und Höhe eines Inhalts unter Verwendung des Stils und den Funktionen zum eigentlichen Zeichnen des Inhalts auf dem Bildschirm.
GUIStyle kümmert sich nämlich nicht nur um einen Stil für ein Steuerelement: Es kann sich um die Darstellung in einer Reihe von Situationen kümmern, in denen sich ein GUI-Element befinden kann - es wird unterschiedlich gezeichnet, wenn der Mauszeiger darüber bewegt wird, wenn es den Tastaturfokus hat, wenn es deaktiviert ist und wenn es "aktiv" ist (z.B. wenn eine Schaltfläche gerade gedrückt wird). Sie können die Farbe und das Hintergrundbild für alle diese Situationen angeben. GUIStyle wählt dann zum Zeitpunkt des Zeichnens auf der Grundlage der ID des Steuerelements das passende Bild aus.
Es gibt vier Hauptwege, um an GUIStyles zu gelangen, die Sie zum Zeichnen Ihrer Steuerelemente verwenden können:
- Konstruieren Sie einen in Code (new GUIStyle()) und legen Sie die Werte dafür fest.
- Verwenden Sie einen der integrierten Stile aus der Klasse EditorStyles. Wenn Sie möchten, dass Ihre benutzerdefinierten Steuerelemente so aussehen wie die eingebauten - also eigene Symbolleisten, Steuerelemente im Inspektor-Stil usw. - dann sind Sie hier genau richtig.
- Wenn Sie nur eine kleine Variation eines vorhandenen Stils erstellen möchten - z.B. eine reguläre Schaltfläche mit rechtsbündigem Text -, können Sie die Stile in der Klasse EditorStyles klonen (new GUIStyle(existingStyle)) und dann nur die gewünschten Eigenschaften ändern.
- Rufen Sie sie von einem GUISkin ab.
Ein GUISkin ist im Wesentlichen ein großes Bündel von GUIStyle-Objekten. Wichtig ist, dass es als Asset in Ihrem Projekt erstellt und über den Inspektor frei bearbeitet werden kann. Wenn Sie ein Steuerelement erstellen und einen Blick darauf werfen, werden Sie Steckplätze für alle Standard-Steuerelementtypen sehen - Boxen, Schaltflächen, Beschriftungen, Kippschalter usw. Als Autor eines benutzerdefinierten Steuerelements sollten Sie Ihre Aufmerksamkeit jedoch auf den Abschnitt "Benutzerdefinierte Stile" am unteren Rand richten. Hier können Sie eine beliebige Anzahl von benutzerdefinierten GUIStyle-Einträgen einrichten und jedem einen eindeutigen Namen geben. Später können Sie sie dann mit GUISkin.GetStyle("nameOfCustomStyle") abrufen. Das einzige fehlende Teil des Puzzles ist, herauszufinden, wie Sie Ihr GUISkin-Objekt überhaupt aus dem Code bekommen. Wenn Sie Ihr Skin im Ordner 'Editor Default Resources' aufbewahren, können Sie EditorGUIUtility.LoadRequired() verwenden; alternativ könnten Sie eine Methode wie AssetDatabase.LoadAssetAtPath() verwenden, um es von einer anderen Stelle im Projekt zu laden. (Legen Sie Ihre reinen Editor-Assets aber nicht irgendwo ab, wo sie versehentlich in Asset-Bundles oder den Ressourcen-Ordner gepackt werden!)
Mit einem GUIStyle bewaffnet, können Sie dann einen GUIContent - eine Mischung aus Text, Symbol und Tooltip - mit GUIStyle.Draw() zeichnen. Übergeben Sie dazu das Rechteck, in das Sie zeichnen, den GUIContent, den Sie zeichnen möchten, und die ID des Steuerelements, das verwendet werden soll, um herauszufinden, ob der Inhalt z.B. den Tastaturfokus hat.
Sie haben sicher bemerkt, dass alle GUI-Steuerelemente, die wir bisher besprochen und geschrieben haben, einen Rect-Parameter enthalten, der die Position des Steuerelements auf dem Bildschirm bestimmt. Und jetzt, wo wir über GUIStyle gesprochen haben, werden Sie vielleicht innegehalten haben, als ich sagte, dass ein GUIStyle "Layout-Eigenschaften enthält, z.B. wie viel Abstand er braucht". Sie denken jetzt vielleicht: "Oh, oh. Bedeutet das, dass wir einen Haufen Arbeit leisten müssen, um unsere Rect-Werte so zu berechnen, dass die Abstandswerte eingehalten werden?"
Das ist sicherlich ein Ansatz, der uns zur Verfügung steht, aber es gibt einen einfacheren Weg. IMGUI enthält einen 'Layouting'-Mechanismus, der automatisch geeignete Rect-Werte für unsere Steuerelemente berechnen kann, wobei Dinge wie Abstände berücksichtigt werden. Wie funktioniert es also?
Der Trick ist ein zusätzlicher EventType-Wert, auf den die Steuerelemente reagieren können: EventType.Layout. IMGUI sendet das Ereignis an Ihren GUI-Code, und die von Ihnen aufgerufenen Steuerelemente reagieren darauf, indem sie IMGUI-Layoutfunktionen aufrufen - unter anderem GUILayoutUtility.GetRect(), GUILayout.BeginHorizonal / Vertical und GUILayout.EndHorizontal / Vertical-, die IMGUI aufzeichnet und so einen Baum der Steuerelemente in Ihrem Layout und des von ihnen benötigten Platzes erstellt. Sobald er fertig ist und der Baum vollständig aufgebaut ist, führt IMGUI einen rekursiven Durchlauf über den Baum durch, berechnet die tatsächlichen Breiten und Höhen der Elemente und wo sie sich im Verhältnis zueinander befinden, positioniert aufeinander folgende Steuerelemente nebeneinander und so weiter.
Wenn es dann an der Zeit ist, ein EventType.Repaint-Ereignis - oder jede andere Art von Ereignis - auszuführen, rufen die Steuerelemente die gleichen IMGUI-Layoutfunktionen auf. Nur dass IMGUI dieses Mal nicht die Aufrufe aufzeichnet, sondern die zuvor aufgezeichneten Aufrufe im Layout-Ereignis 'abspielt' und die berechneten Rechtecke zurückgibt. Nachdem Sie GUILayoutUtility.GetRect() während des Layout-Ereignisses aufgerufen haben, um zu registrieren, dass Sie ein Rechteck benötigen, rufen Sie es während des Repaint-Ereignisses erneut auf und es gibt tatsächlich das Rechteck zurück, das Sie verwenden sollten.
Wie bei den IDs der Steuerelemente bedeutet dies, dass Sie bei den Layout-Aufrufen, die Sie zwischen Layout-Ereignissen und anderen Ereignissen machen, konsistent sein müssen - andernfalls werden Sie am Ende berechnete Rechtecke für die falschen Steuerelemente abrufen. Das bedeutet auch, dass die Werte, die von GUILayoutUtility.GetRect() während eines Layout-Ereignisses zurückgegeben werden, nutzlos sind, da IMGUI das Rechteck, das es Ihnen geben soll, erst dann kennt, wenn das Ereignis abgeschlossen und der Layout-Baum verarbeitet worden ist.
Wie sieht das für unser benutzerdefiniertes Schieberegler-Steuerelement aus? Wir können ganz einfach eine Layout-fähige Version unseres Steuerelements schreiben, denn sobald wir ein Rechteck von IMGUI zurückbekommen haben, können wir einfach den Code aufrufen, den wir bereits geschrieben haben:
public static float MyCustomSlider(float value, GUIStyle style)
{
Rect position = GUILayoutUtility.GetRect(GUIContent.none, style);
return MyCustomSlider(position, value, style);
}
Der Aufruf von GUILayoutUtility.GetRect erfüllt zwei Aufgaben: Während eines Layout-Ereignisses wird aufgezeichnet, dass wir den angegebenen Stil verwenden möchten, um einen leeren Inhalt zu zeichnen - leer, weil es keinen bestimmten Text oder ein Bild gibt, für das wir Platz schaffen müssen - und während anderer Ereignisse wird ein tatsächliches Rechteck abgerufen, das wir verwenden können. Das bedeutet zwar, dass wir MyCustomSlider während eines Layout-Ereignisses mit einem falschen Rechteck aufrufen, aber das macht nichts - wir müssen es trotzdem tun, um sicherzustellen, dass die üblichen Aufrufe an GetControlID() erfolgen und das Rechteck während eines Layout-Ereignisses nicht tatsächlich für irgendetwas darin verwendet wird.
Sie werden sich vielleicht fragen, wie IMGUI die Größe des Schiebereglers berechnen kann, wenn der Inhalt "leer" ist und nur ein Stil angegeben wurde. Wir verlassen uns darauf, dass der Stil alle notwendigen Informationen enthält, die IMGUI verwenden kann, um das zuzuweisende Rechteck zu ermitteln. Aber was wäre, wenn wir dem Benutzer die Kontrolle darüber überlassen wollten - oder, sagen wir, eine feste Höhe aus dem Stil verwenden, aber dem Benutzer die Kontrolle über die Breite überlassen wollten. Wie würden wir das machen?
Die Antwort liegt in der Klasse GUILayoutOption. Instanzen dieser Klasse stellen Anweisungen an das Layoutsystem dar, dass ein bestimmtes Rechteck auf eine bestimmte Art und Weise berechnet werden soll, z.B. "soll die Höhe 30 haben" oder "soll sich horizontal ausdehnen, um den Platz zu füllen" oder "muss mindestens 20 Pixel breit sein". Wir erstellen sie durch den Aufruf von Factory-Funktionen in der GUILayout-Klasse - GUILayout.ExpandWidth(), GUILayout.MinHeight() und so weiter - und übergeben sie als Array an GUILayoutUtility.GetRect(). Sie werden im Layout-Baum gespeichert und berücksichtigt, wenn der Baum am Ende des Layout-Ereignisses verarbeitet wird.
Um es dem Benutzer zu erleichtern, beliebig viele GUILayoutOption-Instanzen bereitzustellen, ohne eigene Arrays erstellen und verwalten zu müssen, nutzen wir das C#-Schlüsselwort 'params', mit dem Sie eine Methode mit einer beliebigen Anzahl von Parametern aufrufen können, die automatisch in ein Array gepackt werden. Hier ist nun unser geänderter Slider:
public static float MyCustomSlider(float value, GUIStyle style, params GUILayoutOption[] opts)
{
Rect position = GUILayoutUtility.GetRect(GUIContent.none, style, opts);
return MyCustomSlider(position, value, style);
}
Wie Sie sehen, nehmen wir einfach das, was der Benutzer uns gegeben hat, und geben es an GetRect weiter.
Der Ansatz, den wir hier verwendet haben - eine manuell platzierte IMGUI-Steuerungsfunktion in eine Auto-Layout-Version zu verpacken - funktioniert für so ziemlich jede IMGUI-Steuerung, einschließlich der in der GUI-Klasse integrierten. Die Klasse GUILayout verwendet genau diesen Ansatz, um automatisch gelayoutete Versionen der Steuerelemente in der Klasse GUI bereitzustellen (und wir bieten eine entsprechende Klasse EditorGUILayout an, um Steuerelemente in der Klasse EditorGUI zu verpacken). Wenn Sie Ihre eigenen IMGUI-Steuerelemente erstellen, sollten Sie diese Konvention für zwei Klassen befolgen.
Es ist auch durchaus möglich, automatisch angeordnete und manuell positionierte Steuerelemente zu mischen. Sie können GetRect aufrufen, um ein Stück Platz zu reservieren, und dann Ihre eigenen Berechnungen durchführen, um dieses Rechteck in Unterrechtecke aufzuteilen, die Sie dann zum Zeichnen mehrerer Steuerelemente verwenden. Das Layoutsystem verwendet in keiner Weise IDs von Steuerelementen, so dass es kein Problem ist, mehrere Steuerelemente pro Layoutrechteck (oder sogar mehrere Layoutrechtecke pro Steuerelement) zu haben. Dies kann manchmal viel schneller sein als die vollständige Nutzung des Layoutsystems.
Beachten Sie außerdem, dass Sie beim Schreiben von PropertyDrawers das Layoutsystem nicht verwenden sollten. Stattdessen sollten Sie nur das Rechteck verwenden, das an Ihre PropertyDrawer.OnGUI()-Überschreibung übergeben wird. Der Grund dafür ist, dass die Editor-Klasse selbst aus Leistungsgründen das Layout-System nicht verwendet. Sie berechnet lediglich ein einfaches Rechteck und verschiebt es für jede nachfolgende Eigenschaft nach unten. Wenn Sie also das Layoutsystem in Ihrem PropertyDrawer verwenden würden, wüsste dieser nichts von den Eigenschaften, die vor Ihren gezeichnet wurden, und würde Sie schließlich über diesen positionieren. Das ist nicht das, was Sie wollen!
Mit allem, was wir bisher besprochen haben, können Sie Ihr eigenes IMGUI-Steuerelement erstellen, das ziemlich reibungslos funktioniert. Es gibt nur noch ein paar weitere Dinge zu besprechen, wenn Sie das, was Sie gebaut haben, auf das gleiche Niveau wie die in Unity integrierten Steuerelemente bringen wollen.
Die erste ist die Verwendung von SerializedProperty. Ich möchte in diesem Beitrag nicht zu sehr auf das SerializedProperty-System eingehen - das verschieben wir auf ein anderes Mal - sondern nur kurz zusammenfassen: Eine SerializedProperty 'umhüllt' eine einzelne Variable, die vom Serialisierungssystem (Laden und Speichern) von Unity behandelt wird. Auf jede Variable in jedem Skript, das Sie schreiben und das im Inspector angezeigt wird, sowie auf jede Variable in jedem Engine-Objekt, das Sie im Inspector sehen, kann über die SerializedProperty API zugegriffen werden, zumindest im Editor.
SerializedProperty ist nützlich, weil Sie damit nicht nur auf den Wert der Variablen zugreifen können, sondern auch Informationen erhalten, z.B. ob sich der Wert der Variablen von dem einer Vorabversion unterscheidet, aus der sie stammt, oder ob eine Variable mit untergeordneten Feldern (z.B. eine Struktur) im Inspector erweitert oder reduziert ist. Außerdem werden alle Änderungen, die Sie an dem Wert vornehmen, in das System zum Rückgängigmachen und Verschmutzen der Szene integriert. Sie können dies auch tun, ohne jemals die verwaltete Version Ihres Objekts zu erstellen, was die Leistung erheblich verbessern kann. Wenn wir also wollen, dass unsere IMGUI-Steuerelemente problemlos mit einer Reihe von Editor-Funktionen zusammenarbeiten - Rückgängig machen, Szene verschmutzen, Voreinstellungen überschreiben usw. - sollten wir sicherstellen, dass wir SerializedProperty unterstützen.
Wenn Sie sich die EditorGUI-Methoden ansehen, die eine SerializedProperty als Argument verwenden, werden Sie feststellen, dass die Signatur etwas anders ist. Anstelle des Ansatzes 'Nimm einen Float, gib einen Float zurück' unseres vorherigen benutzerdefinierten Schiebereglers nehmen die SerializedProperty-fähigen IMGUI-Steuerelemente einfach eine SerializedProperty-Instanz als Argument und geben nichts zurück. Das liegt daran, dass sie alle Änderungen, die sie am Wert vornehmen müssen, direkt auf die SerializedProperty selbst anwenden. Unser benutzerdefinierter Schieberegler von vorher kann nun so aussehen:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style)
Der Parameter 'value', den wir früher hatten, ist zusammen mit dem Rückgabewert verschwunden. Stattdessen ist der Parameter 'prop' da, um die SerializedProperty zu übergeben. Um den aktuellen Wert der Eigenschaft abzurufen, um den Schieberegler zu zeichnen, greifen wir einfach auf prop.floatValue zu, und wenn der Benutzer die Position des Schiebereglers ändert, weisen wir einfach prop.floatValue zu.
Das Vorhandensein der gesamten SerializedProperty im Code des IMGUI-Steuerelements hat jedoch noch weitere Vorteile. Betrachten Sie zum Beispiel die Art und Weise, wie geänderte Eigenschaften in vorgefertigten Instanzen in Fettdruck dargestellt werden. Prüfen Sie einfach die Eigenschaft prefabOverride der SerializedProperty und wenn sie true ist, tun Sie, was immer Sie tun müssen, um das Steuerelement anders anzuzeigen. Wenn Sie wirklich nur Text fett machen wollen, übernimmt IMGUI das zum Glück automatisch für Sie, solange Sie beim Zeichnen keine Schriftart in Ihrem GUIStyle angeben. (Wenn Sie in Ihrem GUIStyle eine Schriftart angeben, müssen Sie sich selbst darum kümmern, indem Sie eine normale und eine fette Version Ihrer Schriftart haben und beim Zeichnen anhand von prefabOverride zwischen ihnen wählen).
Die andere wichtige Funktion, die Sie benötigen, ist die Unterstützung für die Bearbeitung von mehreren Objekten - d.h. die elegante Handhabung der Dinge, wenn Ihr Steuerelement mehrere Werte gleichzeitig anzeigen muss. Testen Sie dies, indem Sie den Wert von EditorGUI.showMixedValue überprüfen. Wenn er wahr ist, wird Ihr Steuerelement verwendet, um mehrere verschiedene Werte gleichzeitig darzustellen, also tun Sie, was immer Sie tun müssen, um dies anzuzeigen.
Sowohl die Mechanismen bold-on-prefabOverride als auch showMixedValue setzen voraus, dass der Kontext für die Eigenschaft mit EditorGUI.BeginProperty() und EditorGUI.EndProperty() eingerichtet wurde. Das empfohlene Muster ist, dass Ihre Kontrollmethode, wenn sie eine SerializedProperty als Argument annimmt, die Aufrufe von BeginProperty und EndProperty selbst vornimmt, während sie, wenn sie mit 'rohen' Werten arbeitet - ähnlich wie z.B. EditorGUI.IntField, das Ints direkt annimmt und zurückgibt und nicht mit Eigenschaften arbeitet -, der aufrufende Code für den Aufruf von BeginProperty und EndProperty verantwortlich ist. (Das ist wirklich sinnvoll, denn wenn Ihr Steuerelement mit 'rohen' Werten arbeitet, hat es ohnehin keinen SerializedProperty-Wert, den es an BeginProperty übergeben kann).
public class MySliderDrawer : PropertyDrawer
{
public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
{
return EditorGUIUtility.singleLineHeight;
}
private GUISkin _sliderSkin;
public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
{
if (_sliderSkin == null)
_sliderSkin = (GUISkin)EditorGUIUtility.LoadRequired ("MyCustomSlider Skin");
MyCustomSlider (position, property, _sliderSkin.GetStyle ("MyCustomSlider"), label);
}
}
// Then, the updated definition of MyCustomSlider:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style, GUIContent label)
{
label = EditorGUI.BeginProperty (controlRect, label, prop);
controlRect = EditorGUI.PrefixLabel (controlRect, label);
// Use our previous definition of MyCustomSlider, which we’ve updated to do something
// sensible if EditorGUI.showMixedValue is true
EditorGUI.BeginChangeCheck();
float newValue = MyCustomSlider(controlRect, prop.floatValue, style);
if(EditorGUI.EndChangeCheck())
prop.floatValue = newValue;
EditorGUI.EndProperty ();
}
Ich hoffe, dieser Beitrag hat Ihnen einige der Kernbestandteile von IMGUI näher gebracht, die Sie verstehen müssen, wenn Sie Ihre Editoranpassung wirklich auf die nächste Stufe bringen wollen. Es gibt noch mehr zu tun, bevor Sie ein Editor-Guru werden können - das SerializedObject / SerializedProperty-System, die Verwendung von CustomEditor versus EditorWindow versus PropertyDrawer, die Handhabung von Undo usw. - aber IMGUI spielt eine große Rolle bei der Erschließung des immensen Potenzials von Unity für die Erstellung benutzerdefinierter Tools - sowohl im Hinblick auf den Verkauf im Asset Store als auch im Hinblick auf die Befähigung der Entwickler in Ihren eigenen Teams.
Teilen Sie mir Ihre Fragen und Ihr Feedback in den Kommentaren mit!