编程香肠体育俱乐部

另请查看我在(Design,Tech Art和BizDev)上的帖子

哲学

对于这样的编程文章,我认为从一些警告开始非常重要。 首先,作为阅读本文的人,您可能是一名程序员。 给程序员的最好建议是,您应该尝试收集关于编程的尽可能多的观点和想法。

无论是上课,博客,教科书,论坛,受人尊敬的开发人员,流光者,还是其他任何人,都可以从许多不同的地方得到信息,这将为您提供更多参考点,供您在解决问题和做出艰难选择时考虑。 其次,在对程序进行内部化之前,您应该始终对程序的任何观点或教条持批评态度。

学习新知识时,我会问自己以下问题:

  • 说这个的人是否有备份经验?
  • 我是否有足够的领域知识来对此发表意见?
  • 如果您想拒绝一个想法,是因为逻辑或倾向?
  • 这项技术是否适合我的项目,还是我觉得很酷?

这篇文章的其余部分将结合我发现有用的设计模式,Sausage Sports Club的实现细节以及一些我将主要介绍的代码库的成功与失败。

游戏状态堆栈

在较高的层次上,香肠体育俱乐部是一种通过一堆GameState对象控制的状态机。 每个GameState都会实现在状态之间切换以及Unity内部更新期间调用的函数API。

该API定义允许每个GameState可选地定义以下功能:

  • OnEnter / Exit —在堆栈中添加/删除时调用
  • OnPause / Unpause(暂停/取消暂停)—在此状态之上添加/删除状态时调用
  • OnUpdate-在Unity内部更新期间调用

这种结构很有用,因为它为游戏的每个状态提供了明确的进入和退出点。 这样可以避免在模式和场景之间切换时在清理过程中可能发生的许多潜在错误。 您需要做的几乎就是确保OnEnter中产生的所有内容或设置都可以在OnExit中清除或重置。 对于香肠体育俱乐部,这还使我可以共享用于设置不同体育比赛的大多数代码。 例如,几乎所有设置足球比赛的流程都与设置任何其他运动模式相同。

在我看来,这似乎是一种常见的模式,尤其是对于具有许多离散状态的游戏,而我的实现方式受到了我在Battle Chef Brigade上使用相同堆栈结构的工作的极大启发。 在此模式上,我最大的创新就是允许用户在调用ChangeState时有选择地传入一个对象GameStateParams,该对象保存该状态下使用的任何数据。 任何GameState都可以定义自己的子类,以添加额外的数据以通过此API传递。

演员/运动

香肠体育俱乐部架构的下一个层次可能是演员/运动。 与GameState状态机相似,每个移动角色都由状态机控制。

角色使用与GameState类似的API(称为PhysicsState),该API定义了上述相同功能以及以下功能:

  • OnInitialize-传递对拥有EntityPhysics的状态机的引用
  • OnCollisionEnter / Exit —传递OnCollisionEnter / Exit事件
  • OnTriggerEnter / Exit —传递OnTriggerEnter / Exit事件
  • OnFixedTick —在Unity内部的FixedUpdate中调用

由于上面列出的相同原因,此结构很有用。 易于清理,在相似实体之间共享代码,此外,易于原型设计并添加新的运动或其他状态类型。 这也基于Battle Chef Brigade的实现,并向ChangeState添加了一个可选的PhysicsStateParam参数。 与GameStates的最大区别在于,PhysicsStates需要一些输入源来控制Actor的运动,我将其实现为从ActorInput派生的邻居组件。 该组件为游戏中使用的所有不同输入定义访问器的API,然后为不同的派生类(如PlayerManualInput或PlayerAIInput)定义访问器的API。

对于播放器控制的输入,我使用InControl作为一层来从硬件中获取输入,而不必直接与Unity不太好的内置输入系统接口。 我衷心推荐使用InControl,因为它附带了对几乎所有常用输入设备(PS4,Xbox One,PS3,Xbox 360,Switch和许多第三者随机控制器)的支持。 它还附带源代码,有据可查且易于扩展。 在Patrick有机会添加官方的Switch支持之前,我可以轻松添加我自己的Joy-Con支持。 实现输入重新绑定也非常简单,我很自豪地说这是香肠体育俱乐部的一项功能。

