Was ist der richtige Umgang mit Daten zwischen Szenen?

52

Ich entwickle mein erstes 2D-Spiel in Unity und bin auf eine wichtige Frage gestoßen.

Wie gehe ich mit Daten zwischen Szenen um?

Darauf scheint es unterschiedliche Antworten zu geben:

  • Jemand erwähnte die Verwendung von PlayerPrefs , während andere mir sagten, dass dies verwendet werden sollte, um andere Dinge wie die Bildschirmhelligkeit usw. zu speichern.

  • Jemand sagte mir, dass es der beste Weg sei, bei jedem Szenenwechsel alles in ein Savegame zu schreiben und beim Laden der neuen Szene die Informationen aus dem Savegame erneut abzurufen. Dies schien mir in der Leistung verschwenderisch. Lag ich falsch?

  • Die andere Lösung, die ich bisher implementiert habe, besteht darin, ein globales Spielobjekt zu haben , das zwischen Szenen nicht zerstört wird und alle Daten zwischen Szenen verarbeitet. Wenn das Spiel startet, lade ich eine Startszene, in die dieses Objekt geladen wird. Danach wird die erste echte Spielszene geladen, normalerweise ein Hauptmenü.

Dies ist meine Implementierung:

using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class GameController : MonoBehaviour {

    // Make global
    public static GameController Instance {
        get;
        set;
    }

    void Awake () {
        DontDestroyOnLoad (transform.gameObject);
        Instance = this;
    }

    void Start() {
        //Load first game scene (probably main menu)
        Application.LoadLevel(2);
    }

    // Data persisted between scenes
    public int exp = 0;
    public int armor = 0;
    public int weapon = 0;
    //...
}

Dieses Objekt kann in meinen anderen Klassen wie folgt behandelt werden:

private GameController gameController = GameController.Instance;

Obwohl dies bisher funktioniert hat, stellt es mich vor ein großes Problem: Wenn ich eine Szene direkt laden möchte, zum Beispiel das letzte Level des Spiels, kann ich es nicht direkt laden, da diese Szene dies nicht enthält globales Spielobjekt .

Behandle ich dieses Problem falsch? Gibt es bessere Praktiken für diese Art von Herausforderung? Ich würde gerne Ihre Meinungen, Gedanken und Vorschläge zu diesem Thema hören.

Vielen Dank

Enrique Moreno Zelt
quelle

Antworten:

64

In dieser Antwort sind die grundlegenden Möglichkeiten aufgeführt, mit dieser Situation umzugehen. Die meisten dieser Methoden lassen sich jedoch nicht gut auf große Projekte skalieren. Wenn Sie etwas Skalierbareres möchten und keine Angst haben, sich die Hände schmutzig zu machen, lesen Sie die Antwort von Lea Hayes zu den Frameworks von Dependency Injection .


1. Ein statisches Skript, das nur Daten enthält

Sie können ein statisches Skript erstellen, das nur Daten enthält. Da es statisch ist, müssen Sie es keinem GameObject zuweisen. Sie können einfach auf Ihre Daten zugreifen, wie z ScriptName.Variable = data;.

Vorteile:

  • Keine Instanz oder Singleton erforderlich.
  • Sie können von überall in Ihrem Projekt auf Daten zugreifen.
  • Kein zusätzlicher Code zum Übergeben von Werten zwischen Szenen.
  • Alle Variablen und Daten in einem einzigen datenbankähnlichen Skript erleichtern die Handhabung.

Nachteile:

  • Sie können keine Coroutine innerhalb des statischen Skripts verwenden.
  • Sie werden wahrscheinlich riesige Zeilen mit Variablen in einer einzelnen Klasse haben, wenn Sie nicht gut organisiert sind.
  • Sie können im Editor keine Felder / Variablen zuweisen.

Ein Beispiel:

public static class PlayerStats
{
    private static int kills, deaths, assists, points;

    public static int Kills 
    {
        get 
        {
            return kills;
        }
        set 
        {
            kills = value;
        }
    }

    public static int Deaths 
    {
        get 
        {
            return deaths;
        }
        set 
        {
            deaths = value;
        }
    }

    public static int Assists 
    {
        get 
        {
            return assists;
        }
        set 
        {
            assists = value;
        }
    }

    public static int Points 
    {
        get 
        {
            return points;
        }
        set 
        {
            points = value;
        }
    }
}

2. DontDestroyOnLoad

Wenn Sie Ihr Skript muss ein Gameobject zugeordnet werden oder leiten sich von MonoBehavior, dann können Sie hinzufügen DontDestroyOnLoad(gameObject);Zeile in die Klasse , wo es einmal ausgeführt werden kann (Platzierung es in Awake()ist usally der Weg für diese zu gehen) .

