Qual é a maneira correta de lidar com dados entre cenas?

52

Estou desenvolvendo meu primeiro jogo 2D no Unity e me deparei com o que parece ser uma pergunta importante.

Como lidar com dados entre cenas?

Parece haver respostas diferentes para isso:

  • Alguém mencionou o uso do PlayerPrefs , enquanto outras pessoas me disseram que isso deve ser usado para armazenar outras coisas, como brilho da tela e assim por diante.

  • Alguém me disse que a melhor maneira era certificar-se de escrever tudo em um jogo de salvamento toda vez que eu mudasse de cena e garantir que, quando a nova cena for carregada, obtenha as informações do jogo de salvamento novamente. Isso me pareceu um desperdício de desempenho. Eu estava errado?

  • A outra solução, que eu implementei até agora, é ter um objeto de jogo global que não seja destruído entre as cenas, manipulando todos os dados entre as cenas. Então, quando o jogo começa, eu carrego uma Cena Inicial em que este objeto está carregado. Depois que isso termina, ele carrega a primeira cena real do jogo, geralmente um menu principal.

Esta é a minha implementação:

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;
    //...
}

Este objeto pode ser tratado em minhas outras classes como esta:

private GameController gameController = GameController.Instance;

Embora isso tenha funcionado até agora, ele me apresenta um grande problema: se eu quiser carregar diretamente uma cena, digamos, por exemplo, o nível final do jogo, não posso carregá-lo diretamente, pois essa cena não contém esse objeto de jogo global .

Estou lidando com esse problema da maneira errada? Existem melhores práticas para esse tipo de desafio? Gostaria muito de ouvir suas opiniões, pensamentos e sugestões sobre este assunto.

obrigado

Tenda Enrique Moreno
fonte

Respostas:

64

Listadas nesta resposta estão as formas fundamentais de lidar com essa situação. Embora a maioria desses métodos não seja adequada para projetos grandes. Se você deseja algo mais escalável e não tem medo de sujar as mãos, confira a resposta de Lea Hayes sobre as estruturas de injeção de dependência .


1. Um script estático para armazenar apenas dados

Você pode criar um script estático para armazenar apenas dados. Como é estático, você não precisa atribuí-lo a um GameObject. Você pode simplesmente acessar seus dados como ScriptName.Variable = data;etc.

Prós:

  • Nenhuma instância ou singleton necessário.
  • Você pode acessar dados de qualquer lugar do seu projeto.
  • Nenhum código extra para passar valores entre as cenas.
  • Todas as variáveis ​​e dados em um único script semelhante ao banco de dados facilitam o manuseio deles.

Contras:

  • Você não poderá usar uma Coroutine dentro do script estático.
  • Você provavelmente acabará com enormes linhas de variáveis ​​em uma única classe, se não se organizar bem.
  • Você não pode atribuir campos / variáveis ​​dentro do editor.

Um exemplo:

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

Se você precisar que seu script seja atribuído a um GameObject ou derivado do MonoBehavior, você poderá adicionar uma DontDestroyOnLoad(gameObject);linha à sua classe onde possa ser executada uma vez (colocá-lo Awake()geralmente é o caminho a seguir) .

Prós:

  • Todos os trabalhos do MonoBehaviour (por exemplo, Coroutines) podem ser realizados com segurança.
  • Você pode atribuir campos dentro do editor.

Contras:

  • Você provavelmente precisará ajustar sua cena, dependendo do script.
  • Você provavelmente precisará verificar qual secene está carregado para determinar o que fazer no Update ou em outras funções / métodos gerais. Por exemplo, se você estiver fazendo algo com a interface do usuário em Update (), precisará verificar se a cena correta está carregada para fazer o trabalho. Isso causa um monte de verificações de caso contrário ou de caixa de opção.

3. PlayerPrefs

Você pode implementar isso se também quiser que seus dados sejam armazenados, mesmo que o jogo seja fechado.

Prós:

  • Fácil de gerenciar, pois o Unity lida com todo o processo em segundo plano.
  • Você pode transmitir dados não apenas entre cenas, mas também entre instâncias (sessões de jogos).

Contras:

  • Usa sistema de arquivos.
  • Os dados podem ser facilmente alterados a partir do arquivo prefs.

4. Salvando em um arquivo

Isso é um pouco exagerado para armazenar valores entre as cenas. Se você não precisa de criptografia, eu o desencorajo com esse método.