对于AI控制的输入,我真的没有做任何花哨的事情。 PlayerAIInput类定义一些util例程,用于使用导航网格(如果可用)跳转,破折并找到到达目标位置的路线。 然后,对于每种模式,我添加一个派生类,例如PlayerAISumo或PlayerAIOverworld,该派生类运行逻辑来确定每个Update中的目标位置。 每种模式的AI总是很简单,我只是一步一步地介绍一个真实玩家的想法,以及他们希望基于所有其他玩家和演员的状态而定的位置。 例如,在足球中,AI只是想在最近的球和他们的球门之间移动,然后将球踢向另一个球门。

角色控制器

角色控制器是我迄今为止在香肠体育俱乐部工作最多的事情。 这是我添加的第一件事,甚至在最后一次提交证书时我都在进行调整。

这是我用来使Sausage Sports Club角色控制器感觉紧绷且反应灵敏的所有技术:

  • 我使用Unity的内置物理方法解决了碰撞,但是通过基于大约12个可调整的值手动积分加速度,阻力和重力来计算每个FixedUpdate的速度和旋转。 玩家是胶囊碰撞器,底部有一个小球形碰撞器,以防止玩家卡在不平坦的表面上。
  • 我从Battle Chef Brigade获得的一个有用技巧是分别跟踪玩家控制的速度和不受控制的速度,并在计算速度时将其合并。 这使您可以将使击键力衰减几帧的代码与将玩家输入转化为运动的代码分开。
  • 为了检查玩家是否接地,我放下射线,然后向下球放下来,作为备用,以防万一我们掉下来。 如果向下移动,我还将使用OnCollisionEnter作为其他备份检查。 为了避免玩家在超重击中时穿越几何体,我会朝预期的移动方向射击射线,并拒绝将其推入墙壁或地面的任何力。
  • 我让玩家离开壁架后的前0.3秒跳动,并缓冲玩家在着陆之前0.2秒内做出的跳动输入,以帮助达到玩家的期望和较弱的反应时间。 这两种生活质量功能对于任何平台游戏者都非常重要,玩家会抱怨没有它们,跳跃感会变得毫无反应。
  • 破折号/脚踢移动会立即更改玩家的移动方向,以匹配您的摇杆输入方向,而不是使用您面对的方向。 尽管您的播放器需要花费几帧来转弯并面向新的移动方向,但这种方法更符合播放器的意图,并且感觉更好。
  • 在击中反应的前半秒后,我会增加玩家输入的权重,甚至允许短按和踩踏,因此即使玩家仍在空中飞行,也可以从击中反应中恢复。
  • 我注意到玩家在冲刺时不小心跳了起来,在冲刺时踩了一下,并将它们变成了先进的技术,让您跳得更远并且分别进行了成角度的踩踏。 这稍微增加了高级玩家的技能上限,并使所有人的跳跃,冲刺和踩踏感觉更加松散和自由。
  • 我将所有变量用于控制玩家的移动,跳跃,击打力,扑灭以及需要在ScriptableObject上进行微调的其他任何变量,以便可以在运行时进行更改并保存。 快速而有趣地迭代这些值非常重要,因此您应尽可能多地这样做。 我在这里有很多调整项,并且经常更改它们,以至于在我的项目中选择此资产映射到Alt + T。

软盘物理

香肠体育俱乐部的特色之一就是角色的脖子长而松软,玩家可以用控制器上的右摇杆(或单个Joy-con上的陀螺仪)摇摆。 在玩游戏时,这有助于细化周围的运动球并避免被击中,但对于玩家来说,这主要是使彼此笑的工具。

角色的脖子是由10个刚体组成的链,具有固定的关节,将每个关节约束到其父对象。 通过向该关节链顶部的刚体施加物理力来进行扑打。 松散感是基于0.166666ms或60fps的物理时间步长来匹配目标帧速率。 我发现这种感觉非常早,应该尝试用更少的关节,精心调整的可配置关节甚至更长的时间来匹配它。

最后,我没有做那些更改,因为我一直以为自己已经接近完成,也因为我对PhysX的理解是通过许多原型,调整和错误修复逐渐获得的。

用户界面

我使用Unity的内置UI系统和TextMeshPro进行所有操作。 他们大多数时候都工作得很好,但是都遇到了很烦人的怪异问题,因此我不得不解决。

