Du bist nicht angemeldet.

Stilllegung des Forums
Das Forum wurde am 05.06.2023 nach über 20 Jahren stillgelegt (weitere Informationen und ein kleiner Rückblick).
Registrierungen, Anmeldungen und Postings sind nicht mehr möglich. Öffentliche Inhalte sind weiterhin zugänglich.
Das Team von spieleprogrammierer.de bedankt sich bei der Community für die vielen schönen Jahre.
Wenn du eine deutschsprachige Spieleentwickler-Community suchst, schau doch mal im Discord und auf ZFX vorbei!

Werbeanzeige

Garzec

Alter Hase

  • »Garzec« ist der Autor dieses Themas

Beiträge: 693

Wohnort: Gießen

  • Private Nachricht senden

1

13.03.2017, 09:16

Sauberer Code in Unity

Guten Morgen,

in Unity schreibt man den Code ja erstmal in ein Script, zieht das Script auf das Objekt und gut ist. Ich würde innerhalb des Scriptes aber gerne die Daten, die Controllermethoden und das optische Zeug voneinander trennen, also nach dem MVC-Pattern. Ich habe hier mal eine Beispielklasse

C#-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class PlayerHealth : MonoBehaviour
{
    private float currentHealth = 100;
    private float maxHealth = 100;

    [SerializeField]
    private Image healthBar;
    
    [SerializeField]
    private Text healthText;

    [SerializeField]
    GameObject popupText;

    private void Start()
    {
        UpdateHealthBar();
    }

    public void ChangeHealth(float valueToAdd) // Verrechnet den Schaden / die Heilung
    {
        CreatePopup(valueToAdd);
        currentHealth = Mathf.Clamp(currentHealth + valueToAdd, 0, maxHealth);
        UpdateHealthBar();
        if (currentHealth <= 0)
            Destroy(gameObject);
    }

    private void UpdateHealthBar() // GUI
    {
        healthBar.rectTransform.localScale = new Vector3(currentHealth / maxHealth, 1, 1);
        healthText.text = currentHealth + " / " + maxHealth;
    }

    void CreatePopup(float value) // Popup Text an der Spielerposition erzeugen
    {
        bool isNegativeValue = value < 0;

        GameObject popup = Instantiate(popupText, transform.position, popupText.transform.rotation);
        PopupController controller = popup.GetComponent<PopupController>();
        controller.PopupText.text = isNegativeValue ? value.ToString() : "+" + value;
        controller.TextColor = isNegativeValue ? new Color(0,1,0,1) : new Color(1,0,0,1);
        controller.MovementDirection = isNegativeValue ? Vector2.down : Vector2.up;
    }
}


Ich persönlich hätte die ganzen Daten oben in eine separate Klasse gepackt. Ebenso das Erzeugen des Popups und das aktualisieren der HealthBar. In der Klasse selbst, würde sich also nur noch der technische Hintergrund befinden, die ChangeHealth() Methode. Dadurch wird es ja leichter mit der Vererbung, bei weiteren Features, die genau die gleiche Struktur besitzen, wie das Leben zB. Dadurch kann ich ja in der Basis 1x eine Methode schreiben und sie mehrfach nutzen lassen..

Nun zu meiner Frage, sollte ich die Klasse so lassen, wie sie ist oder ist es in Ordnung / macht es Sinn, das Ganze zu trennen? Da Unity ja nach seinem eigenen Pattern aufgebaut ist, würde ich das gerne mal wissen.

Renegade

Alter Hase

Beiträge: 494

Wohnort: Berlin

Beruf: Certified Unity Developer

  • Private Nachricht senden

2

13.03.2017, 20:14

Hey Garzec,

ja eine Trennung von Logik und Daten macht Sinn. Ich habe hierfür ein paar praktische Faustregeln, die mir als Entwickler schon bei vielen Projekten deutlich geholfen haben:

1. Klassen die von MonoBehaviour ableiten sind lediglich für Referenzen zu Objekten in der Szene (GameObject, Transform, Button, Camera etc. pp.) zuständig
2. Einstellbare Daten gehören in ein ScriptableObject und werden im Projekt-Folder verwaltet und NICHT in der Szene (MaxHealth, Armor, MoveSpeed etc. pp.)
3. Logik sowie Daten die zur Laufzeit benötigt werden, gehören weder in ein MonoBehaviour noch in ein ScriptableObject
4. Um die Connection zu den Unity-Magic-Methods zu erhalten (Start/Update etc. pp.) lohnt sich eine reguläre Main/Controller-Klasse (die als Einzige neben den Editorreferenzen ein MonoBehaviour ist und in der Szene liegen muss)

