Hero background image
Last updated May 2019, 10 min. read
Wie Sie Code schreiben, wenn Ihr Projekt größer wird

Was Sie auf dieser Seite finden: Lernen Sie effiziente Strategien, wie man den Code eines wachsenden Spieles so gestaltet, dass eine reibungslose Skalierung mit weniger Problemen ermöglicht wird. Wenn Ihr Projekt wächst, müssen Sie sein Design immer wieder ändern und bereinigen. Hier ein guter Ansatz: Sie sollten die Änderungen, die Sie vornehmen, immer mit etwas Abstand betrachten, die Dinge in kleine Elemente unterteilen, um sie in Ordnung zu bringen, und dann alles wieder zusammenzuführen.

Der Beitrag ist von Mikael Kalms, CTO beim schwedischen Spielestudio Fall Damage. Mikael Kalms hat mehr als 20 Jahre Erfahrung in der Entwicklung und Bereitstellung von Spielen. Nach all der Zeit interessiert er sich nach wie vor sehr dafür, wie man Code so gestaltet, dass Projekte auf sichere und effiziente Weise wachsen können.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird
Vom Einfachen zum Komplexen

Schauen wir uns einige Code-Beispiele für ein sehr einfaches Pong-Spiel an, das mein Team für meinen Vortrag auf der Unite Berlin entwickelt hat. Wie Sie auf dem obigen Bild sehen können, gibt es zwei Paddles und vier Wände - oben und unten sowie links und rechts -, eine Spiellogik und die Benutzeroberfläche für den Spielstand. Sowohl die Schläger als auch die Wände haben ein einfaches Skript.

Dieses Beispiel basiert auf einigen Grundprinzipien:

  • Ein „Ding” = ein Prefab
  • Die individuelle Logik für ein „Ding” = ein MonoBehavior
  • Eine Anwendung = eine Szene mit den verketteten Prefabs

Diese Prinzipien funktionieren bei einem sehr einfachen Projekt wie diesem, aber wir müssen die Struktur ändern, wenn wir wollen, dass es wächst. Welche Strategien gibt es also, die wir für die Organisation des Codes verwenden können?

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_Komponentenparameter
Instanzen, Prefabs und ScriptableObjects

Lassen Sie uns zuerst die Missverständnisse über die Unterschiede zwischen Instanzen, Prefabs und ScriptableObjects aufklären. Oben erkennbar ist die Komponente „Paddle“ vom Paddle-GameObject von Spieler 1, betrachtet im Inspector:

Wir können sehen, dass es sich dabei um drei Parameter handelt. Allerdings gibt es in dieser Ansicht keinerlei Hinweise darauf, was der zugrunde liegende Code von mir erwartet.

Macht es Sinn für mich, die Input Axis (Input-Achse) am linken Schläger in der Instanz zu ändern, oder sollte ich sie im Prefab ändern? Ich gehe davon aus, dass die Input-Achse für beide Spieler unterschiedlich ist, also sollte ich sie wohl in der Instanz ändern. Was ist mit der Movement Speed Scale (Skala der Bewegungsgeschwindigkeit)? Handelt es sich dabei um etwas, das ich in der Instanz oder im Prefab ändern sollte?

Sehen wir uns den Code der Paddle-Komponente an.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_Parameter im Code
Parameter in einem einfachen Codebeispiel

Wenn wir ein wenig innehalten und nachdenken, stellen wir fest, dass die verschiedenen Parameter in unserem Programm auf unterschiedliche Weise verwendet werden. Wir sollten den InputAxisName für jeden Spieler einzeln ändern: MovementSpeedScaleFactor und PositionScale sollten von beiden Spielern gemeinsam genutzt werden. Diese Strategie hier ist eine Richtschnur dafür, wann Sie am besten Instanzen, Prefabs und ScriptableObjects verwenden:

  • Benötigen Sie etwas nur einmalig? Erstellen Sie ein Prefab, und instanziieren Sie dann.
  • Benötigen Sie etwas mehrmals, möglicherweise mit einigen instanzenspezifischen Anpassungen? Dann können Sie ein Prefab erstellen, es instanziieren und einige Einstellungen überschreiben.
  • Möchten Sie sicherstellen, dass Einstellungen über mehrere Instanzen hinweg gleich sind? Dann erstellen Sie ein ScriptableObject, und beziehen Sie Daten stattdessen von dort.