Prós:

  • Você está no controle dos dados salvos, em oposição aos PlayerPrefs.
  • Você pode transmitir dados não apenas entre cenas, mas também entre instâncias (sessões de jogos).
  • Você pode transferir o arquivo (o conceito de conteúdo gerado pelo usuário depende disso).

Contras:

  • Lento.
  • Usa sistema de arquivos.
  • Possibilidade de ler / carregar conflitos causados ​​pela interrupção do fluxo ao salvar.
  • Os dados podem ser facilmente alterados a partir do arquivo, a menos que você implemente uma criptografia (o que tornará o código ainda mais lento).

5. Padrão Singleton

O padrão Singleton é um tópico muito importante na programação orientada a objetos. Alguns sugerem, e outros não. Pesquise você mesmo e faça a ligação apropriada, dependendo das condições do seu projeto.

Prós:

  • Fácil de configurar e usar.
  • Você pode acessar dados de qualquer lugar do seu projeto.
  • Todas as variáveis ​​e dados em um único script semelhante ao banco de dados facilitam o manuseio deles.

Contras:

  • Muitos códigos padrão, cuja única tarefa é manter e proteger a instância singleton.
  • Existem fortes argumentos contra o uso do padrão singleton . Seja cauteloso e faça sua pesquisa com antecedência.
  • Possibilidade de conflito de dados devido a má implementação.
  • A unidade pode ter dificuldade em lidar com padrões de singleton 1 .

1 : No resumo do OnDestroymétodo do Script Singleton fornecido no Unify Wiki , é possível ver o autor descrevendo objetos fantasmas que sangram no editor a partir do tempo de execução:

Quando o Unity sai, destrói objetos em uma ordem aleatória. Em princípio, um Singleton só é destruído quando o aplicativo é encerrado. Se algum script chamar Instância depois de destruído, ele criará um objeto fantasma de buggy que permanecerá na cena do Editor, mesmo depois de parar de reproduzir o Aplicativo. Muito ruim! Portanto, isso foi feito para garantir que não estamos criando esse objeto fantasma de buggy.

S. Tarık Çetin
fonte
8

Uma opção um pouco mais avançada é executar a injeção de dependência com uma estrutura como o Zenject .

Isso deixa você livre para estruturar seu aplicativo da maneira que desejar; por exemplo,

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

Em seguida, você pode vincular o tipo ao contêiner de IoC (inversão de controle). Com o Zenject, essa ação é executada dentro de um MonoInstallerou um ScriptableInstaller:

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

A instância singleton de PlayerProfileé então injetada em outras classes que são instanciadas via Zenject. Idealmente através da injeção de construtor, mas também é possível a injeção de propriedades e campos anotando-as com o Injectatributo de Zenject .

A última técnica de atributo é usada para injetar automaticamente os objetos de jogo da sua cena, pois o Unity instancia esses objetos para você:

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


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

Por qualquer motivo, convém vincular uma implementação pela interface, e não pelo tipo de implementação. (Isenção de responsabilidade, o seguinte não deve ser um exemplo incrível; duvido que você queira métodos Save / Load nesse local específico ... mas isso apenas mostra um exemplo de como as implementações podem variar no comportamento).

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()
    {
        ...
    }
}

Que pode então ser vinculado ao contêiner de IoC da mesma maneira que antes:

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
fonte
3

Você está fazendo as coisas de uma maneira boa. É do jeito que eu faço, e claramente do jeito que muitas pessoas fazem, porque esse script de carregador automático (você pode definir uma cena para carregar automaticamente primeiro sempre que clicar em Play) existe: http://wiki.unity3d.com/index.php/ SceneAutoLoader

As duas primeiras opções também são coisas que o seu jogo pode precisar para salvar o jogo entre as sessões, mas essas são ferramentas erradas para esse problema.

jhocking
fonte
Acabei de ler um pouco do link que você postou. Parece que existe uma maneira de carregar automaticamente a cena inicial em que estou carregando o Game Game global. Parece um pouco complexo, então precisarei de um tempo para decidir se é algo que resolve meu problema. Obrigado pelo seu feedback!
Enrique Moreno Tent
O script que eu vinculei resolveu esse problema, em que você pode tocar em qualquer cena em vez de ter que se lembrar de mudar para a cena de inicialização toda vez. Ele ainda inicia o jogo desde o início, em vez de começar diretamente no último nível; você pode colocar um truque para permitir que você pule para qualquer nível ou apenas modificar o script de carregamento automático para passar o nível para o jogo.
Jhocking 07/11
Sim, bem. O problema não era tanto o "aborrecimento" de ter que se lembrar de mudar para a cena inicial, mas também o de ter que invadir para carregar o nível específico em mente. Obrigado mesmo assim!
Enrique Moreno Tent
1