Hier mal die Trennung anhand deines Beispiels:

C#-Quelltext

1
2
3
4
5
6
public class EditorReferences : MonoBehaviour {

    public Image healthBar;
    public Text healthText;
    public GameObject popupText;
}


C#-Quelltext

1
2
3
4
5
[CreateAssetMenu]
public class GameSettings : ScriptableObject {

    public float maxHealth = 100.0f;
}


C#-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
public class Main : MonoBehaviour {

    public EditorReferences editorReferences;
    public GameSettings gameSettings;
    
    private PlayerHealth playerHealth;

    private void Start() {
          playerHealth = new PlayerHealth(editorReferences, gameSettings);
          //You have a well constructed object now. No hidden init or start method...
    }
}


C#-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PlayerHealth {

    private float currentHealth = 100.0f;

    public PlayerHealth(EditorReferences editorReferences, GameSettings gameSettings) {
        editorReferences.healthBar.rectTransform.localScale = new Vector3(currentHealth / gameSettings.maxHealth, 1, 1);
        editorReferences.healthText.text = currentHealth + " / " + gameSettings.maxHealth;
        //or save the specific references/settings you need....
    }
    /* 
    ....
    */
}


Sieht am Anfang erstmal umständlich aus, aber das Ganze hat einige Vorteile:
1. Du hast einen sauberen Konstruktionsprozess und kannst Konstruktoren verwenden
2. Daten die nur einmalig vorhanden sein müssen (wie maxHealth), reservieren nun nicht mehr auf JEDEN PlayerHealth-MonoBehaviour Speicher obwohl der Wert überall gleich ist
3. Daten die global eingestellt werden sollen (über ScriptableObject) können vom Designer sehr easy durch Testsettings ausgetauscht werden, oder durch die Coder gesonder serialisiert etc. pp.
4. Magic-Methods wie z.B. die Update können deutlich Performanceeinbußen bringen, wenn sie auf einer Vielzahl von GameObjects aufgerufen werden, im Gegensatz zu einer Schleife über ein Array (10 Update() calls)
5. Die Logik kann, wenn man Unity-Methoden und Datenstrukturen wrappt auch völlig ohne Unity funktionieren (Stichwort Portabilität und Simulation)
6. Es ist deutlich übersichtlicher einen Haupteinstiegspunkt (e.g. Main, anstatt dutzenden von MonoBehaviours mit Awakes und Starts) (...außerdem auch deutlich wartbarer imo) zu haben
7. Um 1. nochmal zu wiederholen: Man kann Konstruktoren nutzen! In Kombination mit Properties und event Callbacks ein Segen für saubere Datenkapselung!

Es gibt noch viel mehr zum Thema, aber das soll's erstmal sein :)

PS: Ich bin kein großer Freund von MVC für Spiele. Ich empfehle ein ECS - zumindest wenn es das Spielkonzept her gibt.
PPS: Zum Thema Methode in Basis-Klasse schreiben und mehrfach nutzen: Composition > Inheritance! Es macht natürlich super Sinn, die Popup-Logik von der Health-Logik zu trennen. Ggf. möchte man Popups auch für andere Dinge nutzen? Kleine spezialisierte Klassen sind hier der Schlüssel und deutlich wart- und erweiterbarer als große, (möglicherweise) aufgeblähte Ableitungsklassen.
Liebe Grüße,
René

Dieser Beitrag wurde bereits 9 mal editiert, zuletzt von »Renegade« (13.03.2017, 20:49)


Garzec

Alter Hase

  • »Garzec« ist der Autor dieses Themas

Beiträge: 693

Wohnort: Gießen

  • Private Nachricht senden

3

13.03.2017, 22:49

Ja, habe ich ähnlich, ist anfangs mehr Arbeit, hat aber einen riesigen Vorteil für später. Ok, ich mach weiter so :)

Sneyke

Frischling

Beiträge: 33

Beruf: Softwareentwickler

  • Private Nachricht senden

4

14.03.2017, 14:32

Alle Achtung Renegade. Sehr schön geschildert. Ich habe vor einiger Zeit mal angekündigt ein Paar Codefetzen zu "sauberer Programmierung" in Verbindung mit Unity einzustellen. Hatte aber leider bis jetzt keine Zeit dafür. Aber dein Post hat mir die Arbeit eigentlich schon abgenommen. Besser hätt ich das nicht hinbekommen. Es wär cool wenn man solche Beiträge mal sammeln könnte und als "Not that bad practices"-Thread an Coding-Anfänger weitergeben könnte.