这是我对Unity UI和Text Mesh Pro的一些主要了解:

  • Unity的UI系统提供了一种解决方案,可以调整面板的大小以匹配其内容的大小,但是缺少一种直接(有效)的手动调用面板调整大小的方法,因此游戏中的一些面板有些奇怪偶尔的布局状态。
  • Unity的用于掩盖UI元素的内置组件在某些视角下不适用于世界空间UI,尤其是当您要更改不同元素的物料队列时。 直到我学会了如何使用Stencil着色器并花时间了解Unity的UI系统中Stencil缓冲区的作用后,我才知道解决方法。 我的(非理想的)解决方案是向面板中的所有元素添加需要特定值的材质/明暗器,然后使用掩膜矩形来写入所需的模板值。
  • 每个UI面板都是一个单独的对象,并具有控制其状态的组件。 除了一个面板打开另一个面板的极少数情况外,没有一个UI面板可以互相识别。 不使用时,UI面板设置为“活动”。 带有动态内容的所有UI面板都会生成并重建其布局OnEnable。 这可能会在特别复杂的菜单中造成麻烦,并且我可以卸载子菜单内容的生成直到打开之前,但在每种情况下,我都选择保持简单以避免潜在的错误。
  • Unity的UI性能也是阻碍Switch优化的瓶颈,并且要求我将游戏中的所有UI都转换为TextMeshPro。 具体来说,如果使用的字体大小与现有图集的大小不同,则任何使用自动大小功能的Text组件在首次启用时都会强制重建关联的字体图集。
  • 对于开发的前90%,我避免使用补间库,而是编写协程来管理每个菜单的每个移动部分。 这是因为外部库的一些不良经验,以及资产商店资产不足。 回想起来,我本应该早点开始使用库,因为我的每次协同程序的工作方式比较慢,而且容易出错。 我认为,如果我转换所有的协程补间,代码库将减少30%,这意味着创建错误的代码将减少30%。
  • 对于项目的前60%,我的所有UI看起来都非常糟糕。 通过大量的实验和迭代,我发现了几组可以增强游戏主题的颜色和图案,然后在各处使用。 这使我的UI在整个游戏中都具有凝聚力,这是大多数战斗。 添加帧,阴影和微妙的动作也使这些屏幕感觉完成了很长的路要走。

辛格尔顿

在任何地方,有一个需要在许多地方访问的对象的实例,我都使用一个单例,它是从我的通用单例基类派生的。 我大概有15–20个,其中最常用的是PlayerManager,GameManager和UIManager。

这是一个非常常见且有据可查的模式,因此我无话可说,除了每次进入游戏模式或打开游戏时,我都会首先加载到一个空旷的场景,其中包含所有用于管理和运行游戏的经理。

镜头经理

在游戏中不止一个部分关心某个状态的任何地方,我都使用LensManager范例,该范例是我在Battle Chef Brigade上工作的。 我知道命名不是很清楚,所以让我们看一个例子。 考虑一种屏幕淡入淡出系统,其中游戏的多个部分都希望屏幕变黑或不变黑。

使用此范例,每个系统都会向LensManager添加一个LensHandle请求,该请求保留预期的淡入淡出设置。 收到每个请求时,LensManager会根据初始化时传入的C#func在内部进行评估,然后缓存确定的状态结果。 这样,任何人都可以检查状态,而不必每次都评估所有请求。 LensManager甚至具有评估回调,其他任何系统都可以在状态更改时订阅该回调。

多平台

当我开始制作香肠体育俱乐部时,已经开始将独立游戏迁移到游戏机。 这是显而易见的收入来源,因此我从一开始就计划将游戏尽可能多地投放到平台上。 考虑到这一点,并且最近完成了一些使游戏在游戏机上运行的合同工作,我知道最好将可变平台代码与游戏代码尽可能分开。

有几种方法可以做到这一点,但是我的版本中有一个抽象的Platform基类,该基类定义了要在游戏代码中使用的API,并为每个平台都派生了一个类,以根据需要实现这些功能。 这与带有PlatformServices Singleton的Sausage层次结构相适应,该Singleton在Awake上将当前平台的关联Platform派生组件添加到其gameObject中。 然后,可以在游戏代码中的任何地方通过Singleton实例的CurrentPlatform成员访问平台API。

我应该注意,因为香肠体育俱乐部是本地多人游戏,并且没有排行榜之类的在线连接功能,所以我的版本中唯一的功能是初始化,保存/加载,云保存和成就。 编写此类API时要记住的一件事是这些不同的功能将是异步的,或者至少会在多个框架上发生,因此最好将协程构建到您的逻辑中,这样您就可以在需要时等待操作

