[Unity 架构] 更好的 Unity 游戏架构

英文原文:https://thegamedev.guru/unity-architecture/a-better-architecture-for-unity-projects/

  在使用 Unity 重新制作 Diamond Dash 工作了六个月后,我可以说我从 Wooga 的工程师那里学到了很多,除了自我反思。 我经常学习软方式,也学习硬方式。 无论如何,在经历了比失败更多的成功之后,我提出了我对伟大架构应该是什么样子的看法。

  无论您是构建 Unity 应用程序还是 Unity 游戏,您是从头开始,或者您只是对当前系统不满意,我认为您会从阅读中受益。

  全面披露:本文档背后的大部分想法和系统实现都是在 Wooga 开发的。 我主要对它们进行了进一步的完善和增强,以适应我们新项目的需求,并且还花时间对其进行了重组并写了一篇关于它的博客文章。 传播知识!

[Unity 架构] 更好的 Unity 游戏架构_第1张图片
让我们开始吧!

1. 依赖注入

  你的类不负责获取他们需要的引用,不要强迫他们这样做。 作为单一责任原则的延伸,他们应该只关注他们定义明确的小任务。

  您可以使用著名的 DI 框架,例如 Zenject 和 StrangeIoC。 但是,如果您有时间,我鼓励您编写自己的 DI 类。 您将通过它学到很多东西,并准备好处理将来可能出现的 DI 问题。 不到 100 行代码就可以写出 1 行代码; 作为参考,您可以查看我在 Unity 中开发 Diamond Dash 时使用的相同 DI 脚本。

  DI 允许您编写更少的代码,而更少的代码意味着出错的可能性更小。 你的代码会更干净,你的开发者会更开心。 DI 系统是伟大架构的关键需求。 不要忽视它。

2.单入口点

  在你的游戏中有一个单一的入口点来初始化和保存全局对象。 这将帮助您创建、配置和维护全局对象:广告管理器、音频系统、调试选项等。同样重要的是您可以设置的显式系统初始化顺序,构建依赖图。

  如果您添加一个系统来检测未处理的异常,则会出现另一个额外的好处。 在这些情况下,您可以向用户显示道歉消息并重新加载初始化场景,以便您重新启动(引导)整个应用程序而不实际退出它。

  一个例子如下:

public class Game : BaseGame
{
    private void Awake()
    {
        DontDestroyOnLoad(this);
        SetupGame();
    }
 
    protected override void BindGame()
    {
        Container.Bind().FromInstance(new FacebookManager());
        Container.Bind().FromInstance(new Backend());
        Container.Bind().FromInstance(new MainPlayer());
    }
 
    protected override IEnumerator LoadProcess()
    {
        yield return new WaitForSeconds(2);
        yield return CommandFactory.Get<LoadMainMenu>().Load(LoadSceneMode.Single);
    }
}

3. 附加场景加载

  小心使用预制件。 始终牢记黄金法则:一旦持有对预制件(基本上是任何其他对象)的引用,其内容将完全(递归)加载到内存中。 这意味着,包括纹理、几何体、其他引用的预制件、音频剪辑等在内的所有资产都将被同步加载。 它们是否被实例化并不重要,因为实例化过程只会创建一个浅拷贝并调用 Awake/Start/OnEnable/… 方法,这在帧速率、内存、电池等方面可能非常昂贵。 动画师是昂贵系统的一个很好的例子。

  我见过一些项目完全在预制件上构建他们的 UI。 这些项目一旦在功能和用户方面扩大规模,就无法再维护这样的系统了。 虽然它背后的想法是良性的,但它在 Unity 生态系统中的转化效果非常差。 例如,您很可能会在移动设备中获得 40 多秒的加载时间。

  更好地解决这个问题的实际选择是什么? 您始终可以使用asset bundles,但维护其管道并不是特别轻松。 我强烈推荐的方式是使用附加场景加载。

  这个想法是有一个根场景(例如主菜单),它可以根据需要以加法和异步的方式动态加载和卸载场景。 它们将自动堆叠在一起,但仍要小心画布排序顺序。 这种方法比天真的预制加载更高级,但有相当大的好处:

  • 内存占用大大减少,因为您随时只加载您需要的目标场景的内容。
  • 加载时间大大减少,因为要处理、加载和反序列化的东西要少得多。
  • 由此产生的层次结构更清晰,组织良好,其布局类似于一堆有凝聚力的屏幕。 更好的组织意味着更有效的工作。

  您可以通过强制每个单独的场景拥有一个负责管理该场景的顶级根对象来很好地实现这一点。 该根对象通常从加载它的场景中访问和初始化以进行进一步配置。

