在场景之间处理数据的正确方法是什么?


52

我正在Unity中开发我的第一个2D游戏,遇到了一个重要问题。

如何处理场景之间的数据?

对此似乎有不同的答案:

  • 有人提到使用PlayerPrefs,而其他人告诉我,这应该用于存储屏幕亮度等其他内容。

  • 有人告诉我,最好的方法是确保每次更改场景时都将所有内容写入保存游戏,并确保在加载新场景时再次从保存游戏获取信息。在我看来,这在性能上是浪费的。我说错了吗

  • 到目前为止,我已经实现了另一种解决方案,即拥有一个在场景之间不被破坏的全局游戏对象,处理场景之间的所有数据。因此,当游戏开始时,我会加载一个开始场景,并在其中加载该对象。在此之后,它将加载第一个真实的游戏场景,通常是一个主菜单。

这是我的实现:

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

该对象可以在我的其他类上进行处理,如下所示:

private GameController gameController = GameController.Instance;

到目前为止,这种方法行之有效,但却给我带来了一个大问题:如果我想直接加载一个场景,比如说游戏的最终关卡,我不能直接加载它,因为该场景不包含该场景。全局游戏对象

我是否以错误的方式处理此问题?对于这种挑战是否有更好的做法?我很想听听您对这个问题的看法,想法和建议。

谢谢

Answers:


64

在此答案中列出的是处理这种情况的基本方法。虽然,这些方法大多数都不适合大型项目。如果您想要更具可伸缩性的东西,并且不怕弄脏手,请查看Lea Hayes关于依赖注入框架的答案


1.仅用于保存数据的静态脚本

您可以创建一个静态脚本以仅保存数据。由于它是静态的,因此您无需将其分配给GameObject。您可以像访问其他一样简单地访问数据ScriptName.Variable = data;

优点:

  • 无需实例或单例。
  • 您可以从项目中的任何地方访问数据。
  • 无需额外的代码即可在场景之间传递值。
  • 单个类数据库脚本中的所有变量和数据使处理它们变得容易。

缺点:

  • 您将无法在静态脚本内使用协程。
  • 如果组织不善,您可能会在单个类中最终得到大量的变量。
  • 您无法在编辑器内分配字段/变量。

一个例子:

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

如果您需要将脚本分配给GameObject或从MonoBehavior派生,则可以DontDestroyOnLoad(gameObject);在类中添加一行,该行可以执行一次Awake()通常将其放入)

优点:

  • 所有MonoBehaviour作业(例如协程)都可以安全完成。
  • 您可以在编辑器内分配字段。

缺点:

  • 您可能需要根据脚本来调整场景。
  • 您可能需要检查加载了哪个secene,以确定在Update或其他常规功能/方法中要做什么。例如,如果您要使用Update()中的UI进行操作,则需要检查是否加载了正确的场景来完成该工作。这导致了if-else或switch-case检查的负担。

3. PlayerPrefs

如果您还希望即使游戏关闭也要存储数据,则可以实施此操作。

优点:

  • 由于Unity处理所有后台进程,因此易于管理。
  • 您不仅可以在场景之间传递数据,还可以在实例(游戏会话)之间传递数据。

缺点:

  • 使用文件系统。
  • 数据可以从prefs文件轻松更改。

4.保存到文件

这对于在场景之间存储值有点过大。如果您不需要加密,那么我不建议您使用这种方法。

优点:

  • 您可以控制保存的数据,而不是PlayerPrefs。
  • 您不仅可以在场景之间传递数据,还可以在实例(游戏会话)之间传递数据。
  • 您可以传输文件(用户生成的内容概念依赖此文件)。

缺点:

  • 慢。
  • 使用文件系统。
  • 保存时可能因流中断而导致读取/加载冲突。
  • 除非您实施加密,否则可以轻松地从文件中更改数据(这会使代码变慢。)

5.单例模式

单例模式是面向对象编程中一个非常热门的话题。有些建议,有些则没有。自己进行研究,并根据您的项目条件进行适当的调用。

优点:

  • 易于设置和使用。
  • 您可以从项目中的任何地方访问数据。
  • 单个类数据库脚本中的所有变量和数据使处理它们变得容易。

缺点:

  • 许多样板代码,唯一的工作就是维护和保护单例实例。
  • 有很多反对使用单例模式的论点。保持谨慎并事先进行研究。
  • 由于实施不佳而可能发生数据冲突。
  • 团结可能很难处理单例模式1

1:在Unify Wiki中提供OnDestroySingleton Script方法的摘要中,您可以看到作者描述了从运行时渗入编辑器的虚幻对象

Unity退出时,它将以随机顺序销毁对象。原则上,仅在应用程序退出时销毁Singleton。如果在销毁实例后有任何脚本调用Instance,它将创建一个有虫的鬼对象,即使在停止播放应用程序之后,该对象也将保留在Editor场景中。特别糟糕!因此,这样做是为了确保我们不会创建该越野车鬼对象。


8

稍微高级一点的选项是使用Zenject之类的框架执行依赖项注入。

这使您可以随意构造应用程序。例如,

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

然后,您可以将类型绑定到IoC(控制反转)容器。使用Zenject可以在a MonoInstaller或a 内部执行此操作ScriptableInstaller

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