Vorteile:

  • Alle MonoBehaviour-Jobs (zum Beispiel Coroutines) können sicher ausgeführt werden.
  • Sie können Felder im Editor zuweisen.

Nachteile:

  • Wahrscheinlich müssen Sie Ihre Szene je nach Skript anpassen.
  • Möglicherweise müssen Sie überprüfen, welches secene geladen ist, um festzustellen, was in Update oder anderen allgemeinen Funktionen / Methoden zu tun ist. Wenn Sie beispielsweise in Update () etwas mit der Benutzeroberfläche tun, müssen Sie überprüfen, ob die richtige Szene geladen ist, um den Job auszuführen. Dies führt zu einer Vielzahl von If-else- oder Switch-Case-Überprüfungen.

3. PlayerPrefs

Sie können dies implementieren, wenn Sie möchten, dass Ihre Daten auch dann gespeichert werden, wenn das Spiel geschlossen wird.

Vorteile:

  • Einfach zu verwalten, da Unity den gesamten Hintergrundprozess abwickelt.
  • Sie können Daten nicht nur zwischen Szenen, sondern auch zwischen Instanzen (Spielsitzungen) übertragen.

Nachteile:

  • Verwendet das Dateisystem.
  • Daten können einfach aus der Prefs-Datei geändert werden.

4. Speichern in eine Datei

Dies ist ein wenig zu viel für das Speichern von Werten zwischen Szenen. Wenn Sie keine Verschlüsselung benötigen, rate ich Ihnen von dieser Methode ab.

Vorteile:

  • Sie haben die Kontrolle über die im Gegensatz zu PlayerPrefs gespeicherten Daten.
  • Sie können Daten nicht nur zwischen Szenen, sondern auch zwischen Instanzen (Spielsitzungen) übertragen.
  • Sie können die Datei übertragen (benutzergeneriertes Inhaltskonzept stützt sich darauf).

Nachteile:

  • Schleppend.
  • Verwendet das Dateisystem.
  • Möglichkeit von Lese- / Ladekonflikten durch Stream-Unterbrechung beim Speichern.
  • Daten können einfach aus der Datei geändert werden, es sei denn, Sie implementieren eine Verschlüsselung (wodurch der Code noch langsamer wird).

5. Singleton-Muster

Singleton-Pattern ist ein sehr aktuelles Thema in der objektorientierten Programmierung. Einige schlagen es vor, andere nicht. Informieren Sie sich selbst und rufen Sie entsprechend den Bedingungen Ihres Projekts an.

Vorteile:

  • Einfach einzurichten und zu verwenden.
  • Sie können von überall in Ihrem Projekt auf Daten zugreifen.
  • Alle Variablen und Daten in einem einzigen datenbankähnlichen Skript erleichtern die Handhabung.

Nachteile:

  • Viele Boilerplate-Codes, deren einzige Aufgabe es ist, die Singleton-Instanz zu warten und zu sichern.
  • Es gibt starke Argumente gegen die Verwendung von Singleton-Mustern . Seien Sie vorsichtig und recherchieren Sie vorher.
  • Möglichkeit eines Datenkonflikts aufgrund einer schlechten Implementierung.
  • Unity hat möglicherweise Schwierigkeiten, mit Singleton-Mustern umzugehen 1 .

1 : In der Zusammenfassung der OnDestroyMethode von Singleton Script, die im Unify Wiki bereitgestellt wird , können Sie sehen, wie der Autor Geisterobjekte beschreibt , die zur Laufzeit in den Editor eingeblutet wurden:

Wenn Unity beendet wird, werden Objekte in zufälliger Reihenfolge zerstört. Grundsätzlich wird ein Singleton nur dann zerstört, wenn die Anwendung beendet wird. Wenn ein Skript Instance aufruft, nachdem es zerstört wurde, wird ein fehlerhaftes Geisterobjekt erstellt, das auch nach dem Beenden der Wiedergabe der Anwendung in der Editor-Szene verbleibt. Wirklich schlecht! Das wurde gemacht, um sicherzugehen, dass wir dieses fehlerhafte Geisterobjekt nicht erstellen.

S. Tarık Çetin
quelle
8

Eine etwas fortgeschrittenere Option ist die Durchführung einer Abhängigkeitsinjektion mit einem Framework wie Zenject .

So können Sie Ihre Anwendung nach Belieben strukturieren. zum Beispiel,

public class PlayerProfile
{
    public string Nick { get; set; }
    public int WinCount { get; set; }
}

