您想找什么?
Hero background image
Last updated May 2019, 10 min. read
如何根据项目规模构建代码

您将从此页面获得什么:了解为不断扩展的项目构建代码以使项目扩展保持整洁并减少问题的有效策略。随着项目持续扩展,您必须反复修改并清理项目设计。退后一步,暂停更改,将代码拆分成更小的元素以进行整理,然后再次整合,这始终是应对此类问题的黄金准则。

本文作者是瑞典游戏工作室 Fall Damage 的首席技术官 Mikael Kalms,其拥有 20 多年的游戏开发和发行经验。从业多年,他仍然对如何构建代码来让项目以安全和高效的方式发展兴致勃勃。

如何根据项目规模构建代码
从简单到复杂

让我们看一些我的团队为我的 Unite Berlin 演讲制作的非常基本的 Pong 风格游戏的代码示例。从上图可以看出,有两个桨和四面墙(顶部和底部、左侧和右侧),上面有一些游戏逻辑和得分 UI。我们为球拍和墙编写了简单的脚本。

此示例基于以下几个关键原则:

  • 一个“东西”= 一个预制件
  • 每个“东西”的自定义逻辑 = 一个MonoBehavior
  • Application = 包含相互连接的预制件的场景

这些原则适用于像这样的非常简单的项目,但如果我们希望它发展壮大,就必须改变结构。那么,我们可以采用哪些策略来整理代码?

如何根据项目规模构建代码_组件参数
实例、预制件和 ScriptableObject

首先,我们来理清实例、预制件和 ScriptableObject 之间的差异。上面是在 Inspector 中看到的 1 号玩家球拍 GameObject 上的球拍组件:

我们可以看到图中有三个参数。然而,该视图中没有任何内容可以提示底层代码需要我做什么。

对我来说,通过在实例上更改 Input Axis 来更改左侧球拍的 Input Axis 有意义吗?或者说,我应该在预制件中这样做吗?我假设两位玩家的 Input Axis 有所不同,这样一来,我们或许应该在实例中对其作出更改。那 Movement Speed Scale 呢?这是我应该在实例或预制件上更改的内容么?

我们来看看表示球拍组件的代码。

如何根据项目规模构建代码_代码中的参数
简单代码示例中的参数

如果我们停下来思考一下,就会发现我们在程序中以不同的方法使用不同的参数。我们应该为每个玩家单独更改 InputAxisName:MovementSpeedScaleFactor 和 PositionScale 应该由两个玩家共享。下述策略可以指导您在何时使用实例、预制件和 ScriptableObject:

  • 您只需要使用某个东西一次?那就创建预制件,然后将其实例化。
  • 您需要多次使用某个东西,并且很可能与一些针对实例的修改搭配使用?那么您可以创建预制件,将其实例化,然后重载某些设置。
  • 您想确保设置在多个实例中完全相同?那就改为创建 ScriptableObject 并在其中创建源数据。

在下一个代码示例中,了解我们如何将 ScriptableObject 与球拍组件配合使用。

如何根据项目规模构建代码_使用 ScriptableObject
使用 ScriptableObject

由于我们已将这些设置移动到类型为 PaddleData 的 ScriptableObject 中,所以我们的球拍组件中只有对该 PaddleData 的引用。最终,我们会在 Inspector 中得到两样东西:一组 PaddleData 和两个球拍实例。您仍然可以更改轴名称和每个球拍指向的共享设置数据包。借助这一新结构,您可以更轻松地理解不同设置背后的 Intent。

如何根据项目规模构建代码_单一职责原则
拆分大型 MonoBehavior

如果游戏处于实际开发状态,您会发现各个 MonoBehavior 的规模不断扩大。我们来看看如何通过所谓的单一职责原则来拆分它们,该原则规定每个类应该处理一个单一对象。如果正确应用,你应该能够对以下问题做出简短的回答:“某个特定的类做什么?”以及“它不做什么?”这使得团队中的每个开发人员都能轻松理解各个类的作用。您可以将此原则应用到任何大小的代码库。我们来看一个如上图所示的简单示例。

这显示了一个球的代码。内容看起来不多,但经过仔细观察,我们发现该球有一个速度,而设计师在设置球的初始速度矢量和自制物理模拟以追踪球的当前速度时都会用到这一速度。

我们出于两个略有不同的目的来重复利用同一个变量。一旦球开始移动,关于初始速度的信息就会丢失。

自制物理模拟不仅仅是FixedUpdate()中的移动,还包括球击中墙面时的反应。

Destroy() 操作是 OnTriggerEnter() 回调中的深层代码。触发逻辑会在该处删除自己的 GameObject。在大型代码库中,我们很少允许实体删除它们自己;相反,我们倾向于让所有者删除他们拥有的东西。

现在我们有机会将东西拆分成更小的部分。这些类中有许多不同的职责类型:游戏逻辑、输入处理、物理模拟和演示等。