Im nächsten Codebeispiel sehen Sie, wie wir ScriptableObjects mit unserer Paddle-Komponente verwenden.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_ScriptableObjects verwenden
ScriptableObjects nutzen

Da wir diese Einstellungen in ein ScriptableObject vom Typ „PaddleData“ verschoben haben, haben wir in unserer Paddle-Komponente nur einen Verweis auf PaddleData. Was wir im Inspector also erhalten, sind zwei Elemente: einmal PaddleData und zwei Paddle-Instanzen. Sie können nach wie vor den Achsennamen und das Paket geteilter Einstellungen ändern, auf das die einzelnen Schläger zeigen. Durch die neue Struktur können Sie den Zweck der verschiedenen Einstellungen einfacher erkennen.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_Prinzip der Einzelverantwortung
Große MonoBehavior aufteilen

Würde sich dieses Spiel tatsächlich in Entwicklung befinden, würden Sie sehen, wie die einzelnen MonoBehavior größer und größer werden. Lassen Sie uns sehen, wie wir sie aufteilen können, indem wir vom so genannten Single-Responsibility-Prinzip ausgehen, das besagt, dass jede Klasse eine einzige Aufgabe hat. Bei richtiger Anwendung sollten Sie in der Lage sein, kurze Antworten auf die Fragen "Was tut eine bestimmte Klasse?" und "Was tut sie nicht?" zu geben. Das macht es für jeden Entwickler in Ihrem Team einfach zu verstehen, was die einzelnen Klassen tun. Das Prinzip können Sie auf eine Codebasis beliebiger Größe anwenden. Sehen wir uns ein einfaches Beispiel an, wie in der Abbildung oben gezeigt.

Dies ist der Code für einen Ball. Es sieht nicht nach viel aus, aber bei genauerer Betrachtung sehen wir, dass der Ball eine Geschwindigkeit hat, die sowohl vom Entwickler verwendet wird, um den anfänglichen Geschwindigkeitsvektor des Balls festzulegen, als auch von der selbst erstellten Physiksimulation, um zu verfolgen, wie hoch die aktuelle Geschwindigkeit des Balls ist.

Wir verwenden die gleiche Variable für zwei leicht unterschiedliche Zwecke wieder. Sobald der Ball beginnt, sich zu bewegen, gehen die Informationen über die ursprüngliche Geschwindigkeit verloren.

Die selbst erstellte Physiksimulation ist nicht nur die Bewegung in FixedUpdate(). Sie umfasst auch die Reaktion, wenn der Ball auf die Wand trifft.

Im OnTriggerEnter()-Callback befindet sich eine Destroy()-Operation. An dieser Stelle löscht die Auslöserlogik ihr eigenes GameObject. In einer großen Codebasis wird Entitäten nur selten erlaubt, sich selbst zu löschen. Die Tendenz geht stattdessen dahin, dass die Besitzer ihre eigenen Dinge löschen.

Hier bietet sich die Gelegenheit, die Dinge in kleinere Teile zu zerlegen. Es gibt eine Reihe verschiedener Arten von Verantwortlichkeiten in diesen Klassen – Spiellogik, Input-Verarbeitung, Physiksimulation, Darstellungen und mehr.

So können diese kleineren Teile erstellt werden:

  • Die allgemeine Spiellogik, Input-Verarbeitung, Physiksimulation und Darstellung können in MonoBehaviors, ScriptableObjects oder rohe C#-Klassen aufgenommen werden.
  • Zum Verfügbarmachen von Parametern im Inspektor können MonoBehaviors oder ScriptableObjects verwendet werden.
  • Engine-Event-Handler und die Verwaltung der Lebensdauer eines GameObject müssen in MonoBehaviors bleiben.

Ich denke, dass es sich für viele Spiele lohnt, so viel Code wie möglich aus MonoBehaviors herauszuholen. Eine Möglichkeit hierfür ist die Verwendung von ScriptableObjects, und es sind bereits einige großartige Ressourcen für diese Methode verfügbar.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_Monobehavior in C#-Klassen auslagern
Von MonoBehaviors zu regulären C#-Klassen

Die Verlagerung von MonoBehaviors zu regulären C#-Klassen ist eine weitere Methode, die man sich ansehen sollte, aber was sind die Vorteile davon?