Anschließend können Sie den Typ an den IoC-Container (Inversion of Control) binden. Mit Zenject wird diese Aktion in a MonoInstalleroder a ausgeführt ScriptableInstaller:

public class GameInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        this.Container.Bind<PlayerProfile>()
            .ToSelf()
            .AsSingle();
    }
}

Die Singleton-Instanz von PlayerProfilewird dann in andere Klassen injiziert, die über Zenject instanziiert werden. Idealerweise durch Konstruktorinjektion, aber Eigenschafts- und Feldinjektion ist auch durch Annotieren mit dem Zenject- InjectAttribut möglich.

Die letztere Attributtechnik wird verwendet, um die Spielobjekte Ihrer Szene automatisch einzufügen, da Unity diese Objekte für Sie instanziiert:

public class WinDetector : MonoBehaviour
{
    [Inject]
    private PlayerProfile playerProfile = null;


    private void OnCollisionEnter(Collision collision)
    {
        this.playerProfile.WinCount += 1;
        // other stuff...
    }
}

Aus irgendeinem Grund möchten Sie eine Implementierung möglicherweise auch nach Schnittstelle und nicht nach Implementierungstyp binden. (Haftungsausschluss, das Folgende ist kein hervorragendes Beispiel. Ich bezweifle, dass Sie Methoden zum Speichern / Laden an diesem bestimmten Ort verwenden möchten. Dies zeigt jedoch nur ein Beispiel dafür, wie Implementierungen im Verhalten variieren können.)

public interface IPlayerProfile
{
    string Nick { get; set; }
    int WinCount { get; set; }

    void Save();
    void Load();
}

[JsonObject]
public class PlayerProfile_Json : IPlayerProfile
{
    [JsonProperty]
    public string Nick { get; set; }
    [JsonProperty]
    public int WinCount { get; set; }


    public void Save()
    {
        ...
    }

    public void Load()
    {
        ...
    }
}

[ProtoContract]
public class PlayerProfile_Protobuf : IPlayerProfile
{
    [ProtoMember(1)]
    public string Nick { get; set; }
    [ProtoMember(2)]
    public int WinCount { get; set; }


    public void Save()
    {
        ...
    }

    public void Load()
    {
        ...
    }
}

Welches dann auf ähnliche Weise wie zuvor an den IoC-Container gebunden werden kann:

public class GameInstaller : MonoInstaller
{
    // The following field can be adjusted using the inspector of the
    // installer component (in this case) or asset (in the case of using
    // a ScriptableInstaller).
    [SerializeField]
    private PlayerProfileFormat playerProfileFormat = PlayerProfileFormat.Json;


    public override void InstallBindings()
    {
        switch (playerProfileFormat) {
            case PlayerProfileFormat.Json:
                this.Container.Bind<IPlayerProfile>()
                    .To<PlayerProfile_Json>()
                    .AsSingle();
                break;

            case PlayerProfileFormat.Protobuf:
                this.Container.Bind<IPlayerProfile>()
                    .To<PlayerProfile_Protobuf>()
                    .AsSingle();
                break;

            default:
                throw new InvalidOperationException("Unexpected player profile format.");
        }
    }


    public enum PlayerProfileFormat
    {
        Json,
        Protobuf,
    }
}
Lea Hayes
quelle
3

Sie machen die Dinge auf eine gute Art und Weise. So mache ich es und genau so machen es viele Leute, denn dieses Autoloader-Skript (Sie können festlegen, dass eine Szene automatisch geladen wird, wenn Sie auf "Wiedergabe" klicken) ist vorhanden: http://wiki.unity3d.com/index.php/ SceneAutoLoader

Beide der ersten beiden Optionen sind auch Dinge, die Ihr Spiel möglicherweise benötigt, um das Spiel zwischen den Sitzungen zu speichern. Dies sind jedoch falsche Tools für dieses Problem.

schockierend
quelle
Ich habe gerade ein wenig von dem Link gelesen, den Sie gepostet haben. Es scheint, als gäbe es eine Möglichkeit, die offizielle Szene, in der ich das globale Spielobjekt lade, automatisch zu laden. Es sieht ein wenig komplex aus, daher werde ich einige Zeit brauchen, um zu entscheiden, ob es etwas ist, das mein Problem löst. Vielen Dank für Ihr Feedback!
Enrique Moreno Zelt
Das Skript, mit dem ich verlinkt habe, löst dieses Problem, indem Sie in jeder Szene spielen können, anstatt jedes Mal zur Startszene wechseln zu müssen. Es startet das Spiel jedoch immer noch von vorne, anstatt direkt im letzten Level zu beginnen. Sie können einen Cheat eingeben, um zu einem beliebigen Level zu springen, oder einfach das Autoload-Skript ändern, um das Level an das Spiel zu übergeben.
jhocking
Nun ja. Das Problem war nicht so sehr das "Ärgernis", sich daran erinnern zu müssen, zur Startszene zu wechseln, als vielmehr herumhacken zu müssen, um das bestimmte Level zu laden. Danke trotzdem!
Enrique Moreno Zelt
1