4. 命令

  作为开发人员,我们鼓励设计(自动)可测试的解耦系统。 然而,系统很少独立工作。 它们必须经常协调,即再次耦合,才能正确执行某些过程。

  我拥有的黄金法则之一是:启动进程的对象负责完成和清理它。 例子:

  • 你撒掉咖啡 -> 你清洗它。
  • 函数分配内存 -> 同一个函数释放它,
  • Manager 开始购买 -> 经理完成并清理
  • Manager实例化一个敌人 -> 经理在死亡时将其摧毁
  • 功能阻塞教程中的界面 -> 相同的功能在完成后解除阻塞

  在这种情况下,一个行之有效的想法是命令模式。 行为可以通过这种方式被视为一流的实体。 命令是一个可实例化的类,用于包装方法调用并在完成时销毁。 这样做的好处是我们可以在异步调用中以对象变量的形式存储时间信息。 命令确实以干净的状态开始和结束,并且只有在它们有最终结果(数据、成功/失败)时才返回。 Unity 通过使用协程很好地适应了这种模式。

public class MainMenu : MonoBehaviour
{
    [Inject] private CommandFactory _commandFactory;
 
    private void Start()
    {
        StartCoroutine(OpenPopup());
    }
 
    private IEnumerator OpenPopup()
    {
        var popupCommand = _commandFactory.Get<ShowPopup>();
        yield return popupCommand.Run("Showing popup from main menu");
        Debug.Log("Result: " + popupCommand.Result);
    }
}
 
public class ShowPopup : Command
{
    public Popup.ResultType Result;
 
    public IEnumerator Run(string text)
    {
        var loadSceneCommand = CommandFactory.Get<LoadModalScene<Popup>>();
        yield return loadSceneCommand.Load();
 
        var popup = loadSceneCommand.LoadedRoot;
        popup.Initialize(text);
 
        yield return new WaitUntil(() => popup.Result.HasValue);
        Result = popup.Result.Value;
    }
}
public class Popup : MonoBehaviour
{
    public enum ResultType { Ok, Cancel }
    public ResultType? Result;
    [SerializeField] private Text _sampleText;
 
    public void Initialize(string text)
    {
        _sampleText.text = text;
    }
 
    private void OnOkPressed()
    {
        Result = ResultType.Ok;
    }
 
    private void OnCancelPressed()
    {
        Result = ResultType.Cancel;
    }
}

5. Transaction

setters 可能非常危险,例如:

_mainPlayer.SetGold(userInput);

  一种更安全的方法是将写入操作限制在非常具体的地方,这些地方有一个具体的、明确的原因。 这种额外的安全性可以通过提供一个可注入的只读接口并保持其可写对象引用来简单地实现。

  只读接口(例如 _mainPlayer.GetGold() )可以注入到每种类型中,尤其是在用户界面中,而启用写的对象引用保持实例化但不可注入。

  可写对象仅适用于派生自 Transaction 的类。 事务是原子的,可以远程跟踪以增强安全性和可调试性。 它们在目标类上执行。

public interface IMainPlayer
{
    int Level { get; }
    IResource Gold { get; }
}
 
public class MainPlayer : IMainPlayer
{
    public int Level { get { return _level; } }
    public IResource Gold { get { return _gold; } }
 
    public void ExecuteTransaction(MainPlayerTransaction transaction)
    {
        _injector.Inject(transaction);
        transaction.Execute(this);
        MarkDirty();
    }
    public void SetLevel(int newLevel) { _level = newLevel; }
}
 
public class UpdateAfterRoundTransaction : MainPlayerTransaction
{
    public UpdateAfterRoundTransaction(GameState gameState, string reason)
    {
        _gameState = gameState;
        _reason = reason;
    }
 
    public override void Execute(MainPlayer mainPlayer)
    {
        Debug.Log("Updating after round for reason: " + _reason);
        mainPlayer.SetLevel(_gameState.Level);
        mainPlayer.Gold.Set(_gameState.Gold);
    }
}
 
