5 min read

Atomic Design + Godot?

Atomic Design + Godot?
https://atomicdesign.bradfrost.com/ - Brad Frost

Dieser Ansatz beschreibt ganz grob zusammengefasst die Praxis, UI Komponenten "atomar" aufzubauen. Bedeutet, dass eine UI Komponente eine klare Funktion erfüllt. Effektiv sind das auch die bezeichnenden "Atome" gemäß dieses Ansatzes. Moleküle bilden sich aus dem Zusammenschluss von mindestens 2 Atomen, während sich ein Organismus aus Molekülen zusammensetzt usw.

Im Wesentlichen sind die standard Controls in Godot bereits "Atome"

  • Button
  • CheckBox
  • Label
  • Panel
  • ...

All diese Controls decken eine klare Funktionalität ab und sie lassen sich gut mit anderen Controls kombinieren und somit Moleküle bilden.

Sicherlich kann man sich hier und da streiten, ob nun etwas ein "Atom" oder ein "Molekül" ist, mir geht es bei dem Artikel jedoch eher um das "Mindset" und weniger um starre Definitionen.

Ein wichtiger Zusammenhang bei der Umsetzung von UI Komponenten ist, dass der "Business Code" erst frühestens in den Templates oder sogar in den Pages sein wird. Das liegt daran, weil ein Atom/Molekül alleine noch gar nicht weiß, wo es tatsächlich eingebunden und angezeigt wird. Je nach Kontext ändert sich ggf. auch die Funktionalität.

Ein Button kann somit zum einen dafür sorgen, dass das Spiel gestartet wird oder die Ansicht gewechselt wird oder ein Skill ausgelöst wird o.ä. und trotzdem wird das Atom "Button" verwendet.

Ein anderer Ansatz wäre es, unterschiedliche Button-Klassen zu definieren und diese dann zu verwenden. Eine Klasse sorgt bspw. dafür, dass das Spiel beendet wird. Mit so einem Ansatz bleibt der Button "self-contained", also diesen kann man überall einsetzen und "es funktioniert".

class_name QuitButton
extends Button

func _ready() -> void:
  pressed.connect(_on_pressed)

func _on_pressed() -> void:
  get_tree().quit()

Der Nachteil von diesem Ansatz ist, dass es bei größeren Spielprojekten sehr viele solcher Atome gibt und das Projekt somit schnell unübersichtlich wird. Der Vorteil ist, dass die Funktionalität auch "atomar" wird und somit solche Klassen grundsätzlich sehr übersichtlich bleiben.

Ich möchte hier mal die zwei Ansätze gegenüber stellen. Erstmal ein Hauptmenü als Page mit dem zuerst vorgestellten Ansatz:

class_name MainMenu
extends Control

@export var button_start: Button
@export var button_quit: Button

func _ready() -> void:
  button_start.pressed.connect(_on_pressed_start)
  button_quit.pressed.connect(_on_pressed_quit)

func _on_pressed_start() -> void:
  # Start Game

func _on_pressed_quit() -> void:
  get_tree().quit()

Diese Klasse ist auch noch sehr übersichtlich und es ist auf einem Blick klar, was passiert. Die komplette Page wird hier mit Funktionalitäten angereichert, sodass alles gemäß dieses Kontexts funktioniert.

Nun ein Hauptmenü als Page mit den unterschiedlichen Button Klassen:

# Button - Start
class_name StartButton
extends Button

func _ready() -> void:
  pressed.connect(_on_pressed)

func _on_pressed() -> void:
  # Start Game

# Button - Quit
class_name QuitButton
extends Button

func _ready() -> void:
  pressed.connect(_on_pressed)

func _on_pressed() -> void:
  get_tree().quit()

# MainMenu -> Brauchen wir nicht!

Was ist auffällig? Das wir keine MainMenu Klasse brauchen! Es reicht eine Klasse mit einem Control als Root-Node und die 2 Buttons als ChildNodes, die jeweils das passende Skript bekommen. Dadurch, dass die Buttons da sind, kümmern sie sich selbst um die Funktionalität (das ist mit "self-contained" gemeint) und das MainMenu muss das nicht mehr verbinden.

Mithilfe von Komposition lassen sich so also Pages entwerfen, ohne erneut eine Zeile Code schreiben zu müssen. Jedoch ist das Beispiel auch bewusst simpel gewählt. Wenn es komplexer wird, werden die Vor- und Nachteile dieser Ansätze klarer.

Die erste Komplexitätsstufe ist erreicht, wenn auch ein Pause Menü im Spiel implementiert werden soll. So ein Pause Menü braucht vermutlich auch wieder einen "Spiel beenden" Button. Mit den einzelnen atomaren Klassen ist das gar kein Thema, man würde einfach nur eine neue Szene PauseMenu anlegen, ggf. neue Button-Klassen implementieren und diese wieder als ChildNodes definieren (Vorteil für Ansatz 2 mit den atomaren Klassen).