Eine ideale Methode zum Speichern von Variablen zwischen Szenen ist die Verwendung einer Singleton-Manager-Klasse. Indem Sie eine Klasse zum Speichern persistenter Daten erstellen und diese Klasse auf DoNotDestroyOnLoad()festlegen, können Sie sicherstellen, dass sofort auf sie zugegriffen werden kann und sie zwischen Szenen bestehen bleibt.

Eine weitere Option ist die Verwendung der PlayerPrefsKlasse. PlayerPrefsdient zum Speichern von Daten zwischen Wiedergabesitzungen , dient jedoch weiterhin zum Speichern von Daten zwischen Szenen .

Verwenden einer Singleton-Klasse und DoNotDestroyOnLoad()

Das folgende Skript erstellt eine persistente Singleton-Klasse. Eine Singleton-Klasse ist eine Klasse, die so konzipiert ist, dass sie nur eine Instanz gleichzeitig ausführt. Durch die Bereitstellung dieser Funktionalität können wir sicher eine statische Selbstreferenz erstellen, um von überall auf die Klasse zuzugreifen. Dies bedeutet, dass Sie direkt auf die Klasse zugreifen können DataManager.instance, einschließlich aller öffentlichen Variablen innerhalb der Klasse.

using UnityEngine;

/// <summary>Manages data for persistance between levels.</summary>
public class DataManager : MonoBehaviour 
{
    /// <summary>Static reference to the instance of our DataManager</summary>
    public static DataManager instance;

    /// <summary>The player's current score.</summary>
    public int score;
    /// <summary>The player's remaining health.</summary>
    public int health;
    /// <summary>The player's remaining lives.</summary>
    public int lives;

    /// <summary>Awake is called when the script instance is being loaded.</summary>
    void Awake()
    {
        // If the instance reference has not been set, yet, 
        if (instance == null)
        {
            // Set this instance as the instance reference.
            instance = this;
        }
        else if(instance != this)
        {
            // If the instance reference has already been set, and this is not the
            // the instance reference, destroy this game object.
            Destroy(gameObject);
        }

        // Do not destroy this object, when we load a new scene.
        DontDestroyOnLoad(gameObject);
    }
}

Unten sehen Sie den Singleton in Aktion. Beachten Sie, dass das DataManager-Objekt in der Hierarchieansicht von der szenenspezifischen Überschrift in die Überschrift "DontDestroyOnLoad" verschoben wird, sobald ich die erste Szene ausführe.

Eine Bildschirmaufnahme mehrerer geladener Szenen, während der DataManager unter der Überschrift "DoNotDestroyOnLoad" gespeichert bleibt.

Mit der PlayerPrefsKlasse

Unity verfügt über eine integrierte Klasse zur Verwaltung grundlegender persistenter DatenPlayerPrefs . Alle Daten, die an die PlayerPrefsDatei übergeben werden, bleiben während der gesamten Spielsitzung erhalten , sodass sie natürlich auch szenenübergreifend gespeichert werden können.

Die PlayerPrefsDatei kann Variablen der Typen speichern string, intund float. Wenn wir Werte in die PlayerPrefsDatei einfügen , geben wir einen zusätzlichen stringals Schlüssel an. Wir verwenden denselben Schlüssel, um unsere Werte später aus der PlayerPrefDatei abzurufen .

using UnityEngine;

/// <summary>Manages data for persistance between play sessions.</summary>
public class SaveManager : MonoBehaviour 
{
    /// <summary>The player's name.</summary>
    public string playerName = "";
    /// <summary>The player's score.</summary>
    public int playerScore = 0;
    /// <summary>The player's health value.</summary>
    public float playerHealth = 0f;

    /// <summary>Static record of the key for saving and loading playerName.</summary>
    private static string playerNameKey = "PLAYER_NAME";
    /// <summary>Static record of the key for saving and loading playerScore.</summary>
    private static string playerScoreKey = "PLAYER_SCORE";
    /// <summary>Static record of the key for saving and loading playerHealth.</summary>
    private static string playerHealthKey = "PLAYER_HEALTH";

