在这篇中,我们会了解到Mimimi Games的高级开发人员Phillip Wittershagen如何与工作室一起创建游戏的存档系统。

原文:Fine tuning the scummy save system of Shadow Gambit: The Cursed Crew (gamedeveloper.com)

翻译&校对:大白马

大家好,我是Philipp Wittershagen,Mimimi Games的高级开发人员。我们以《影子战术:将军之刃Shadow Tactics: Blades of the Shogun》的成功而闻名,该游戏使这中策略类型得到了重生,并获得了赞誉,随后又推出了《赏金奇兵3Desperados III》。最近,我们最雄心勃勃的作品《影子诡局:被诅咒的海盗Shadow Gambit: The Cursed Crew》,虽然再次获得了赞誉,但很遗憾,这将是我们的最后一款游戏。

如下图,Mimimi Games在数月前已经宣布关闭。

在创建《将军之刃》时,一个由四个人组成的非常小的开发部门为后来的游戏奠定了基础。虽然后来对许多东西进行了优化,但保存系统的架构是在那时定义的,因此受到了小团队规模和超高效要求的影响。

您可能会想:“大多数游戏都有存档系统。您的存档系统有什么特别之处?”让我简要介绍一下隐秘策略类型以及它对存档系统的特殊要求,之后我将描述我们的系统如何工作,我们如何解决需求以及如何对其进行优化。

潜行策略类型

潜行策略类游戏,尤其是Mimimi Games的作品,会以俯瞰视角呈现约200×200平方米的地图。这听起来可能不多。然而,它们密集地挤满了敌人和互动。虽然我们之前游戏的关卡最多只能容纳100个NPC,但《影子战术》凭借其更开放的结构,有时在一个地图上最多可容纳250个NPC。所有这些NPC都有自己的检测、例程和AI行为运行,玩家可以随时滚动和分析。此外,最多有八个(尽管在大多数情况下是三个)玩家控制的人物,他们的技能可以同时计划和执行,以及各种脚本事件和与地图的互动,这些事件和互动可以具有各种复杂的逻辑,并可以自由触发过场动画、动画或任务更新。

潜行游戏一直很容易出现保存问题,尽管有多个玩家角色和多样化的技能系统,我们的游戏还是积极接受了这一点,鼓励玩家尝试不同的方法并经常保存和加载。

所有这些对我们的存储系统提出了某些要求:

· 可靠地保存每一刻的每一件事——无论是地图上每个NPC的当前行为、与可用对象的交互、执行技能和角色的动画状态,还是由关卡设计创建的脚本化地图事件。

· 快速保存——因为保存最有可能经常触发。

· 加载更快。

· 在开发过程中保持可靠性,因为关卡设计将不断需要它进行测试。

· 不要给开发部门带来太多的开销,因为这个小团队仍然必须非常高效地交付雄心勃勃的项目。

一个初步的决定

如上图,对于港口区域,保存根中的所有动态对象都在右侧可视化(左侧为静态对象)。这些对象与其所有组件和字段一起保存,并在每次加载时被销毁并完全重建。

为了让团队快速高效地工作,并可靠地保存所有动态信息,在开发早期做了一个基本决定:我们将在场景中定义某些根对象(所谓的“保存根”),这些根对象会将所有子对象及其所有组件一起引入保存系统。默认情况下,所有这些对象(包括它们引用的每个字段)都会被保存,除非明确标记为不保存。