保存/加载

在Sausage Sports Club中,保存数据非常简单。 在Singleton管理器PlatformServices上调用保存和加载,并创建一个JSON对象,然后在实现ISaveable接口的所有管理器上手动运行。 该接口仅需要实现Save和Load函数,该函数将JSON对象作为参数并根据每个管理器的要求应用/读出数据。 在加载开始时,我们要求平台打开保存文件并将内容作为JSON对象提供给我们,在保存结束时,我们将填充的JSON对象写入新文件并覆盖现有的保存。 其中最烦人的部分是必须手动打包和解压缩保存和加载功能中需要的数据。 将来,我可能会写一个使那部分自动化的属性。 通过该实现,我只需在属性上方添加[Saveable]以使其自动序列化,然后PlatformServices将在所有Singletons中搜索具有该属性的字段。

保存系统中最怪异的部分是如何在游戏中表示和使用保存。 游戏中的某些数据(角色,皮肤和竞技场解锁)显然应该是持久性的,只需要解锁一次,而其他一些数据(帽子,名称和输入重新绑定)则更适合于各个玩家。 为此,我将个人玩家数据存储在PlayerSave对象中,并在GlobalSave对象下跟踪这些数据,并且都实现了我提到的ISaveable接口。 玩家可以创建新的玩家保存或在现有玩家之间交换的方式是通过更改所选玩家的姓名。 如果您已经玩了一段时间,并且在创建新名称时有与您的当前名称相关联的数据,您的进度将被复制到新名称。

数据的这种组织结构有一些有趣的皱纹。 它很奇怪,可以处理复杂的边缘情况,因此很难沟通。 我的解决方案是跳过解释。 如果2个玩家一起玩并解锁了一堆帽子,然后恢复玩另一天-这次以相反的顺序签名,则可能他们不会注意到自己的解锁帽子已经掉了。 如果他们确实注意到了,他们可能只会交换控制器。 这有点怪异,但太棒了,因为我避免强迫新玩家学习保存数据,玩家姓名或任何其他不需要进入游戏并获得乐趣的知识。

本土化

许多人说,重要的是不要在项目结束前就把它丢掉,如果这样做,那将是非常困难的。 我做了相反的事情,尽管游戏中有超过10,000个单词,但一切都还不错。 实施并修复所有弹出的错误可能需要花费2周的全职工作时间,偶尔还会弹出一些修复程序。

以下是使我支持多种语言的过程变得不那么痛苦的一些事情:

  • 我所有的对话都存储在单个数据结构中,几乎所有对话都存储在预制件中。 这意味着我可以编写一个函数以递归方式清理任何对话属性的预制件,并为其指定一个本地化术语。
  • 游戏中的所有对话都是从相同的例程读取的,因此只有一个地方可以确保正确地传递对话。
  • 游戏中只有少数几个位置具有动态文本,其中的名称或词组被替换为一行,因此我用于替代文本的解决方案不需要超级健壮。
  • 我使用了i2Localization,它是AFAIK的首个Unity友好本地化工具。 我可以写很多我喜欢和不喜欢的东西,但是只要您正在本地化Unity游戏,就可以说这完全是理所当然的事情。

以下是使这一过程更加痛苦的事情:

  • 在代码中有很多地方,我很难将行硬编码到教程中,也无法将一次性编写的脚本序列硬性地追踪到。 我最终不得不使用正则表达式搜索所有字符串,然后手动逐个查找它们。
  • 许多菜单都在代码中分配了文本。 我可以编写一个工具在使用’.text =’的任何地方在LocalizeText函数中进行交换,从而挤掉了很多东西,但是我不得不手动修复大约50个左右的点。
  • 对于文字繁重的游戏而言,本地化非常昂贵! 我听过每个唯一字的报价介于$ 0.04到$ 0.10之间,这会在多种语言中迅速加总。 尽管该游戏在功能上支持本地化,但在花钱之前,我仍在等待观察它的功能以及可以负担的翻译数量。
  • 相关:研究哪种语言最能翻译。 有人会建议EFIGS(英语,法语,意大利语,德语,西班牙语)显然是首选,但是Steam上中国和俄罗斯玩家的比例要高于英语以外的任何玩家。

