您将从此页面获得什么:有关如何通过使用可编写脚本的对象来构建游戏代码以使游戏代码易于更改和调试的提示。
这些技巧由 Schell Games 首席工程师 Ryan Hipple 提供。他在使用脚本化对象构建游戏方面拥有先进的经验。您可以在此处观看 Ryan 有关脚本化对象的 Unite 演讲;我们还建议您观看 Unity 工程师 Richard Fine 的演讲,以获取有关脚本化对象的精彩介绍。
ScriptableObject 是一个可序列化的 Unity 类,能够让您在脚本实例之外存储大量共享数据。使用 ScriptableObject 可以更轻松地管理更改和调试。您可以在游戏中的不同系统之间建立一定程度的灵活通信,以便在整个生产过程中以及重用组件时进行更易于管理的更改和调整。
采用模块化设计:
- 避免创建直接相互依赖的系统。例如,库存系统应该能够与游戏中的其他系统通信,但您不会希望在它们之间创建硬引用,因为这会导致将系统重新组装成不同的配置和关系变得很难。
- 从头开始创建场景:避免场景之间存在瞬态数据。每当点击一个场景时,该场景应该是光洁的断面和负载。这可以让您获得其他场景中所没有的独特行为的场景,而不需要强制去摸索。
- 设置预制件,使其独立工作。您拖入场景的每一个预制件都应包含所有内部功能。这有助于大型团队控制源代码,其中场景是预制件列表,并且预制件包含单独的功能。这样,您的大部分签入都处于预制件级别,从而减少了场景中的冲突。
- 让每个组件主要解决一个问题,这样可以更轻松地将多个组件拼凑在一起以构建新的组件。
简化更改和编辑部分:
- 尽可能多利用数据驱动游戏。当您将游戏系统设计成像机器一样,将数据作为指令处理时,即使游戏正在运行,也能够更有效地对游戏进行更改。
- 如果尽可能将系统设置为模块化和基于组件的,美术师和设计师将能更轻松地进行编辑。如果设计师能够在游戏中将事物拼凑在一起而无需要求具有明确的功能,很大程度上要归功于每个只实现一个功能的微小组件,然后他们可以通过不同方式组合这些组件,以找到新的游戏玩法/机制。Ryan 说,他的团队在他们的游戏中使用的一些主要功能来自于此过程,他称之为“紧急设计”。
- 团队可以在运行时更改游戏的能力至关重要。您可以在运行时对游戏进行的更改更多,就可以找到更多平衡和值。如果您还能够将运行时状态保存回来(就像脚本化对象一样),那就更好了。
简化调试:
这更像是前面两个支柱的子支柱。游戏模块化程度越高,就越容易测试其中的任何一个部分。也就是说,游戏越是可编辑,在自己的 Inspector 视图中拥有的功能就越多,调试也就越容易。确保可以在 Inspector 中查看调试状态,并且在为如何调试游戏制定某些计划之前从不考虑完成的功能。
您可以使用 ScriptableObject 制作游戏的最简单的方法之一是使用自包含的基于资源的变量。以下是 FloatVariable 的示例,但该变量也可扩展到任何其他可序列化类型。
无论技术怎样,您的团队中的每个人都可以通过创建新的 FloatVariable 资源来定义新游戏变量。任何 MonoBehaviour 或 ScriptableObject 都可以使用公共 FloatVariable(而不是公共浮点变量),以引用新的共享值。
更妙的是,如果一个 MonoBehaviour 更改了 FloatVariable 的值,则其他 MonoBehaviour 可以看到此更改。这在不需要彼此引用的系统之间创建了一种消息传递层。
此类示例之一是玩家的生命值 (HP)。在拥有一个本地玩家的游戏中,玩家的 HP 可以是名为 PlayerHP 的 FloatVariable。当玩家受到伤害时,它会从 PlayerHP 中减去,当玩家接受治疗时,它会加到 PlayerHP 中。
现在,想像一下场景中的生命条预制件。生命条监控 PlayerHP 变量以更新显示。如果不更改任何代码,它可以轻松地指向不同的对象,如 PlayerMP 变量。生命条对场景中的玩家一无所知,它只是从玩家写入的同一个变量中读取值。
一旦我们这样设置,就可以轻松添加更多的东西来观看PlayerHP。音乐系统可以随着玩家生命值变低而改变,敌人可以在知道玩家较弱时改变其攻击模式,屏幕空间效果可以强调下一次攻击的危险,等等。这里的关键是玩家脚本不会将消息发送到这些系统,并且这些系统不需要知道玩家游戏对象。您还可以在游戏正在运行时进入 Inspector,更改 PlayerHP 的值,以便对某些变量进行测试。
编辑 FloatVariable 的值时,最好是将数据复制到运行时值,从而不更改存储在磁盘上的 ScriptableObject 的值。如果这样做,MonoBehaviour 应访问 RuntimeValue 以防止编辑保存到磁盘的 InitialValue。
可以在 ScriptableObject 上构建的 Ryan 最喜欢的功能之一是事件系统。事件架构可在彼此不直接了解的系统之间发送消息来帮助模块化代码。它们允许事件对状态的更改作出响应,而无需在更新循环中进行持续监控。
以下代码示例摘自包含两个部分(GameEvent ScriptableObject 和 GameEventListener MonoBehaviour)的事件系统。设计师可以在项目中创建任意数量的 GameEvent 来表示可以发送的重要消息。GameEventListener 等待引发特定 GameEvent,并通过调用 UnityEvent 作出响应(这不是一个真正的事件,而是序列化函数调用)。
GameEvent ScriptableObject:
GameEventListener:
其中一个示例是在游戏中处理玩家死亡。这里大部分执行都可以更改,但可能很难确定对所有逻辑进行编码的位置。玩家脚本应该触发游戏结束 UI 还是音乐更改?敌人应该每一帧都检查玩家是否还活着吗?事件系统可以让我们避免出现像这样有问题的依赖关系。
如果玩家死亡,玩家脚本会在 OnPlayerDied 事件上调用 Raise。玩家脚本不需要知道哪些系统与之相关,因为它只是一个广播。游戏结束 UI 正在侦听 OnPlayerDied 事件并开始制作动画,摄像机脚本可以侦听它并开始淡化为黑色,音乐系统可以对音乐变化作出响应。我们也可以让每个敌人侦听 OnPlayerDied,触发讽刺动画或状态更改以重新回到空闲行为。
这种模式可以非常容易地为玩家死亡添加新的响应。此外,还可以通过从 Inspector 中的某些测试代码或按钮的事件调用 Raise,轻松地对玩家死亡响应进行测试。
他们在 Schell Games 中构建的事件系统已经变得越来越复杂,并且具有可传递数据和自动生成类型的功能。本示例基本上是他们现在使用某些内容的起点。
脚本化对象不一定只是数据。使用您在 MonoBehaviour 中实现的任意系统,看看您是否可以将实现移动到 ScriptableObject。不要在 DontDestroyOnLoad MonoBehaviour 上使用 InventoryManager,而是将其放置在 ScriptableObject 上。
由于它与场景无关,因此它没有 Transform 函数,也无法获得 Update 函数,但是它将在场景加载之间保持状态,而不需要进行任何特定初始化。当需要脚本访问库存时,对库存系统对象的公共引用(而不是使用单例)。这使得在测试库存或教程库存中进行交换比使用单例更简单。
在这里,您可以想像一下引用库存系统的玩家脚本。当玩家生成时,它可以向库存询问所有拥有的对象并生成任何设备。设备 UI 还可以引用库存并循环遍历项目以确定要绘制的内容。