Reguläre C#-Klassen verfügen über bessere sprachliche Möglichkeiten als die Unity-eigenen Objekte, um Code in kleine, zusammensetzbare Teile zu zerlegen. Außerdem kann normaler C#-Code mit einer nativen .NET-Codebasis außerhalb von Unity geteilt werden.

Andererseits versteht der Editor bei Verwendung normaler C#-Klassen die Objekte nicht und sie können nicht nativ im Inspektor angezeigt werden usw.

Mit dieser Methode teilen Sie die Logik nach Art der Verantwortung auf. Wenn wir auf das Ball-Beispiel zurückkommen, haben wir die einfache Physiksimulation in eine C#-Klasse verschoben, die wir „BallSimulation“ nennen. Die einzige Aufgabe, die sie erfüllen muss, ist die Integration der Physik und die Reaktion, wenn der Ball auf etwas trifft.

Ist es jedoch sinnvoll, dass eine Ballsimulation Entscheidungen auf der Grundlage dessen trifft, auf was der Ball trifft? Das klingt mehr nach einer Spielelogik. Am Ende hat der Ball einen Logikteil, der die Simulation in gewisser Weise steuert, und das Ergebnis dieser Simulation fließt zurück ins MonoBehavior.

Wenn wir uns die obige neu organisierte Version ansehen, ist eine wesentliche Änderung, dass die Destroy()-Operation nicht mehr unter vielen Schichten verborgen ist. An diesem Punkt sind nur noch einige wenige Verantwortungsgebiete im MonoBehavior übrig.

Es gibt noch mehr Dinge, die wir in dieser Hinsicht tun können. Wenn Sie sich die Positionsaktualisierungslogik in FixedUpdate() ansehen, können wir sehen, dass der Code eine Position einsenden muss und dann von dort eine neue Position zurückgibt. Die Ballsimulation besitzt nicht wirklich den Standort des Balls. Sie führt eine Simulationskontrolle aus, die auf dem bereitgestellten Standort eines Balls basiert, und gibt dann das Ergebnis aus.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_Schnittstellen verwenden
Schnittstellen verwenden

Mithilfe von Schnittstellen können wir vielleicht einen Teil dieser Ball-MonoBehavior mit der Simulation teilen – nur die Teile, die sie benötigt (siehe obiges Bild).

Sehen wir uns den Code noch einmal an. Die Ball-Klasse implementiert eine einfache Schnittstelle. Die LocalPositionAdapter-Klasse ermöglicht es, eine Referenz auf das Ball-Objekt an eine andere Klasse zu übergeben. Wir übergeben nicht das gesamte Ball-Objekt, sondern nur den LocalPositionAdapter-Aspekt davon.

„BallLogic“ muss zudem den Ball informieren, wenn es an der Zeit ist, das GameObject zu löschen. Anstatt ein Flag zurückzugeben, kann der Ball der BallLogic einen Delegaten bereitstellen. Das tut die letzte markierte Zeile in der reorganisierten Version. Das gibt uns ein sauberes Design: Es gibt eine Menge Standardlogik, aber jede Klasse hat einen eng definierten Zweck.

Mit diesen Prinzipien können Sie dafür sorgen, dass ein Ein-Personen-Projekt gut strukturiert bleibt.

Wie Sie Code schreiben, wenn Ihr Projekt größer wird_Softwarearchitektur
Softwarearchitektur

Schauen wir uns die Softwarearchitektur-Lösungen für etwas größere Projekte an. Wenn wir das Beispiel des Ballspiels verwenden, sollten wir, sobald wir anfangen, spezifischere Klassen in den Code einzuführen – BallLogic, BallSimulation usw. –, in der Lage sein, eine Hierarchie aufzubauen:

Die MonoBehaviours müssen über alles andere Bescheid wissen, weil sie all die andere Logik umfassen, aber die Simulationsteile des Spiels müssen nicht unbedingt wissen, wie die Logik funktioniert. Sie führen schlichtweg die Simulation aus. Manchmal gibt die Logik Signale an die Simulation weiter, und die Simulation reagiert entsprechend.

Es ist vorteilhaft, den Input an einem separaten, in sich geschlossenen Ort zu bearbeiten. Dort werden Input-Ereignisse generiert und dann an die Logik weitergegeben. Was dann passiert, bestimmt die Simulation.

Dies funktioniert gut für Input und Simulation. Es ist jedoch wahrscheinlich, dass Sie bei allem, was mit der Darstellung zu tun hat, auf Probleme stoßen, z. B. bei der Logik, die Spezialeffekte hervorbringt, bei der Aktualisierung Ihrer Wertungszähler und so weiter.