尽管有可能在最后阶段进行本地化,但我建议您尽早意识到本地化基础知识的工作方式。 最重要的是,游戏中的每个唯一字符串都将与一个本地化术语/ ID关联,该本地化术语/ ID将用于查找当前使用的语言的匹配字符串。 仅仅那一点点知识就可以阻止您在代码中添加大量原始字符串并以多种不同方式表示字符串。

最后的想法

在此代码库中构建并生活了3年之后,让我们遍历与代码相关的成功和失败。

首先,成功是什么?

  • 在这个项目的大部分时间里,我都是以承包商的身份参与其他项目的。 我一直很想问很多问题,这些问题涉及项目的结构以及该体系结构的动机。 每当我从事某个能够很好地解决常见问题的项目时,我都会吸取该课程并将其应用于我的游戏。 乐于随着更好的方式来重写或重组游戏的任何部分对于该项目而言非常重要。 这种理念也适用于制作游戏的其余部分,因为如果它们并没有真正使项目变得更好,我总是乐于削减。
  • 进行这项工作的关键是我要认真维护代码。 谁知道我什么时候需要搜索项目的历史记录以查找旧的原型或跟踪引入错误的时间? 考虑到这一点,我可以很好地组织项目,仔细选择变量名,在需要时添加有用的注释,并编写冗长的提交消息。 另外,我要避免早期的优化,因为这会使复杂的代码变得复杂和模糊,并且我会尽力仅添加轻量级的外部依赖关系,并包括源代码。
  • 该项目的下一个最大成功是我花时间精通编写编辑器工具。 至少在Unity中,编写工具需要学习完全不同的API,并且要面对一般游戏编程中的另一类陷阱。 这花了大约一周的刻苦练习,然后花了一些时间去碰碰运气,以学习足够的知识以达到自给自足的目的。 我编写了一些自定义检查器或工具,其中包括在角色上戴帽子,预览摄像机设置,游戏偏好,对话编辑等等。 如果您没有时间学习该API,则可以通过向函数添加ContextMenu属性来开始使用,这些属性将在该组件的右键单击菜单中公开。
  • 另一个巨大的成功是,我设法将平台特定的代码排除在游戏代码之外。 我在上面的“多平台”部分中详细介绍了该操作的方式,但是构建此API很有价值,因此添加新平台只需要添加一个新的Platform类并实现抽象功能即可。 还有一些地方,我只需要在一个平台上隐藏按钮或显示指令标签,因此只添加了#ifdef,但这是该规则的例外。

这是一些不好的东西:

  • 在开发的前半部分,在花时间花在原型上之前,我没有充分考虑原型如何影响范围并增加价值。 我花了数周的时间在可视化节点编辑器上工作,以帮助作者完成任务,然后才意识到这比节省时间要花费更多的时间。 我花了数周的时间来制作其他模型,并发现一些令人兴奋的游戏模式,而没有考虑它们如何适合游戏或它们会增加多少价值。 在添加了“冒险模式”之后,我对此做得更好,该模式经过了认真的评估,并对范围产生了令人震惊的影响。
  • 直到项目后期,我才聘请人进行质量检查,我并没有将测试新功能放在优先位置,并且一直都在添加许多错误。 这意味着我的常规构建中经常布满错误,并且通过控制台认证获得游戏是一个艰巨的过程。 我熬夜做修改并引入大量明显的问题,从而浪费了承包商的时间之后,才开始改变自己的方式。
  • 总的来说,我过分依赖OOP和继承,使演员,生物,玩家及其物理状态有些交织在一起。 这引入了很多间接性,并使导航代码令人困惑,并且常常使本来应该是显而易见的问题更难以调试。 尤其是在代码库的较早部分中,我使系统和角色之间更加纠缠在一起,彼此之间的了解超出了他们所需要的。
  • 将GameStates和PhysicsStates表示为始终处于活动状态且经常使用协程的gameObjects上的MonoBehaviours,这在我没有完全清理问题时会造成很多难以跟踪的错误。
  • 在优化时,我切换了播放器,vfx,actor和match目标以使用对象池,并创建了大量的Bug,如果我花时间编写系统以确保重置基础状态可以避免。

无耻的插头

感谢您的阅读,希望您觉得这很有价值。 如果您想看到更多类似的内容,请查看Sausage Sports Club并与朋友分享,以支持我的工作。 它即将在7月19日登陆Steam和Nintendo Switch,现在美国的电子商店已经可以预订!