    /// <summary>Saves playerName, playerScore and 
    /// playerHealth to the PlayerPrefs file.</summary>
    public void Save()
    {
        // Set the values to the PlayerPrefs file using their corresponding keys.
        PlayerPrefs.SetString(playerNameKey, playerName);
        PlayerPrefs.SetInt(playerScoreKey, playerScore);
        PlayerPrefs.SetFloat(playerHealthKey, playerHealth);

        // Manually save the PlayerPrefs file to disk, in case we experience a crash
        PlayerPrefs.Save();
    }

    /// <summary>Saves playerName, playerScore and playerHealth 
    // from the PlayerPrefs file.</summary>
    public void Load()
    {
        // If the PlayerPrefs file currently has a value registered to the playerNameKey, 
        if (PlayerPrefs.HasKey(playerNameKey))
        {
            // load playerName from the PlayerPrefs file.
            playerName = PlayerPrefs.GetString(playerNameKey);
        }

        // If the PlayerPrefs file currently has a value registered to the playerScoreKey, 
        if (PlayerPrefs.HasKey(playerScoreKey))
        {
            // load playerScore from the PlayerPrefs file.
            playerScore = PlayerPrefs.GetInt(playerScoreKey);
        }

        // If the PlayerPrefs file currently has a value registered to the playerHealthKey,
        if (PlayerPrefs.HasKey(playerHealthKey))
        {
            // load playerHealth from the PlayerPrefs file.
            playerHealth = PlayerPrefs.GetFloat(playerHealthKey);
        }
    }

    /// <summary>Deletes all values from the PlayerPrefs file.</summary>
    public void Delete()
    {
        // Delete all values from the PlayerPrefs file.
        PlayerPrefs.DeleteAll();
    }
}

Beachten Sie, dass ich beim Umgang mit der PlayerPrefsDatei zusätzliche Vorsichtsmaßnahmen einhalte:

  • Ich habe jeden Schlüssel als gespeichert private static string. Auf diese Weise kann ich garantieren, dass ich immer den richtigen Schlüssel verwende. Wenn ich den Schlüssel aus irgendeinem Grund ändern muss, muss ich nicht sicherstellen, dass ich alle Verweise darauf ändere.
  • Ich speichere die PlayerPrefsDatei nach dem Schreiben auf die Festplatte. Dies wird wahrscheinlich keinen Unterschied machen, wenn Sie die Datenpersistenz nicht über mehrere Wiedergabesitzungen hinweg implementieren. PlayerPrefs wird während des Schließens einer normalen Anwendung auf der Festplatte gespeichert, wird jedoch möglicherweise nicht automatisch aufgerufen, wenn Ihr Spiel abstürzt.
  • Ich tatsächlich überprüfen , dass jeder Schlüssel existiert in der PlayerPrefs, bevor ich mit ihr verbundenen Wert abzurufen versuchen. Dies mag sinnlos erscheinen, aber es ist eine gute Übung, dies zu überprüfen.
  • Ich habe eine DeleteMethode, die die PlayerPrefsDatei sofort löscht . Wenn Sie nicht beabsichtigen, die Datenpersistenz über Wiedergabesitzungen hinweg zu berücksichtigen, können Sie diese Methode auch aufrufen Awake. Durch Löschen der PlayerPrefsDatei zu Beginn jedes Spiels, stellen Sie sicher , dass alle Daten , die haben aus der vorherigen Sitzung bestehen nicht fälschlicherweise als Daten aus der behandelt aktuellen Sitzung.

PlayerPrefsUnten sehen Sie in Aktion. Wenn ich auf "Daten speichern" klicke, rufe ich die SaveMethode direkt auf und wenn ich auf "Daten laden" klicke, rufe ich die LoadMethode direkt auf . Ihre eigene Implementierung wird wahrscheinlich variieren, zeigt aber die Grundlagen.

Über die Funktionen Speichern () und Laden () wird eine Bildschirmaufzeichnung von Daten überschrieben, die noch vorhanden sind.


Abschließend möchte ich darauf hinweisen, dass Sie das Grundlegende erweitern PlayerPrefskönnen, um nützlichere Typen zu speichern. JPTheK9 bietet eine gute Antwort auf eine ähnliche Frage , in der ein Skript zum Serialisieren von Arrays in Zeichenfolgenform zum Speichern in einer PlayerPrefsDatei bereitgestellt wird . Sie verweisen auch auf das Unify Community-Wiki , in dem ein Benutzer ein umfangreicheres PlayerPrefsXSkript hochgeladen hat , um die Unterstützung einer größeren Vielfalt von Typen wie Vektoren und Arrays zu ermöglichen.

Gnemlock
quelle