Interessant wäre für mich jetzt noch zu wissen:

Wenn ich jetzt in der Scene mehrere Objects habe die alle einen bestimmten Script brauchen (z.B. ein normaler Gegner), die aber keine Updatemethode besitzen, wie updatest du dann? Implementierst du dann eine eigene Updatemethode die du selbst kontrollieren kannst? Oder hast du sowas wie eine Zentrale Controllerklasse?

Renegade

Alter Hase

Beiträge: 494

Wohnort: Berlin

Beruf: Certified Unity Developer

  • Private Nachricht senden

5

14.03.2017, 22:40

Alle Achtung Renegade. Sehr schön geschildert. Ich habe vor einiger Zeit mal angekündigt ein Paar Codefetzen zu "sauberer Programmierung" in Verbindung mit Unity einzustellen. Hatte aber leider bis jetzt keine Zeit dafür. Aber dein Post hat mir die Arbeit eigentlich schon abgenommen. Besser hätt ich das nicht hinbekommen. Es wär cool wenn man solche Beiträge mal sammeln könnte und als "Not that bad practices"-Thread an Coding-Anfänger weitergeben könnte.


Interessant wäre für mich jetzt noch zu wissen:

Wenn ich jetzt in der Scene mehrere Objects habe die alle einen bestimmten Script brauchen (z.B. ein normaler Gegner), die aber keine Updatemethode besitzen, wie updatest du dann? Implementierst du dann eine eigene Updatemethode die du selbst kontrollieren kannst? Oder hast du sowas wie eine Zentrale Controllerklasse?


Hey Sneyke,
danke Dir für das Lob!

Prinzipiell füge ich der Main-Klasse (mein zentraler Einstiegspunkt) eine Update-Methode hinzu und gebe dann meist via Konstruktor und Update-Schnittstelle den jeweiligen Logik-Klassen die Event-Callback mit. Klappt auch als klassischer Observer. Im Prinzip eine Frage inwiefern die Main/Controller-Klasse das Registrieren, Updaten und Deregistrieren steuern muss. In beiden Fällen besteht der Vorteil, dass ganz klar durch den Konstruktor des Gegners gekennzeichnet ist, dass dieser geupdated werden muss. Ein deutliches Plus für die Kapselung und natürlich auch für die Testbarkeit (Stichwort Unit-Tests).

Hier mal ein kleines Beispiel:

C#-Quelltext

1
2
3
4
5
public delegate void Update();

public interface IUpdate {
    event Update update;
}


C#-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main : MonoBehaviour, IUpdate {

    public event Update update;    
    
    private Enemy enemy;

    private void Start() {
          enemy = new Enemy(this);
    }

    private void Update() {
          update?.Invoke();
    }
}


C#-Quelltext

1
2
3
4
5
6
7
8
9
10
11
public class Enemy {
    
    public Enemy(IUpdate update) {
        update.update += Update;
        //Just save the reference to the IUpdate Interface if you like to Unregister later...
    }

    private void Update() {
        //Im getting updated after the construction
    }
}


Ggf. nochmal zur Verdeutlichung: Die Gegner die in der Szene sind, gehören in keiner Weise zur Logik und sollten auch nichts über eine Update wissen. Alle Assets in der Szene sind lediglich Teil der Darstellung/des Views und werden der jeweiligen Logik-Klasse a) als EditorReference (e.g. MonoBehaviour), oder b) via GameSettings (e.g. ScriptableObject, als Feld z.B. Prefab zur Erstellung) per Konstruktor übergeben (Je nachdem ob Ressourcen bereits in der Szene liegen oder zur Laufzeit noch erstellt werden müssen). Wie dann mit z.B. der Geometrie oder den Animationen des Gegners umgegangen wird, entscheidet die Enemy-Klasse. Diese ist aber weder MonoBehaviour noch ScriptableObject.

Bei den Unity-Magic-Methoden die auf dem jeweiligen GameObject liegen müssen und nicht direkt in der Main-Klasse sein können (z.B. OnTrigger, OnCollision, OnVisible etc. pp.) verwende ich meist ein simples Helfer-MonoBehaviour, welches wie alle anderen Ressourcen aus der Szene mittels EditorReference-Container an die jeweiligen Logik-Klassen übergeben wird. Das könnte dann so oder ähnlich aussehen (Natürlich auch hier ist eine IOnTriggerEnter-Schnittstelle denkbar):

C#-Quelltext

1
2
3
4
5
6
7
8
9
10
public delegate void OnTriggerEnter(Collider other);

public class OnTriggerEnter : MonoBehaviour {
    
    public event OnTriggerEnter onTriggerEnter;