Uma maneira ideal de armazenar variáveis ​​entre cenas é através de uma classe de gerenciador único. Criando uma classe para armazenar dados persistentes e configurando-a para DoNotDestroyOnLoad(), você pode garantir que ele seja imediatamente acessível e persista entre as cenas.

Outra opção que você tem é usar a PlayerPrefsclasse. PlayerPrefsfoi projetado para permitir que você salve dados entre sessões de reprodução , mas ainda servirá como um meio para salvar dados entre cenas .

Usando uma classe singleton e DoNotDestroyOnLoad()

O script a seguir cria uma classe singleton persistente. Uma classe singleton é uma classe projetada para executar apenas uma única instância ao mesmo tempo. Ao fornecer essa funcionalidade, podemos criar com segurança uma auto-referência estática, para acessar a classe de qualquer lugar. Isso significa que você pode acessar diretamente a classe DataManager.instance, incluindo quaisquer variáveis ​​públicas dentro da classe.

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);
    }
}

Você pode ver o singleton em ação, abaixo. Observe que, assim que executo a cena inicial, o objeto DataManager passa do cabeçalho específico da cena para o cabeçalho "DontDestroyOnLoad", na exibição da hierarquia.

Uma gravação de tela de várias cenas sendo carregadas, enquanto o DataManager persiste sob o cabeçalho "DoNotDestroyOnLoad".

Usando a PlayerPrefsclasse

O Unity foi construído em uma classe para gerenciar dados persistentes básicos chamadosPlayerPrefs . Quaisquer dados confirmados no PlayerPrefsarquivo persistirão nas sessões do jogo ; portanto, naturalmente, ele é capaz de persistir os dados nas cenas.

O PlayerPrefsarquivo pode armazenar variáveis ​​dos tipos string, inte float. Quando inserimos valores no PlayerPrefsarquivo, fornecemos um adicional stringcomo chave. Usamos a mesma chave para recuperar posteriormente nossos valores do PlayerPrefarquivo.

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();
    }
}

Observe que tomo precauções adicionais ao manipular o PlayerPrefsarquivo:

  • Eu salvei cada chave como um private static string. Isso me permite garantir que estou sempre usando a chave certa e significa que, se eu precisar alterar a chave por qualquer motivo, não preciso garantir que altere todas as referências a ela.
  • Eu salvo o PlayerPrefsarquivo no disco depois de gravá -lo. Provavelmente, isso não fará diferença se você não implementar a persistência de dados nas sessões de reprodução. PlayerPrefs será salvo no disco durante o fechamento normal de um aplicativo, mas poderá não ser chamado naturalmente se o jogo travar.
  • Na verdade, verifico se cada chave existe no PlayerPrefs, antes de tentar recuperar um valor associado a ele. Isso pode parecer uma verificação dupla inútil, mas é uma boa prática.
  • Eu tenho um Deletemétodo que limpa imediatamente o PlayerPrefsarquivo. Se você não pretende incluir persistência de dados nas sessões de reprodução, considere chamar esse método Awake. Ao limpar o PlayerPrefsarquivo no início de cada jogo, você garante que todos os dados que se persistem desde a sessão anterior não é erroneamente tratados como os dados da atual sessão.

Você pode ver PlayerPrefsem ação abaixo. Observe que, quando clico em "Salvar dados", estou chamando o Savemétodo diretamente e, quando clico em "Carregar dados", estou chamando o Loadmétodo diretamente . Sua própria implementação provavelmente varia, mas demonstra o básico.

Uma gravação de tela dos dados persistentes passados ​​é substituída pelo inspetor, por meio das funções Salvar () e Carregar ().


Como nota final, devo salientar que você pode expandir o básico PlayerPrefs, para armazenar tipos mais úteis. O JPTheK9 fornece uma boa resposta para uma pergunta semelhante , na qual eles fornecem um script para serializar matrizes na forma de cadeia de caracteres, para serem armazenadas em um PlayerPrefsarquivo. Eles também nos apontam para o Wiki da Comunidade Unify , onde um usuário enviou um PlayerPrefsXscript mais abrangente para permitir o suporte a uma variedade maior de tipos, como vetores e matrizes.

Gnemlock
fonte