Mit dem ersten Ansatz wäre das im ersten Augenblick etwas "komplizierter", weil eine PauseMenu Klasse bedeuten würde, dass dort auch erneut (!) die Funktionalität implementiert werden muss. Das ergibt dann leider duplicate Code und wäre somit nicht "DRY" (Don't Repeat Yourself).

Sieht man aber genauer hin und denkt nochmal zurück an die Atomic Design Prinzipien, stellt man fest, dass der Aufbau etwas anders sein muss. Dadurch, dass jetzt eine Funktionalität erneut gebraucht wird, wird nämlich auch eine separate Komponente gebraucht. Sprich: Die Funktionalität wird nicht mehr auf Page-Ebene definiert, sondern auf Template oder sogar auf einer niedrigeren Ebene.

Wenn wir nun eine Klasse definieren, die so aussieht (Spoiler: ähnlich wie die MainMenu Klasse zuvor):

class_name MenuTemplate
extends Control

@export var button_start: Button
@export var button_quit: Button

func _ready() -> void:
  button_quit.pressed.connect(_on_pressed_quit)

func _on_pressed_quit() -> void:
  get_tree().quit()

Und dieses Template nutzen wir nun wiederum in den Page-Szenen MainMenu und PauseMenu, dann können wir erfolgreich die Funktionalität wiederverwenden. Allerdings fällt hier auf, was ich eingangs erwähnt habe. button_start hat an dieser Stelle noch keine Funktionalität, weil noch nicht klar ist, was passieren soll. Im Hauptmenü soll das Spiel gestartet werden, im Pause Menü soll jedoch das Spiel fortgeführt werden. Wie lösen wir das?

# Main Menu Page
class_name MainMenu
extends Control

@export var template: MenuTemplate

func _ready() -> void:
  template.button_start.pressed.connect(_on_pressed_start)

func _on_pressed_start() -> void:
  # Start Game

# Pause Menu Page
class_name PauseMenu
extends Control

@export var template: MenuTemplate

func _ready() -> void:
  template.button_start.pressed.connect(_on_pressed_continue)

func _on_pressed_continue() -> void:
  # Continue Game
  # template.visible = false o.ä.

Hier sehen wir, dass das MenuTemplate an zwei Stellen wiederverwendet wird. Außerdem kümmern wir uns auf Page-Ebene nicht mehr darum, den button_start zu definieren, das macht schließlich bereits das Template. In der Page reichern wir lediglich die "Business Logik" an, wobei die Funktionalität von button_quit erhalten bleibt.

Es zeichnet sich also bereits ein wenig ab, dass der erste Ansatz ein "Top to Bottom" Ansatz ist, während der zweite Ansatz eher auf "Bottom to Top" abzielt. Das sind zwei unterschiedliche Denkweisen bzw. "Mindsets". Ich persönlich tendiere eher zu Letzterem, mir fällt es einfacher, die "Bausteine" zu identifizieren, zu implementieren und anschließend zusammenzustecken. Anderen fällt es leichter "oben" beim Allgemeinen zu beginnen und sich dann langsam an die Bausteine heranzutasten.

Meiner Meinung nach benötigt der "Top to Bottom" Ansatz eine genauere Planung, weil sonst viele Refactorings nötig werden. Auch im Rahmen dieses Artikels haben wir ein kleines Refactoring vorgenommen. Die MainMenu Page musste zu einem MenuTemplate werden.

Abschließend möchte ich auf eine Kleinigkeit aufmerksam machen und einen weiteren Vorteil vom "Bottom to Top" Ansatz aufzeigen.

Ich habe stets extends Control geschrieben. Warum? Weil ich zwischen Code und dem tatsächlichen UI trenne, ich möchte kein Layout im Code annehmen, weil das die Aufgabe der Szene selbst ist und somit die Verantwortung im Godot Editor liegt. Was ist damit gemeint? In der Szene muss die RootNode eben kein Control sein, sondern kann alles sein, was Control als Basisklasse hat. Sprich: man kann auch einen HBoxContainer als RootNode verwenden und trotzdem das MainMenu oder MenuTemplate Skript nutzen.

Der Zusammenhang ist also: ein angehangenes Skript muss nicht vom Node-Typen erben!

Der weitere kleine Vorteil vom "Bottom to Top" Ansatz ist, dass die Benamung tendenziell einfacher ist. Die Funktionen heißen immer on_pressed und sind somit sehr allgemein und klar, weil durch den Klassennamen bereits klar ist, was passiert, wenn ein solcher Button angeklickt wird.

In Summe hilft Atomic Design bei einer besseren Struktur der UI Komponenten in einem Godot Projekt. Wie genau man diese Prinzipien umsetzt ist davon abhängig, was für eine Art Spiel entwickelt werden soll, wie viele UI Elemente es voraussichtlich im Spiel überhaupt geben soll und wie das Vorgehensmodell bei der Umsetzung des Spielprojekts ist. Wird sehr agil mit vielen und schnellen Iterationen gearbeitet, empfehle ich klar den "Bottom to Top" Ansatz. Gibt es eine intensive Planungsphase, ein klares UI/UX durch vorhandene Figma/Penpot Designs und entsprechende Dokumentation, bietet sich der "Top to Bottom" Ansatz sicherlich etwas mehr an.

Ich hoffe, dass ich Dir damit weiterhelfen konnte. Konnte ich dich mit diesem Atomic Design Prinzip abholen oder löst du UI Probleme völlig anders?