    private void OnTriggerEnter(Collider other) {
        onTriggerEnter?.Invoke(other);
    }
}


Zum Abschluss sei vielleicht nochmal gesagt, dass ich natürlich nicht alles stumpf in eine GameSettings- und einen EditorReference-Container schmeisse. Das mag bis zu einer gewissen Menge sicherlich funktionieren (oder auf dem Global Game Jam), aber in einer echten Produktion leidet dadurch schnell die Übersicht. Idealerweise nutzt man hier weitere kleine, verschachtelte Container um eine Übersicht zu verschaffen. Zum Beispiel könnte man einen allgemeinen Container für die UI mit dem Namen UIEditorReferences nehmen und dieser besteht wiederum aus Containern wie UIMenuEditorReferences und UIGameEditorReferences. Diese können wiederum aus weiteren Containern für Buttons, Grafiken, Transitions etc. pp. bestehen. Der große Vorteil bei dieser Vorgehensweise ist, dass durch die Komposition a) Daten mit einer Zugehörigkeit auch tatsächlich bei einander stehen und b) man via Konstruktor auch nur jene Container übergeben muss die auch tatsächlich nötig sind. Für mich hat sich als Best Practice dabei erwiesen, dass jene Daten in Container zwar public, aber lediglich als readonly zu behandeln sind. Sollten Daten verändert, kopiert oder zerstört werden müssen, verwende ich üblicherweise weitere Datenstrukturen die zur Laufzeit erzeugt werden (MonoBehaviour-Unabhängig sozusagen).
Liebe Grüße,
René

Dieser Beitrag wurde bereits 4 mal editiert, zuletzt von »Renegade« (14.03.2017, 22:50)


Sneyke

Frischling

Beiträge: 33

Beruf: Softwareentwickler

  • Private Nachricht senden

6

15.03.2017, 09:04

Danke für die Erklärung. Man merkt dass da Professionalität dahinter steckt. Woher hast du das know-how eigentlich her? Weil durch normales googlen erhält man ja weitgehend nur so lulli-infos wie in den meisten (Anfänger-)Tutorials.

BlueCobold

Community-Fossil

Beiträge: 10 738

Beruf: Teamleiter Mobile Applikationen & Senior Software Engineer

  • Private Nachricht senden

7

15.03.2017, 10:48

Es wär cool wenn man solche Beiträge mal sammeln könnte und als "Not that bad practices"-Thread an Coding-Anfänger weitergeben könnte.

Konjunktive sind immer schlecht, dann macht es keiner ;) Also selber machen. Wie? Da oben gibt's einen Wiki-Link. Einfach dort rein.
Teamleiter von Rickety Racquet (ehemals das "Foren-Projekt") und von Marble Theory

Willkommen auf SPPRO, auch dir wird man zu Unity oder zur Unreal-Engine raten, ganz bestimmt.[/Sarkasmus]

Sneyke

Frischling

Beiträge: 33

Beruf: Softwareentwickler

  • Private Nachricht senden

8

15.03.2017, 11:26

Stimmt schon :D Dann werd ich mal mal Zeit nehmen dafür

Renegade

Alter Hase

Beiträge: 494

Wohnort: Berlin

Beruf: Certified Unity Developer

  • Private Nachricht senden

9

19.03.2017, 21:04

Danke für die Erklärung. Man merkt dass da Professionalität dahinter steckt. Woher hast du das know-how eigentlich her? Weil durch normales googlen erhält man ja weitgehend nur so lulli-infos wie in den meisten (Anfänger-)Tutorials.


Hey Sneyke,
ich arbeite als professioneller Unity Developer seit mehreren Jahren bei verschiedenen Unternehmen in der Berliner Spieleindustrie. Wenn du mehr zu bestimmten Problemstellungen und meiner Vorgehensweise wissen möchtest, kannst du gerne fragen - Ich stehe Rede und Antwort :)

Bezüglich eines Wiki-Eintrages hatte ich mal hier begonnen eine Diskussion anzusetzen. Da hat sich aber leider auch nach über 500 Aufrufen keiner gemeldet, der mit mir etwas erarbeiten möchte.
Liebe Grüße,
René

Sneyke

Frischling

Beiträge: 33

Beruf: Softwareentwickler

  • Private Nachricht senden

10

20.03.2017, 09:14

Ich hab in das Wiki Thema mal eine kleine Einleitung geschrieben und einen Link eingestellt. Besser als nichts schon mal. Ich glaube man muss da einfach mal anfangen. Die Interesse der Community wird dann schon von allein kommen... oder auch nicht ^^

Werbeanzeige