PlayerProfile然后,将的单例实例注入通过Zenject实例化的其他类中。理想情况下,通过构造函数注入,但是也可以通过使用Zenject的Inject属性注释属性和字段来实现注入。

后一种属性技术用于自动注入场景中的游戏对象,因为Unity为您实例化了这些对象:

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


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

无论出于何种原因,您可能还希望按接口而不是按实现类型来绑定实现。(免责声明,下面的例子不应该是一个了不起的例子;我怀疑您是否希望在此特定位置保存/加载方法...但这只是一个示例,说明实现方式可能会有所不同)。

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

然后可以像以前一样将其绑定到IoC容器:

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,
    }
}

3

您做事很好。这就是我这样做的方式,显然也是很​​多人这样做的方式,因为存在此自动加载程序脚本(您可以将场景设置为在您点击Play时自动自动加载):http : //wiki.unity3d.com/index.php/ SceneAutoLoader

前两个选项也是游戏在会话之间保存游戏可能需要的东西,但是这些都是解决此问题的错误工具。


我只是阅读了您发布的一些链接。好像有一种方法可以自动加载我正在加载全局Game Object的初始场景。它看起来有点复杂,所以我将需要一些时间来确定它是否可以解决我的问题。感谢您的反馈意见!
恩里克·莫雷诺

我链接到的脚本解决了该问题,因为您可以在任何场景中播放,而不必每次都记住要切换到启动场景。它仍然从头开始游戏,而不是直接从最后一级开始。您可以作弊,以使您可以跳到任何级别,或者只是修改自动加载脚本以将级别传递给游戏。
2015年

是的,很好。麻烦不仅在于必须记住切换到开始场景的“烦恼”,还在于必须四处寻找以加载特定级别的内容。不管怎么说,还是要谢谢你!
恩里克·莫雷诺

1

在场景之间存储变量的理想方法是通过单例管理器类。通过创建一个用于存储持久性数据的类,并将该类设置为DoNotDestroyOnLoad(),可以确保可以立即访问该类并在场景之间持久化。

您还有另一个选择是使用PlayerPrefs该类。PlayerPrefs旨在允许您在播放会话之间保存数据,但是它仍然是在场景之间保存数据的一种手段。

使用单例类和 DoNotDestroyOnLoad()

以下脚本创建一个持久性单例类。单例类是旨在仅同时运行一个实例的类。通过提供此类功能,我们可以安全地创建静态自引用,以便从任何地方访问该类。这意味着您可以使用直接访问该类DataManager.instance,包括该类中的任何公共变量。

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

您可以在下面看到正在运行的单例。请注意,一旦运行初始场景,DataManager对象就会从特定于场景的标题移到层次结构视图上的“ DontDestroyOnLoad”标题。

屏幕快照记录了正在加载的多个场景,而DataManager保留在“ DoNotDestroyOnLoad”标题下。

使用PlayerPrefs课程

Unity有一个内置类来管理称为的基本持久性数据PlayerPrefs。提交给该PlayerPrefs文件的任何数据都将在游戏会话中持续存在,因此自然地,它能够在场景之间保留数据。

PlayerPrefs文件可以存储类型的变量stringintfloat。当我们将值插入PlayerPrefs文件时,我们会提供一个额外string的键。我们使用相同的键稍后从PlayerPref文件中检索我们的值。

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

请注意,在处理PlayerPrefs文件时,我采取了其他预防措施:

  • 我已将每个键另存为private static string。这使我可以保证我一直在使用正确的密钥,这意味着如果出于任何原因必须更改密钥,则无需确保更改所有对其的引用。
  • PlayerPrefs写入文件后,将文件保存到磁盘。如果您未在播放会话之间实现数据持久性,则这可能不会有所作为。PlayerPrefs 在正常应用程序关闭保存到磁盘,但是如果您的游戏崩溃,它可能不会自然调用。
  • 其实,我检查每个键存在PlayerPrefs,之前我尝试检索与它相关联的值。这看起来似乎毫无意义的重复检查,但这是一个好习惯。
  • 我有一种Delete立即擦除PlayerPrefs文件的方法。如果您不打算在播放会话中包括数据持久性,则可以考虑在上调用此方法Awake。通过清除PlayerPrefs在每场比赛开始的文件,可以确保任何数据与前一交易日坚持不误从数据处理当前会话。

您可以PlayerPrefs在下面看到实际情况。请注意,当我单击“保存数据”时,我直接调用该Save方法,而当我单击“加载数据”时,我直接调用了该Load方法。您自己的实现可能会有所不同,但是它展示了基础知识。

通过Save()和Load()函数从检查器中覆盖了持续传递的数据的屏幕记录。


最后一点,我要指出,您可以扩展basic PlayerPrefs,以存储更多有用的类型。JPTheK9 为类似的问题提供了一个很好的答案,在该问题中JPTheK9 提供了一个脚本,用于将数组序列化为字符串形式并存储在PlayerPrefs文件中。他们还向我们指向Unify Community Wiki用户在该PlayerPrefsX站点上载了更广泛的脚本,以支持更多类型的向量,例如向量和数组。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.