以下是创建更小部分的方法:

  • 常规游戏逻辑、输入处理、物理模拟和演示可以驻留在MonoBehavior、ScriptableObject或原始的C#类中。
  • 如要在检视面板中展示参数,可以使用MonoBehavior或ScriptableObject。
  • 引擎事件管理程序和GameObject的生命周期管理需要驻留在MonoBehavior中。

我认为,对许多游戏而言,从 MonoBehavior 中尽可能多地获取代码是非常划算的做法。使用 ScriptableObject 便是实现此目的的一种方法,并且已有关于这一方法的一些优质资源。

如何根据项目规模构建代码_将 monobehavior 转变为 C# 类
从 MonoBehavior 到常规 C# 类

另一种方法是将 MonoBehavior 移动到常规 C# 类,但这样做有哪些好处?

常规 C# 类拥有比 Unity 的自有对象更好的语言设施,可以将代码分解成可组合的小数据块。此外,我们还可以在 Unity 外将常规 C# 代码分享给本机的 .NET 代码库。

另一方面,如果您使用常规C#类,编辑器将无法理解对象,也无法在检视面板中以原始的面貌呈现对象等等。

使用此方法,您可以按照职责类型拆分逻辑。回到球的示例,我们已将简单的物理模拟移动到我们称之为 BallSimulation 的 C# 类中。它唯一要做的就是进行物理集成并在球碰到东西时作出反应。

然而,对球模拟来说,根据其实际击中的物体来做出决定是否可行呢?这听起来更像是游戏逻辑。我们最终会得到拥有逻辑部分的球,该逻辑部分会以某些方式控制模拟,并将模拟结果馈送回 MonoBehavior 中。

如果研究上面重组后的版本,我们会发现一个重大变化,那就是 Destroy() 操作不会再隐藏在许多层下面。此时 MoneBehavior 中只剩下几个明确的职责区域。

对此我们还有更多发现。如果查看 FixedUpdate() 中的位置更新逻辑,我们会发现代码需要发送一组位置信息,然后球模拟会返回一组新的位置信息。球模拟并非真正拥有球的位置,而是根据代码提供的球的位置运行模拟标记,然后返回结果。

如何根据项目规模构建代码_使用接口
使用接口

如果我们使用接口,那么我们或许可以与该模拟共享球 MonoBehavior 的一部分,即该模拟需要的部分(见上图)。

我们再看一遍代码。Ball 类实现一个简单接口。借助 LocalPositionAdapter 类,我们可以将对 Ball 对象的引用传递至另一个类。我们并不传递整个 Ball 对象,而是只传递它的 LocalPositionAdapter 方面。

BallLogic 还需要通知 Ball 何时销毁 GameObject。Ball 不仅会返回标记,还会为 BallLogic 提供委托。这就是重组版本中标记的最后一行所做的工作。这为我们提供了一个简洁的设计:代码中存在许多样板逻辑,但每个类都有严格明确的目的。

借助以上原则,您可让单人项目保持良好的结构。

如何根据项目规模构建代码_软件架构
软件架构

我们再看看规模稍大的项目的软件架构解决方案。我们以这个小球游戏为例,只要开始向代码中引入更多指定的类(例如 BallLogic、BallSimulation 等),我们就应该能够构建层级视图:

MonoBehaviour 需要了解所有其他内容,因为它恰好要封装所有其他逻辑,但是游戏的模拟代码不一定需要了解逻辑的运作方式。这次代码只运行模拟。逻辑有时会将信号馈送到模拟中,然后模拟会作出相应的反应。

在分开的独立环境中处理输入大有裨益。此类环境会生成输入事件,然后将其馈送到逻辑中,而接下来发生的一切都取决于模拟。

这非常适用于输入和模拟。然而,您可能会遇到与演示有关的任何问题,例如产生特殊效果的逻辑、更新得分计数器等等。

逻辑和演示

演示需要知道其他系统中发生的事情,但无需完全访问所有系统。如有可能,请尝试将逻辑与演示分开。试着明确您可以在何处以纯逻辑模式和逻辑加演示模式运行代码库。

有时您需要连接逻辑和演示,以便演示可以在合适的时间获得更新。不过,我们的目标应该是只提供正确显示内容所需的演示,仅此而已。通过这种方式,您将在这两个部分之间得到自然的边界,这会降低您正在开发的游戏的整体复杂度。

纯数据类和 Helper 类

将数据整合至同一类需要合并其所有逻辑和操作,而有时您无需这样做,只需创建一个只包含数据的类即可。

您也可以创建除函数外不包含任何数据的类,这些函数的目的是操控其指定对象。

静态方法

静态方法的好处在于,如果您假定它不涉及任何全局变量,那么您可以通过查看在调用方法时作为参数传入的内容来确定方法可能影响的范围。您根本无需研究此方法的实现过程。

此方法涉及函数编程领域。其核心构建代码块是:您向函数中发送一些内容,函数返回一个结果或者修改其中一个 out 参数。试试这种方法,您可能会发现,与您进行传统的面向对象编程相比,这样做出现的错误更少。