Logik und Darstellung

Die Präsentation muss wissen, was in anderen Systemen vorgeht, aber sie benötigt keinen vollen Zugang zu all diesen Systemen. Wenn möglich, versuchen Sie Logik und Darstellung zu trennen. Versuchen Sie, an den Punkt zu gelangen, an dem Sie Ihre Codebasis in zwei Modi ausführen können: nur Logik und Logik plus Darstellung.

Manchmal müssen Sie Logik und Darstellung verbinden, sodass die Darstellung zum richtigen Zeitpunkt aktualisiert wird. Das Ziel sollte jedoch nach wie vor sein, dass der Darstellung nur das zur Verfügung gestellt wird, was sie zur korrekten Anzeige benötigt, und nicht mehr. Dadurch ergibt sich eine natürliche Grenze zwischen den zwei Teilen, wodurch das Spiel, das Sie entwickeln, weniger komplex wird.

Reine Datenklassen und Helper-Klassen

Manchmal ist es angebracht, eine Klasse mit nur Daten zu haben, ohne die gesamte Logik und alle Operationen, die mit diesen Daten ausgeführt werden können, in die gleiche Klasse aufzunehmen.

Es kann auch sinnvoll sein, Klassen zu erstellen, die über keinerlei Daten verfügen, aber Funktionen zur Manipulation von Objekten beinhalten, die an sie weitergegeben werden.

Statische Methoden

Das Schöne an einer statischen Methode ist, dass Sie – sofern die Methode keine globalen Variablen berührt – den Geltungsbereich, auf den sich die Methode möglicherweise auswirkt, ganz einfach durch Betrachtung dessen identifizieren können, was beim Aufrufen der Methode als Argumente übergeben wird. Sie müssen sich der Implementierung dieser Methode überhaupt nicht widmen.

Dieser Ansatz hat Berührungspunkte mit dem Feld des funktionalen Programmierens. Der Kernbaustein dabei ist: Sie senden etwas an eine Funktion, und die Funktion gibt ein Ergebnis zurück oder ändert vielleicht einen der Ausgabeparameter. Probieren Sie diesen Ansatz aus. Es zeigt sich vielleicht, dass Sie weniger Fehler erhalten als mit dem klassischen objektorientierten Programmieren.

Objekte entkoppeln

Sie können außerdem Objekte entkoppeln, indem Sie Verbindungslogik zwischen ihnen einfügen. Nehmen wir noch einmal das Pong-ähnliche Spiel: Wie kommunizieren die Ball-Logik und die Ergebnisdarstellung miteinander? Wird die Ball-Logik die Ergebnisdarstellung informieren, wenn in Bezug auf den Ball etwas passiert? Wird die Ergebnis-Logik die Ball-Logik abfragen? Sie müssen irgendwie miteinander kommunizieren.

Sie können ein Pufferobjekt anlegen, dessen einziger Zweck darin besteht, den Speicherbereich bereitzustellen, in den die Logik Dinge schreiben und aus dem die Darstellung Dinge lesen kann. Sie könnten alternativ dazwischen eine Warteschlange einfügen, sodass das Logiksystem Dinge in die Warteschlange stellen kann und die Präsentation Dinge aus der Warteschlange liest.

Eine gute Möglichkeit, die Logik von der Präsentation zu entkoppeln, wenn Ihr Spiel wächst, ist ein Nachrichtenbus. Das Kernprinzip bei der Nachrichtenübermittlung besteht darin, dass weder ein Empfänger noch ein Sender über die andere Partei Bescheid weiß, aber beide kennen den Nachrichtenbus/das System. Daher muss eine Ergebnisdarstellung vom Nachrichtensystem über alle Ereignisse informiert werden, die das Ergebnis verändern. Die Spiellogik sendet dann Ereignisse an das Nachrichtensystem, die eine Punkteänderung für einen Spieler angeben. Ein guter Ausgangspunkt für das Entkoppeln der Systeme ist die Nutzung von UnityEvents. Sie können das Schreiben aber auch selbst übernehmen, um separate Busse für unterschiedliche Zwecke zu haben.

Laden von Szenen

Nutzen Sie nicht mehr LoadSceneMode.Single, sondern stattdessen LoadSceneMode.Additive.