public class FinishRoundCommand : BaseCommand
{
    public bool Successful;
 
    [Inject] private IMainPlayer _mainPlayer;
    [Inject] private IBackend _backend;
 
    public IEnumerator Run(IngameStatistics statistics)
    {
        Successful = false;
 
        var eorCall = new FinisHRoundCall(statistics);
        yield return _backend.Request(eorCall);
 
        var gameState = eorCall.ParseResponse();
        _mainPlayer.ExecuteTransaction(new UpdateAfterRoundTransaction(gameState, "Normal end of round response"));
        Successful = gameState.Successful;
    }
}

6. 信号/事件与轮询

让我们从简单的非官方定义开始:

  • 信号/事件
    可以订阅这些对象以接收有关值的未来更新的信息。
  • 轮询
    每 x 帧检查一个变量的当前值。

  两者都反映了有机会对变量的变化做出反应的想法,例如 购买包裹后,在金色标签上制作动画。 现在让我们讨论一些相关的差异。

  事件总是比轮询更有效。 时期。 事件的主要缺点是它们的复杂性随着具体过程所需的数量呈指数增长。 例如。 在HP文本框中显示的文本取决于当前生命数量、无限生命等修饰符、互联网连接、教程状态、玩家级别、特殊玩家特权等。此外,您可能会忘记取消注册信号, 这最终将导致致命的崩溃。 在这些情况下,以对 Unity 友好的方式进行轮询通常是更好的选择。

  执行轮询的推荐方法是使用在设置阶段启动一次的协程。 它在后台运行,每次执行时,您都可以确定您正在使用当前状态。

public class LivesView : MonoBehaviour
{
    private void Start()
    {
        StartCoroutine(MainLoop());
    }
 
    private IEnumerator MainLoop()
    {
        var wait = new WaitForSeconds(1);
        while (true)
        {
            var hasUnlimitedLives = _mainPlayer.HasUnlimitedLives;
            var waitForNewLive = _mainPlayer.Lives == 0;
            if (hasUnlimitedLives)
            {
                SetCurrentState(State.Unlimited);
                _livesUnlimitedCountdownTimer.SetTarget(_mainPlayer.Lives.UnlimitedEndDate.Value);
            }
            else if (waitForNewLive)
            {
                SetCurrentState(State.NewLifeIn);
                _newlifeInCountdownTimer.SetTarget(_mainPlayer.DateTimeOfNewLife.Value);
            }
            else
            {
                SetCurrentState(State.Normal);
                if (_mainPlayer.Lives != _lastAmount)
                {
                    _lastAmount = _mainPlayer.Lives;
                    _livesAmountText.AnimateTo(_lastAmount);
                }
            }
            yield return wait;
        }
    }
}

7.构建Pipeline

我设置的构建管道由三种协作技术组成:

  • Docker (可选).
    它允许快速部署预装了构建项目所需的环境(Unity、NDK、Android SDK 等)的 Linux 容器。 一旦你启动它,它就可以编译了,不需要进一步的设置。
    Docker 可以帮助您,尤其是在这里,因为不再需要手动配置和更新(Jenkins?)构建节点。 它们在 Mac 和 Linux 中运行,构建速度非常快。

  • Bitrise
    它是构建运行器软件,将在您的主机(docker 映像或真实主机)中执行。 它负责从高级别的角度管理构建过程。 就我而言:

    • Unity 许可证激活。
    • Unity 构建过程。
    • Unity 许可证停用。
    • 上传到 Hockeyapp/aws/testflight
    • 将构建链接发布到 Slack。

  uTomate/UBS/Jenkins 脚本。 归根结底,您仍然需要一些从低级别控制构建的技术:例如设置版本、更改目标平台、纹理压缩、纹理图集、资产包等。

  如果你身边有经验丰富的人,我建议你试一试。 使用它,您可以获得稳定、强大且可维护的构建管道。 更多信息在我之前的博文中。
  在我们的案例中,我们实现了一些有用的构建步骤来自动化重复性任务:

  • 检查场景未分配的引用
  • 纹理图集创建
  • 纹理压缩格式
  • Asset bundles

我希望这些信息对您有所帮助。 如果您有反馈,请发表评论。 对 Wooga 的人们有很多功劳!

你可能感兴趣的:(Unity,Unity,架构,unity,架构)