这导致我们的开发人员以一种特定的但仍然有效的方式编写代码(不支持一些内置的C#类,如HashSet或Func),但同时取得了非常显著的结果:

如果代码使用了无法保存的功能,那么在第一次尝试保存-加载后,它很可能会立即破坏游戏。

如果代码不使用不支持的功能,则保存-加载将成功,并保证每个保存的对象和每个保存的字段都可以在加载过程中再次生成。

由于一开始并不清楚哪些功能不受支持,在几个月的时间里,每个程序员都会运行一个工具,如果保存-加载功能没有经过几分钟以上的测试,该工具就会向用户发出警告。这确保了所有开发人员都能自动学习哪些语言功能应该避免使用。

深入探讨

创建一个正确的保存-加载系统,允许所有这些自定义代码自动保存和加载,这不是一件容易的事,所以让我们看看我们是怎么做到的,以及我们一路上做了什么决定。虽然我试图以独立于引擎的方式描述这个系统,但这并不总是可能的。请记住,在Mimimi Games,我们使用Unity3D引擎和C#作为我们的编程语言。虽然反射等概念也可以通过宏引入到C++引擎中,但值类型和引用类型等概念在其他语言中是不存在的。

存档

我们首先收集所有游戏对象和组件,这些对象是我们定义的保存根对象的子对象。然后,这些对象会反映它们的属性或字段。在这个过程中发现的一些对象也可能具有超自定义的序列化方法。

根据对象的类型,使用三种不同的序列化方法之一:

属性反射:大多数是预定义的引擎内对象或第三方包的组件,其中公共属性通常定义API,并且足以重新创建对象。

字段反射(也是非公开的):主要是我们定制的组件,我们不追求覆盖所有内部流程的API,但同时希望保留所有内部结构。

自定义序列化方法:协程对象(如果没有引用对象,Mimimi中的协程可能永远不会启动,否则,保存系统不会知道它们)、委托和材质。

由于我们还需要保存未在引擎中注册的对象,我们递归地将我们在这些字段和属性中发现的新的对象添加到反射过程中。我们希望忽略我们可以定义为“资产”(即网格、图形或仅仅是设置容器)的对象。我们的引擎在运行时(虽然在开发过程中)无法提供任何方法来区分资产对象和其他对象,所以我们不得不引入另一个步骤,在游戏构建之前运行。在反射的帮助下,类似于保存过程,我们递归地收集场景中使用的所有资产对象,将它们保存到场景中的一个巨大的字典中,并为它们分配唯一的ID。为了简化加载过程,我们还向这个字典添加了所有保存根,因为它们应该像资产一样处理。

对于所有非资产对象,我们创建了小型、简单的数据结构,也称为Passive Data Structures(PDS)或Plain Old Data(POD),因为它们不再具有任何面向对象的特性——

通过用所谓的RefID(保存文件中其他对象的ID)或AssetID(资产的ID)替换所有引用。然后,这个简单的数据结构可以很容易地以各种格式序列化。为了确保与我们所有目标平台的兼容性和优化的可能性,我们决定编写自己的简单序列化器,它有文本和二进制版本。

协同程序、委托和材质都是持续的挑战(我们将在修补部分稍后看到一个例子),尽管能够保存协同程序,我们也可以轻松地保存各种异步代码。如果我们的所有开发人员每次等待某些外部属性更改或只是等待定时延迟时,都必须引入状态变量,我相信我们永远无法以我们的状态发布这些游戏。

加载

在加载过程中,我们首先清理所有保存的根,然后从PDS中重新创建所有对象。之后,所有保存的属性和字段都通过反射进行设置,类似于序列化过程。在需要时使用自定义反序列化方法。

显然,事情并没有那么简单,因为并非游戏引擎的所有内置功能都绑定到游戏对象或可以轻松恢复。我在这里只列出各种特殊情况,这些情况在保存、加载或两者都需要额外的代码:

协程:它们具有自定义序列化方法,并在加载后从其当前的枚举器位置启动。

时间:我们希望在保存时继续计时,因此我们添加了一个单例组件来包装内部的时间方法。

Random:我们在加载后恢复种子。

材料:我们必须神奇地将材料实例映射到它们的资源,这最终需要按名称映射。

引擎对象的内部awake状态:想想在对象第一次启用后(即Awake或Start)如何自动调用一些方法。这些方法不应在加载游戏后触发,或者至少不应影响我们的代码。

物理:与awake状态类似,物理对象也有一些内部状态。我们必须忽略在加载过程中连续发生的触发和碰撞回调。

静态字段:我们将它们包含在所有对象中,就像它们只是普通的字段一样(我们之前假设根本不需要保存它们,但很快意识到这会导致不稳定和内存泄漏)。这理论上会导致它们被多次保存和加载,尽管我们不鼓励使用静态,除了我们的基本单例类,它只存在一次。

结构体:为了简单起见,我在之前提供的示例中默默忽略了它们。它们与自定义引用类型的保存过程共享一些代码,但在加载过程中会进行特殊处理,在其他对象之前对其进行反序列化。

优化,优化,再优化

我认为在所有这些技术描述之后,现在是一个好时机来重新说明这一游戏类型的存档系统的要求了:

(已完成)可靠地及时保存每一刻的一切:我们发现了所有需要处理的特殊情况,并能够建立一个坚实、稳定的保存-加载系统

快速保存——因为保存最有可能经常触发。

加载更快。

(已完成)在开发过程中保持可靠性:通过将默认设置为保存所有内容,我们可以快速检测错误;关卡设计在开发过程中也可以使用保存系统,很少出现错误。

(已完成)不要给开发部门带来太多的开销:我们的开发人员首先必须学习如何编写代码以使保存系统工作(支持哪些类,不支持哪些类),但在短暂的适应期后,存档系统不会给日常工作带来任何开销。

在阅读这份清单并注意到某些行中缺少“(Done)”标签之前,有经验的C#开发人员可能会对本文中使用的每个“反射”一词的实例,以及这种系统的性能产生越来越多的怀疑。虽然我们对它的速度感到惊讶,但反射并不是特别出名。虽然目前的状态对于开发来说还可以,但对于生产来说却是灾难性的。因此,开始了一项繁琐但必要的任务,即尽可能地优化每一英寸。

寻找罪魁祸首

每次优化尝试的第一步应该是分析并找出最糟糕的因素。除了明显的罪魁祸首“反射”,我们还发现了其他几个候选因素:

文本序列化和存储在磁盘上的文件大小:我们切换到二进制序列化格式,并使用GZip对保存文件进行压缩。

C#类型反序列化:我们现在将类型名称保存为ID,并在第一次出现时缓存其C#对象。这导致每个会话的第一次保存-加载速度稍慢,有利于后续会话的速度提升。

引擎内对象的再生

前两个问题很容易解决,这就是为什么我很快描述了我们的解决方案,并且不会进一步讨论它们。现在让我们来看看我们为反射性能所做的优化(也很明显)。

优化反射

在游戏中构建保存加载系统的经典方法是定义一个通用的可保存基类,该基类公开一些“序列化”和“反序列化”方法,并将它们传递给某种类似数据库的对象。在这些方法中,所有派生对象都可以通过一些ID存储或检索他们想要保存或加载的数据。它们可能看起来像这样:

由于我们使用反射处理所有内容,我们没有这些方法。为了能够恢复引擎内对象的内部状态,我们确实已经为所有可保存的组件提供了一个公共基类。但是,手动编写这些方法显然违背了我们“不产生过多开销”的既定目标,但是随着系统的稳定,基于反射的系统不需要任何运行时信息,我们可以在构建过程中轻松生成这些方法。除了生成方法外,我们现在能够微优化类型解析,并使用预先计算的字段名称哈希作为ID。

生成序列化和反序列化方法在缩短用户友好的加载时间方面发挥了巨大作用。在左侧,您可以看到在开发过程中禁用大多数优化时的加载过程,右侧是最终版本。序列化和反序列化方法本身为我们提供了大约1.5秒的加速。

优化引擎内对象的再生

我们在引擎对象和组件的再生过程中发现了我们没有预见到的性能瓶颈。Unity3D中用于此的方法所占的时间不成比例。这可能是由于C#和C++引擎之间的桥梁,每次调用都必须通过。考虑到这一理论,我们尝试利用另一种方法来生成新的对象和组件:用许多组件实例化预制件。

创建引擎内对象所需的时间似乎与引擎调用的次数成正比,因此对于具有许多组件的对象,实例化调用要快得多。此调用不仅可以创建预制件,还可以复制场景中的其他对象。由于我们知道场景中的对象是什么样子以及它们包含什么组件,我们预料到问题的解决方案。我们场景中的类似对象包含类似的组件:对于多达250个NPC中的许多NPC来说都是如此;对于我们的玩家角色来说也是如此;对于我们的关卡设计师构建的许多逻辑来说,在某种程度上也是如此,因为它们由多个游戏对象组成,并且每个游戏对象通常只包含一个命令组件。

有了这些知识,我们草拟了一个实验:如果我们用某些组件创建模板对象,那么在加载具有此组件集的对象时,我们只需要复制它们。这使我们不需要单独调用添加组件。即使大多数对象只有一个组件,它仍然可以将所需的调用数量减半,但我们在《影子战术》中的NPC每个都有大约100个组件。对引擎的调用次数,以及我们在这里节省的时间量是巨大的。后来,我们甚至在第一次加载后添加了模板副本的预生成。我们现在已经预料到下一次加载,并且在正常游戏过程中,根据我们在玩家之前的加载中看到的数量,预生成模板对象的多个副本。

所有这些优化使我们的最终保存和加载时间仅为几秒钟,即使在最大的地图上也是如此。这将最终符合我们的既定目标,并确保流畅的玩家体验。

补丁

在Mimimi,我们总是努力让我们的游戏尽可能没有缺陷,但总会有一些问题,要么是QA漏掉,要么是比我们估计的更严重。在修补游戏时,人们不想破坏当前玩家的游戏存档,因为这通常会导致玩家完全放弃游戏。在我们的第一个《影子战术》补丁中未能确保游戏存档兼容性后,我们构建了工具来确保这种情况不会再次发生,并且不得不为我们的后续项目调整我们的部分保存代码。

有两个事件最常导致我们的保存代码在补丁后不兼容:

关卡要求的资产清单中的任何变化。

为协程生成的代码中的任何更改。

生成的协程代码的变化

在查看代码的反编译时,我们经常看到编译器为协程生成的对象。让我们来看一些代码,以便更好地理解这里发生的事情。这是12月6日刚刚发布的DLC《Shadow Gambit: Zagan’s Wish》中可玩角色Zagan的黑暗切除技能的摘录。

我们在那里有一个执行各种动画的协程。当我们在协程中定义局部字段时,需要一些对象来保存这些值。为此,编译器为我们生成了一个名为“<coroAnimation>d__4”的新类。这也是我们为当前运行的协程保存的对象。如果我们给游戏打补丁,则该对象的类型不能变成“<coroAnimation>d__5”,仍然应该是“<coroAnimation>d__4”,否则,我们无法正确恢复协程——这在我们的《影子战术》补丁中经常发生。但是,它的类型到底发生了什么变化?我们意识到,类型名称中的数字取决于协程之前同一类中出现的字段、属性或方法的数量:

0: m_actionExecuteLoop

1: m_actionExecuteEnd

2: m_coroAnimation

3: playPostExecutionAnimation

4: coroAnimation →  指向类名 <coroAnimation>d__4

这也意味着我们可以安全地将补丁更改添加到我们的代码中,除非我们在任何协程之前添加它们。我们为每个创建的补丁在源文件的底部添加了一个特殊部分,新的字段和方法将驻留在那里。我们还添加了另一个后构建操作,其中外部应用程序检查所有构建的程序集,以检查生成的协程对象的名称是否发生了变化。

人们可能会认为在补丁阶段遵守规则应该不太困难,但事实是,在日常生活中很容易发生错误,打包流程中的安全检查使我们避免了一次又一次地发布有缺陷的补丁。

总结

我希望您觉得深入了解我们的存档系统很有趣,并且可以从中学习到一些东西。对我来说,我想强调五个要点:

尊重程序员的时间,消除可能的摩擦;对我们来说,这很重要。

默认保存所有内容,无需额外代码。

将特殊规则保持在最低限度(即允许协程的保存-加载)。

优化不一定是从一开始就内置的:如果最终您知道所有要求,那么对特定瓶颈的优化通常更容易实现。

要意识到日常生活中总会发生错误:你应该始终有防止它们漏掉的流程。

我真的很喜欢在存档系统和潜行策略游戏上工作,并希望即使没有Mimimi Games的开创性项目,这种类型也会继续蓬勃发展。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

目录