Verwenden Sie explizite Entladungen, wenn Sie eine Szene entladen möchten – früher oder später werden Sie einige Objekte während eines Szenenübergangs aktiviert lassen müssen.

Nutzen Sie auch DontDestroyOnLoad nicht mehr. Es verursacht den Verlust der Kontrolle über die Lebensdauer eines Objekts. Wenn Sie nämlich Dinge mit LoadSceneMode.Additive laden, dann entfällt der Gebrauch von DontDestroyOnLoad. Stellen Sie Ihre langlebigen Objekte stattdessen in eine besondere langlebige Szene.

Sauberes und kontrolliertes Beenden

Ein weiterer Tipp, der sich bei allen meinen Spielen als hilfreich erweisen hat: Unterstützen Sie ein sauberes und kontrolliertes Beenden.

Ermöglichen Sie Ihrer Anwendung, praktisch alle Ressourcen freizugeben, bevor die Anwendung beendet wird. Wenn möglich, sollten keine globalen Variablen mehr zugewiesen und keine GameObjects mit DontDestroyOnLoad markiert sein.

Wenn Sie eine bestimmte Reihenfolge für die Art und Weise haben, wie Sie die Dinge beenden, wird es für Sie einfacher sein, Fehler zu erkennen und Ressourcenlecks zu finden. Dadurch wird auch Ihr Unity-Editor in einem guten Zustand belassen, wenn Sie den Wiedergabemodus verlassen. Unity führt beim Verlassen des Wiedergabemodus kein vollständiges Neuladen der Domäne durch. Wenn alles sauber beendet wird, ist es weniger wahrscheinlich, dass der Editor oder irgendein Editor-Modus-Skript seltsames Verhalten zeigt, nachdem Sie Ihr Spiel im Editor ausgeführt haben.

Reduzierung von Fehlern durch das Zusammenführen von Szenedateien

Dazu können Sie ein Versionskontrollsystem wie Git, Perforce oder Plastic verwenden. Speichern Sie alle Assets als Text, und verschieben Sie Objekte aus Szenedateien, indem Sie sie zu Prefabs machen. Teilen Sie schließlich Szenedateien in viele kleinere Szenen auf. Dies kann allerdings zusätzliche Tools erfordern.

Prozessautomatisierung für das Testen von Codes

Wenn Ihr Team in Kürze aus 10 Personen oder mehr bestehen wird, sollten Sie etwas Arbeit in die Prozessautomatisierung investieren.

Als kreativer Programmierer möchten Sie sich mit der einzigartigen, minutiösen Arbeit befassen und das Repetitive nach Möglichkeit der Automatisierung überlassen.

Beginnen Sie damit, indem Sie Tests für Ihren Code entwerfen. Insbesondere wenn Sie Dinge aus MonoBehaviours in reguläre Klassen verlagern, ist es sehr einfach, ein Komponententest-Framework für den Aufbau von Komponententests für Logik und Simulation zu verwenden. Das ist nicht überall sinnvoll, macht den Code jedoch später meistens zugänglicher für andere Programmierer.

Prozessautomatisierung für das Testen von Inhalten

Testen hat nicht nur etwas mit dem Testen von Code zu tun. Sie wollen auch Ihren Inhalt testen. Wenn Sie Inhaltsentwickler in Ihrem Team haben, ist es besser, wenn diese über eine standardisierte Methode zur schnellen Validierung der von ihnen erstellten Inhalte verfügen.

Die Testlogik – wie die Validierung eines Prefabs oder die Validierung einiger Daten, die sie über einen benutzerdefinierten Editor eingegeben haben – sollte den Inhaltsentwicklern leicht zugänglich sein. Wenn sie einfach auf eine Schaltfläche im Editor klicken können und eine schnelle Validierung erhalten, werden sie bald feststellen, dass sie dadurch Zeit einsparen.

Der nächste Schritt danach ist das Einrichten des Unity Test Runner, sodass Sie automatisch regelmäßige erneute Tests erhalten. Sie richten es als Teil Ihres Build-Systems ein, sodass es auch alle Ihre Tests durchführt. Eine gute Vorgehensweise ist die Einrichtung von Benachrichtigungen, sodass Ihre Teamkollegen eine Benachrichtigung via Slack oder E-Mail erhalten, wenn ein Problem auftritt.

Automatisierte Spieldurchgänge erstellen
Haben Ihnen diese Inhalte gefallen?