解耦对象

您还可以通过在对象间插入粘合逻辑来解耦对象。还以这个 Pong 风格的游戏为例:Ball 逻辑将如何与 Score 演示相互沟通?当与球有关的内容发生变化时,Ball 逻辑会通知 Score 演示么?Score 逻辑会询问 Ball 逻辑么?无论如何,它们都需要相互交流。

您可以创建一个缓冲区对象,该对象的唯一目的是提供存储区域,逻辑可以在该区域中写入内容,演示可以在这里读取内容。或者,您可以在它们之间放一个队列,以便逻辑系统将信息放入队列中,然后演示系统会读取队列中发生的事情。

随着游戏规模的不断扩大,使用消息总线是解耦逻辑与演示的好方法。消息传递的核心原则是接收方和发送方都不知道对方,但都知道消息总线/系统。因此,Score 演示需要从消息系统中了解更改分数的任何事件。而游戏逻辑代码会向消息系统发布事件,以提示玩家的分数变化。如果您想要解耦系统,使用 UnityEvents 是一个很好的起点。您也可以自行编写事件,这样您就可以拥有用于不同目的的不同总线。

加载场景

停止使用 LoadSceneMode.Single,改用 LoadSceneMode.Additive。

卸载场景时请使用显式卸载,因为在场景转换期间,您迟早需要让一些对象保持活动状态。

也请停止使用 DontDestroyOnLoad。它会让您无法控制对象的生命周期。实际上,如果您正在使用 LoadSceneMode.Additive 加载内容,则您无需使用 DontDestroyOnLoad。相反,请将生命周期较长的对象置入生命周期较长的特殊场景中。

简洁可控的退出代码

在我参与过的所有单人游戏中,还有另一项实用技巧,即支持简洁可控的关闭操作。

请让您的应用程序能够在其关闭之前释放几乎所有资源。如有可能,我们不应该分配全局变量,也不应该用 DontDestroyOnLoad 标记 GameObject。

当有用于关闭应用程序方式的特定顺序时,您可以轻松地识别出错误并找到资源泄露。这也会在您退出“播放模式”时,让您的 Unity Editor 处于良好状态。退出“播放模式”时,Unity 不会重新加载整个域。如果您可以提供简洁的关闭操作,当您在编辑器中运行游戏后,编辑器或任何一种编辑模式脚本都不太会可能出现异常行为。

减少场景文件合并的痛苦

您可以通过使用 Git、Perforce 或 Plastic 等版本控制系统来实现这一点。然后以文本形式存储所有资源,并通过把对象做成预制件来将其从场景文件中移出。最后,将场景文件拆分成多个较小的场景,但是请注意,这项操作可能会需要其他工具。

代码测试的流程自动化

如果您的队伍即将成为一个 10 人或 10 人以上的团队,那么您将需要在流程自动化方面有所投入。

作为富有创造力的工程师,您应该做独特、细致的工作,并将重复性的部分尽可能地留给自动化。

您可以从编写代码测试开始做起。具体而言,如果您正在将内容移出 MonoBehaviour 并将其移入常规类中,那么使用单元测试框架来构建逻辑和模拟的单元测试就会非常简单。这种结论并不适用于所有情况,但是往往可以让其他编程人员在以后也可以访问您的代码。

内容测试的流程自动化

测试不仅包括测试代码,您还应该测试您的内容。假如您的团队中有内容创作者,并且他们有可以快速验证其创作内容的标准化方法,那么您的测试工作的进展会更加顺利。

内容创建者应该可以轻松测试逻辑代码,如验证预制件或验证预制件通过自定义编辑器输入的一些数据。如果他们能只点击编辑器中的一个按钮即可得到快速验证,他们将很快意识到这会节省他们的时间。

验证逻辑代码的下一步是设置 Unity Test Runner,这样您就可以定期对内容进行自动重新测试。您可以将 Unity Test Runner 设置为构建系统的一部分,这样它便可以运行您的所有测试。设置通知不失为一个好方法,这样一来,您的团队成员就会在问题发生时收到 Slack 或电子邮件通知。

创建自动化游戏演练

自动化的游戏演练涉及到制作可以玩游戏的 AI,然后记录错误。简而言之,AI 多发现一个错误,您就少一个需要花时间寻找的错误!

在我们的示例中,我们在同一台机器上安装了大约 10 个游戏客户端,并都以最低的细节设置运行所有游戏。我们会监视这些客户端的运行,等到它们崩溃后,我们会研究日志。这些客户端运行至崩溃所用的每段时间都是为我们节省的时间,我们无需为找到错误而花费这些时间来自己玩游戏或者让其他人来做这件事。那意味着当我们自己或者和其他玩家一起进行游戏测试时,我们可以专注于游戏是否有趣、哪里有视觉故障等其他方面。

您喜欢本文吗?
是的。
还行。