Unity-2021-游戏开发模式-全-
Unity 2021 游戏开发模式(全)
原文:
zh.annas-archive.org/md5/d7723d5b1db10b139a66fb6e2e45f59b译者:飞龙
前言
第一原理,克拉丽斯:简单。阅读马库斯·奥勒留斯。
“对每一件特定的事情,问:它本身是什么?它的本质是什么?”
– 汉尼拔·莱克特
这段引言来自我最喜欢的电影之一,总结了我的学习方法。在游戏行业工作了十多年后,我发现掌握复杂系统的唯一正确方法是将它分解为其最基本的组成部分。换句话说,我在掌握最终形式之前试图理解核心成分。在这本书的整个过程中,你会发现我以一种简单但具有情境性的方法来呈现每个模式。
目标不是使主题内容变得简单化,而是通过隔离每个设计模式背后的核心概念来学习,这样我们就可以观察它们并学习它们的复杂性。我在游戏行业作为设计师和程序员时学到了这种方法。我们经常在被称为“健身房”的独立级别中为我们的游戏构建组件和系统。我们会花费数周时间迭代、测试和调整我们游戏中的每个成分,直到我们理解了如何使它们作为一个整体工作。我以这种方式写这本书,与我的游戏开发方法保持一致,这样你作为读者就可以沉浸在这个主题中,同时培养一些有助于你职业生涯的好习惯。
然而,重要的是要声明,这本书的内容并不是关于 Unity 中模式的终极参考。它只是对这个主题的介绍,而不是学习过程的最终目的地。我并不把自己定位为首要的专家,也不希望我的话在开发者中成为圣经。我只是一个试图找到优雅方式在 Unity 中使用标准软件设计模式的开发者,并想分享我所发现的内容。因此,作为读者,我鼓励你批评、研究、定制和改进本书中呈现的每一件事。
第一章:这本书面向谁
在撰写这本书的过程中,我决定针对目标受众采用一个特定的心理模型,主要原因是在游戏开发方面几乎不可能写一本书来满足每种潜在的读者类型,主要是因为游戏开发是一个多元化的行业,有如此多的平台和类型,每种都有其特定的特征,我无法在单本书中考虑到所有这些。因此,我决定将内容集中在特定的受众上,我可以这样描述:
目标受众是正在使用 Unity 引擎开发移动或独立游戏项目并正在重构其代码以使其更易于维护和扩展的游戏程序员。读者应该对 Unity 和 C#语言有基本的了解。
在开发大型 AAA 或 MMO 游戏的高级程序员可能会发现本书中的特定示例与他们在日常工作中通常面临的架构挑战相比有限。然而,另一方面,本书的内容可能为 Unity 中设计模式的使用提供了另一种视角。因此,如果您已经了解理论并想看看我是如何实现特定模式的,可以自由地跳过任何章节。
本书涵盖的内容
第一章,开始之前,简要介绍了本书的内容。
第二章,游戏设计文档,展示了赛车游戏完整可玩原型的设计文档*。
第三章,Unity 编程简明指南,回顾了一些基本的 C#和 Unity 概念。
第四章,使用单例模式实现游戏管理器,介绍了如何使用臭名昭著的单例模式实现一个全局可访问的游戏管理器*。
第五章,使用状态模式管理角色状态,回顾了经典的状态模式以及如何封装角色的状态行为。
第六章,使用事件总线管理游戏事件,介绍了事件总线模式的基本原理以及如何使用它来管理全局游戏事件。
第七章,使用命令模式实现回放系统,回顾了如何使用命令模式为赛车游戏构建回放系统。
第八章,使用对象池模式进行优化,介绍了如何使用 Unity 原生对象池模式进行性能优化。
第九章,使用观察者模式解耦组件,回顾了如何使用观察者来解耦核心组件。
第十章,使用访问者模式实现加成功能,解释了如何使用访问者模式实现可定制的加成功能游戏机制。
第十一章,使用策略模式实现无人机,介绍了如何使用策略模式动态地为敌对无人机分配攻击行为。
第十二章,使用装饰者模式实现武器系统,解释了如何使用装饰者模式作为武器附件系统的基础。
第十三章,使用空间分区实现关卡编辑器,回顾了如何使用空间分区的一般概念为赛车游戏构建关卡编辑器。
第十四章,使用适配器模式适应系统,涵盖了适配器模式的基础知识以及如何使用它来适配第三方库以便与新系统一起重用。
第十五章,使用外观模式隐藏复杂性,使用外观模式来隐藏复杂性,并为复杂组件的复杂排列提供一个干净的面向前端的接口。
第十六章,使用服务定位器模式管理依赖项,回顾了服务定位器模式的基础知识以及如何使用它来实现一个在运行时允许注册和定位特定服务的系统。
为了充分利用本书
为了充分利用本书,您需要对 Unity 引擎有一个基本的了解。您还需要熟悉 C#,并具备面向对象编程的一般知识。如果您希望重现即将到来的章节中的代码,您需要将最新版本的 Unity 下载到您的计算机上。
您可以通过以下链接获取 Unity 的最新构建版本:unity3d.com/get-unity/download
运行 Unity 的系统要求可以在以下链接找到:docs.unity3d.com/Manual/system-requirements.html
如果您想下载我们 GitHub 仓库上提供的源代码,您需要一个 Git 客户端;我们推荐使用 GitHub Desktop,因为它是最容易使用的。您可以通过以下链接下载:desktop.github.com/
除了这些工具外,运行本书中提供的代码示例不需要下载其他库或依赖项。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
代码实战
本书代码实战视频可以在bit.ly/2UNCZX1查看。
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以通过以下链接下载:static.packt-cdn.com/downloads/9781800200814_ColorImages.pdf.
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“此类初始化Context对象和状态,并触发状态变化。”
代码块设置如下:
namespace Chapter.State
{
public interface IBikeState
{
void Handle(BikeController controller);
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
namespace Chapter.State
{
public enum Direction
{
Left = -1,
Right = 1
}
}
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“这些对象可以发布事件总线声明的特定类型的事件给订阅者。”
警告或重要注意事项如下所示。
小贴士和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 Unity 2021 进行游戏开发模式》*,我们非常乐意听听您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。

https://packt.link/r/<1800200811>
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第二章
第一部分:基础
在本书的这一部分,我们将在进入本书的实践部分之前,回顾一些设计和编程基础。
本节包括以下章节:
-
第一章,开始之前
-
第二章,游戏设计文档
-
第三章,Unity 编程简明指南
在我们开始之前
欢迎来到《Unity 游戏开发实战模式》的第二版;这一版不仅仅是前版的修订,而是对原始书籍的全面升级。自从第一版问世以来,我有幸收到了许多建设性的反馈,这激发了我改进新版结构的想法。正如我们将在以下章节中回顾的那样,本书专注于标题中的“实战”方面;换句话说,我们将亲自动手,为具有设计模式的全功能游戏原型实现系统和功能。这种新的书籍结构方法将更加具体,也更加有趣。与随机的代码示例相比,开发一个可玩的游戏更有趣。因此,在我们开始之前,在以下章节中,我将为本书的内容和整体方法设定具体的参数。
让我们快速回顾一下本章将要讨论的主题,如下所示:
-
关于新版的说明
-
本书哲学
-
什么是设计模式?
-
本书没有涵盖哪些主题?
-
游戏项目
第三章:关于新版的说明
如前所述,我完全根据前版读者的反馈重新设计了这一版,因此,作为结果,我决定从上一版中删除一些读者认为琐碎的内容,例如以下内容:
-
游戏循环和更新模式章节:这些章节过于侧重于理论,并未与本书的“实战”方法保持一致。
-
反模式章节:反模式是一个复杂而深入的主题,值得一本单独的书籍来公正地对待。
本版的目标不是削减内容,而是重新设计本书,以专注于软件设计模式在实用游戏开发中的应用,以便构建一个完整的项目。换句话说,与第一版不同,在第一版中,我采取了独立展示每个设计模式并附带自包含代码示例的方法,这次我们将统一使用它们。
我在本版中增加了一些在前版中缺失的章节,例如以下内容:
-
游戏设计文档:新游戏项目的开始通常是从编写 GDD 开始的。GDD 是一个文档,将帮助我们理解我们将通过本书构建的游戏系统和机制背后的设计意图。
-
Unity 编程简明指南:本书将使用多个 Unity 引擎概念、C#特性和面向对象编程(OOP)技术。在本章中,我们将花时间回顾它们,以建立共同的知识库。
本书哲学
这本书不是技术圣经,也不是使用 Unity 设计模式的终极权威,它最好被描述为一本指南,其中包含设计提案,用于解决 Unity 中的一些游戏编程挑战。每一章中包含的代码示例并非完美实现,因为设计和编程的艺术是一个持续改进的过程,因此这本书的核心目标是向你介绍可能的解决方案,并激发你找到更好的解决方案。
我总是建议在技术主题上寻求至少两个信息来源,尤其是在设计模式方面。避免过度受单一视角的复杂主题影响,以至于它变成了教条而不是知识。
设计模式是什么?
对于那些编程新手来说,设计模式可能是一个新的概念。解释设计模式最简单的方式是,它们是针对常见软件开发问题的可重用解决方案。建筑师克里斯托弗·亚历山大(Christopher Alexander)提出了设计模式的概念,用以描述可重用的设计思想。在 20 世纪 80 年代末,受到这一概念的启发,软件工程师开始尝试将可重用设计模式的概念应用于软件开发,并且随着时间的推移,关于这一主题的几本书被撰写出来,例如所谓的“四人帮”(Gang of Four)所著的经典之作《设计模式:可复用面向对象软件元素》(Design Patterns: Elements of Reusable Object-Oriented Software)。
但在这本书中,我将避免涵盖软件设计模式的学术方面,而是专注于它们在 Unity 中编程游戏机制和系统时的实际应用。我将在每一章中提出一个常见的游戏编程问题,并提出使用针对 Unity 应用程序编程接口(API)进行适配的特定设计模式来解决它。
这本书没有涵盖哪些主题?
游戏编程有许多方面,一本书无法全面深入地涵盖它们。这本书有一个特定的重点:设计模式和 Unity 引擎。因此,如果你刚开始踏上成为专业游戏程序员的旅程,这本书将不足以完成你的教育。但幸运的是,我们行业的一些非常有才华的个人已经花时间撰写了关于游戏开发核心主题的非常专业的书籍。我建议任何对加入游戏行业感兴趣的人阅读以下参考书籍:
-
物理编程:实时碰撞检测,克里斯蒂安·埃里克森(Christer Ericson)
-
引擎编程:游戏引擎架构,贾森·格雷戈里(Jason Gregory)
-
三维(3D)编程:3D 游戏编程与计算机图形学数学,埃里克·伦格维尔(Eric Lengyel)
-
人工智能(AI)编程:通过实例编程游戏 AI,马特·巴克兰德(Mat Buckland)
我将本书的内容集中在游戏编程的一个特定方面,但我会全书提及游戏开发其他领域的概念。所以,如果你对提到的某些主题感到不熟悉,请花时间深入探索它们;在研究上投入的时间将使你成为一个更好的游戏程序员。
游戏项目
在本书中,我们将持续工作在一个单一的游戏项目示例上。游戏的暂定名称是Edge Racer。正如标题可能暗示的那样,它是一款赛车游戏;更具体地说,它是一款未来风格的赛车游戏,玩家驾驶高速摩托车。我们将在第二章,“游戏设计文档”中更详细地回顾游戏的核心概念。但在继续之前,我想列出我选择赛车游戏而不是其他类型游戏(例如,角色扮演游戏(RPG))的原因如下:
-
简单性:赛车游戏有一个简单的前提——尽可能快地到达终点线而不撞车。因为这本书不是关于游戏设计而是关于游戏编程,我想有一个简单的游戏类型,这样我们就可以专注于学习软件设计模式,而不被复杂游戏机制的实施细节所困扰。
-
乐趣:我参与过许多不同类型的游戏开发,我发现赛车游戏是最有趣的一种,因为测试它们非常愉快。在赛车游戏中,你可以快速运行到游戏的特定部分,快速重现错误或测试新功能。与拥有复杂游戏机制和大地图的其他游戏(如 RPG)不同,赛车游戏通常调试起来更快。
-
性能:编程赛车游戏的主要挑战是在添加更多功能和内容的同时保持一致的帧率。因此,我发现专注于赛车游戏迫使你保持良好的游戏程序员习惯,始终关注代码的运行速度,而不仅仅是使其更易读。
-
个人原因:我选择赛车游戏的另一个个人原因是——它是我的最爱。我喜欢玩赛车游戏,也喜欢制作它们。
总之,游戏产业在许多类型和子类型中生产各种产品,但赛车游戏是我们开始学习 Unity 中设计模式的好参考点,因为它是一个简单的背景,并迫使我们关注保持代码干净和快速。
摘要
在本章中,我们回顾了本书的结构,以便我们可以从对其内容和目的的清晰理解开始。关键要点是,我们将使用设计模式来构建名为Edge Racer的新赛车游戏的游戏机制和系统。
在下一章中,我们将回顾 GDD(游戏设计文档),以便对我们将要在后续章节中工作的游戏项目有一个稳固的理解。我不建议跳过这一部分,因为在开始编写代码之前尽可能多地了解一个游戏项目始终是一个好的实践,因为这有助于理解各个部分是如何融入整体的。
游戏设计文档
在我们开始编写代码之前,我们需要完成游戏开发周期中的一个关键步骤,那就是创建一个游戏设计文档(GDD)。GDD 是我们整个项目的蓝图;它的主要目的是将游戏核心概念的整体愿景记录在纸上,并在漫长的生产周期中为多学科开发团队提供指导。
GDD 可以包括以下元素的详细描述:
-
核心视觉、动画和音频成分列表
-
概要、角色传记和叙事结构
-
市场调研材料和货币化策略
-
说明和图解系统与机制描述
在大多数情况下,游戏设计师负责编写和维护 GDD,而游戏程序员负责在文档中实现所描述的系统和方法。因此,程序员和设计师在整个制定 GDD 的过程中必须相互反馈。如果双方不能合作,游戏设计师将编写出在合理时间内无法实现系统的方案。或者程序员会浪费时间编写有缺陷设计的游戏系统代码。
考虑到这本书的重点是编程而不是游戏设计,我将展示一个简化和简短的 GDD 版本,用于我们在本书中将要工作的游戏项目。文档部分还将包括一些针对不熟悉分析 GDD 流程的人的提示和注意事项。
由于篇幅原因,我没有提及艺术家、动画师和音频工程师在起草 GDD 过程中的参与。但他们的参与是至关重要的,因为他们需要知道他们需要生产多少资产来实现游戏的总体愿景。
在本章中,我们将涵盖以下主题:
-
游戏概要和核心机制概述
-
核心游戏循环和目标解释
-
游戏成分和系统列表
第四章:设计文档
以下游戏设计文档被划分为几个部分,代表整个项目中的各个独立兴趣点。例如,游戏的概要部分可能对那些专注于游戏叙事元素的人来说很有兴趣。同时,关于最低要求和系统的部分则面向程序员。但无论我们的专业是什么,阅读整个设计文档并在开始工作之前对项目有一个完整的心理模型都是良好的实践。
游戏概述
刀锋赛车手(暂定名称)是一款未来主义街机赛车游戏,玩家试图控制一辆超高速摩托车穿越障碍赛道,以最高分到达终点线。赛道建立在铁路系统之上,玩家可以在轨道之间操控车辆以避开障碍。在路径上,轨道系统在战略位置生成拾取物品,为玩家提供速度和护盾加成。只有反应敏捷的玩家才能以最快的时间穿过终点线,而不损坏他们的摩托车。
暂定名称是项目生产期间使用的临时名称。它不是最终的,当项目准备发布时可以更改。
独特卖点
以下是一些潜在的独特卖点:
-
国际排行榜,全球玩家可以竞争,将自己的名字列入榜单之首
-
市场上最具挑战性的赛车游戏
-
该游戏可以在任何设备上玩,从手机到 PC
如果与发行商合作,在 GDD(游戏设计文档)中拥有独特的卖点很好。这展示了你对最终产品的愿景以及你将如何进行市场推广。请注意,上述示例是占位符,并非最终结论。
最低要求
以下是目前目标平台的最低要求:
对于移动设备:
-
操作系统:Android 10
-
型号:三星 S9
对于 PC:
-
操作系统:Windows 10
-
显卡:Nvidia GTX 980
^(我们将支持与指定 GTX 卡同一代的等效 ATI 卡。)*
在项目初期就指定最低平台要求对游戏程序员非常有好处。资源有限的平台将要求对游戏的可视资产和代码库进行更多优化,以确保所有目标设备上都能以稳定的帧率运行。
游戏简介
2097 年。人类已经掌握了地球,现在将目光投向了太阳系边缘之外,希望面对新的挑战和发现新的世界。随着全球和平的降临,那些尚未离开地球的人们体验到了一种新的集体无聊。得益于技术和生物黑客技术,过去的所有挑战都已消失。任何人都可以不费吹灰之力成为顶尖运动员、美丽和有才华的人。
感受到全球人口不断增长的不安情绪,古怪的技术企业家和千亿富翁 Neo Lusk 决定发明一项新的运动,这项运动涉及超高速摩托车,其速度是大多数人类难以驾驭的。为了使其更具挑战性,他设计了一种独特的赛道系统,包括轨道和障碍物。
这种新的运动激发了全球人民的热情,数百万人决定参加。但很少有人能够控制这些高科技自行车,并在致命的障碍布局中操控它们。只有那些真正具有钢铁般的神经和像刀刃一样敏锐的反射能力的人才能掌握这项运动,但到目前为止,世界都在等待知道谁将成为第一个被加冕为冠军的人。
尽管我们当前的项目是一个街机赛车游戏,这种类型通常故事性不强但游戏性很强,但仍然有一些叙事元素为游戏世界提供一些背景信息是好的。这激发了玩家的想象力,并为游戏赋予了整体意义。即使是经典的街机游戏,如《吃豆人》和《导弹命令》,也都有一定程度的叙事成分,产生了一种你在虚拟世界中扮演角色的感觉。
游戏目标
核心目标(即玩家目标)的列表如下:
-
主要目标(即胜利状态)是顺利通过终点线,没有任何致命的碰撞。
-
次要目标是通过在避障时采取冒险的赛道来获得“风险”分数,以获得最佳成绩。
-
作为一项额外目标,玩家可以通过收集分布在赛道上的特定数量的收集物品(即拾取物)来获得额外分数。这个目标针对的是“完美主义者”。
-
游戏的最终目标是获得最高分,同时以最快的速度完成比赛。
一个好的游戏设计师会始终设计几个目标来满足各种类型的玩家,以尽可能长时间地保持他们的参与度。例如,具有“完美主义者”心态的玩家不仅仅想要完成游戏,而是通过实现每一个可能的目标和收集每一个可收集物品来完整地完成游戏。
玩家可以通过以下行为在实现核心目标时失败(即失败状态):
-
主要的“失败状态”是在玩家由于致命碰撞而没有到达终点线时触发的。
-
如果激活了检查点游戏模式,玩家会因为未能及时通过每个检查点而失败。
在游戏早期就明确定义所有可能的“失败条件”也是一个好的实践,因为这有助于分析我们需要处理多少种不同的胜利和失败状态。
游戏规则
游戏关卡是一个开放环境,玩家控制一辆在由四条轨道组成的轨道系统上高速行驶的摩托车。玩家可以横向操控车辆以避开障碍物或捕获沿轨道生成的“拾取”物品。玩家必须在最短的时间内到达沿路径设置的检查点,同时通过避开障碍物来避免损坏车辆,从而到达终点线。玩家可以通过选择风险较高的赛道,如转弯避开几乎与之碰撞的障碍物来获得分数。玩家还可以通过按正确顺序捕获一定数量的“拾取”物品来获得额外分数。
游戏循环
以下图示展示了核心游戏循环:

图 2.1 – 核心游戏循环图
游戏在其核心游戏循环中有四个支柱:
-
种族:玩家与时间赛跑,向终点线冲刺。
-
避开:玩家必须在尽可能快的同时避开障碍物,并冒险以获得分数。
-
收集:玩家可以在比赛中收集赛道上放置的物品和奖励。
-
升级:在比赛期间或之后,玩家可以使用收集到的物品升级车辆。
游戏环境
每个级别的首要设置是一个未来派的荒凉太空,有四条横跨地平线的轨道。铁路两侧有金属灯柱和障碍物。天空多云,持续的雾气遮蔽了地平线,而暴风雨时常发生。在轨道上,有各种障碍物和玩家需要避开或捕获的物品。关卡设计侧重于惩罚行动缓慢的玩家,并奖励那些快速行驶且敢于冒险的玩家。
摄像机、控制、角色(3Cs)
3Cs(摄像机、控制和角色)是 AAA 行业中在如Ubisoft等工作室使用的行业标准术语。它基本上表明,玩家体验的核心是由摄像机、玩家角色和控制方案之间和谐关系的平衡所定义的。如果玩家感觉到角色对输入的反应符合预期,同时摄像机无缝地定位在最佳角度,那么整体体验将更加沉浸。让我们详细看看 3Cs。
摄像机
主摄像机将以第三人称视角跟随车辆从后方移动。在转弯时,摄像机将自动调整并旋转到车辆方向最近的轨道对面。在涡轮加速时,摄像机将后退并轻微震动,以传达难以跟随车辆快速移动的感觉。当摩托车减速回到正常速度时,摄像机与摩托车之间的距离将迅速恢复到默认距离,如下图所示:

图 2.2 – 摄像机位置示意图
在当前的游戏版本中,将不会有第一人称或后视摄像头。
角色
在本节中,我们将定义主要玩家控制的角色。
角色描述
主要可玩角色是人类飞行员,他驾驶一辆未来派摩托车车辆,在铁路系统上运行,并且限于以非常高的速度从一个轨道转向另一个轨道。考虑到主要可玩角色在整个游戏过程中始终在车辆驾驶舱内,我们可以将车辆和飞行员在游戏环节中视为一个可玩角色。游戏将不会有其他可玩的人形角色或可控制车辆类型。
角色度量
以下部分概述了主要角色/车辆度量:
-
最小速度:500 km/h
*车辆在比赛中始终在移动,永远不会完全停止。
-
最大速度:6,500 km/h
*车辆只能以最大速度维持几秒钟,其操控能力将降至最低。
-
最大健康值:100%
*车辆有一个以百分比计算的损伤计。
-
攻击伤害:*取决于武器。
车辆没有默认的武器系统,但有一个升级槽位。
-
操控:*取决于当前速度。
当车辆达到最高速度时,其操控能力会降低。
角色状态
以下部分概述了主要角色/车辆状态:
-
空闲:车辆的空闲状态是一个动画周期,显示了底盘因引擎轰鸣而振动,以及尾灯闪烁。
-
加速:加速状态具有车轮转动的动画周期。
-
减速:减速动画会减慢车轮并闪烁尾灯。
-
转向:转向状态具有车辆倾斜并向最近轨道方向移动的动画。
-
制动:**制动动画会使车辆倾斜并在前进轨迹上向侧面滑动。此状态仅在玩家越过终点线后激活。
-
震动:**随着车辆在最高速度时操控能力的降低,震动动画的强度会增加。
-
翻转:当车辆与路障障碍物碰撞时,翻转动画会将车辆向前翻转。
-
滑动:滑动动画将车辆转向并向前滑动。
从一开始,设计和编程团队就定义游戏中每种可玩的可控角色、设备或车辆的类型非常重要。主要是因为为汽车或两足人形角色实现 3Cs 需要不同的方法。就像在现实世界中,驾驶和行走是不同类型的有特定机械复杂性的体验。
控制器
以下部分概述了主要控制器方案,即键盘:

图 2.3 – 键盘输入键图
由于简单性的原因,我们将仅支持键盘作为本书游戏项目的首选输入设备。
游戏成分
下文概述了核心游戏成分:
-
摩托车 是游戏中的主要车辆,可以由玩家或 AI(人工智能)控制。
-
收集物 是在轨道上生成并由玩家通过与之碰撞来拾取的物品。每种类型的物品都将具有独特的形状,并将在轨道上方漂浮几英寸。
-
障碍物 是在轨道上生成的环境成分。某些类型的障碍物实体可能会损坏车辆,而其他障碍物则会阻挡车辆并导致其撞车。
-
武器 是可以附加到玩家车辆上的可用槽位的组件。玩家的武器组件基于激光,并允许他们摧毁障碍物。
-
无人机 是在飞行模式中飞行的机器人实体,它们用激光束攻击玩家。
超级摩托车
| 名称 | 描述 | 升级槽 | 操控性 | 最高速度 |
|---|---|---|---|---|
| 毒液 | 初级赛车手的入门级模型。 | 1 | 80% | 1,900 km/h |
| 银弹 | 中级赛车手的高级模型 | 2 | 60% | 4,000 km/h |
| 死亡骑士 | 最先进的模型,只有顶尖玩家才能访问。 | 4 | 40% | 6,500 km/h |
摩托车的操控性由车辆对玩家输入的响应速度来定义。随着速度的增加,整体稳定性会降低。因此,摩托车在转弯时会开始滑动,并且需要更长的时间来稳定并对玩家的命令做出反应。
收集物
| 名称 | 描述 | 效果 | 价值 |
|---|---|---|---|
| 可收集物品 | 一个可收集物品按顺序生成在轨道的长度上。 | 增加风险分数 | 10 风险点 |
| 加速 | 此物品在指定位置生成在轨道上。 | 填充涡轮表 | 10% 涡轮加速 |
| 损伤修复 ^(* 即健康恢复) | 此物品在特定位置生成在轨道上。 | 修复损伤 | 10% 的健康值 |
障碍物
| 名称 | 描述 | 效果 | 伤害 |
|---|---|---|---|
| 轨道屏障 | 一个又高又平的屏障,可以阻挡轨道,但碰撞时会破碎。 | 造成伤害但不会停止车辆 | 20% 的健康值 |
| 路障 | 一段短栅栏通过使车辆向前翻滚并撞车来阻止车辆。 | 停止车辆 | 10o% 的健康值 |
武器装备
| 名称 | 描述 | 效果 | 范围 |
|---|---|---|---|
| 激光射手 | 单个射线投射武器 | 摧毁范围内的所有物体 | 10 米 |
| 等离子炮 | 多个射线投射武器 | 摧毁范围内的所有物体和 60 度视野内的物体 | 20 米 |
游戏系统
下文概述了核心游戏系统:
-
铁路系统 铁路系统允许玩家的车辆在四条独立的轨道上横向行驶。每条轨道都可以在其长度上生成障碍物和收集物。
-
风险系统 风险系统奖励那些敢于冒险的玩家,例如在最高速度下及时避开障碍物。所给的分数基于玩家避开障碍物时车辆与障碍物之间的距离。安装在自行车前方、范围为 5 米的传感器会持续监控当前赛道上的任何障碍物。一旦检测到潜在的碰撞,传感器就会计算障碍物与车辆之间的距离。分数是根据玩家避开障碍物时的距离来确定的。
下图展示了前向传感器:

图 2.4 – 前向传感器检测障碍物的示意图
-
涡轮加速系统
涡轮加速系统(TBS)允许车辆达到其最高速度。要激活系统,玩家必须通过拾取涡轮加速物品来填充涡轮表。TBS 系统在抬头显示(HUD)上有一个量表,允许玩家可视化可用的涡轮加速量。当 TBS 激活时,车辆操控性降低,更容易受到伤害。
-
车辆升级系统
玩家的车辆有物品槽位,允许他们安装武器和其他升级。根据车辆类型的不同,槽位数量会有所变化。
游戏机制和系统之间的区别有时会引起混淆。在这本书中,我将定义游戏系统为一组游戏机制和元素。例如,武器拾取是元素,但在战场上拾取它们并在库存菜单中选择正确的武器以赢得战斗的动作是单独的机制。但所有这些元素都是整个武器系统的独立组成部分。
游戏菜单
在本节中,我们将概述游戏菜单:
主菜单:主菜单将在游戏开始时可用。菜单选项如下:
-
开始比赛:此选项加载比赛赛道场景。
-
分数:此选项显示最新的前 10 名分数。
-
退出:此选项关闭游戏窗口。
游戏内菜单:游戏内菜单仅在比赛中可访问。菜单选项如下:
-
重新开始比赛:从比赛开始处重新开始
-
退出比赛:将玩家送回大厅场景
游戏 HUD
以下部分概述了游戏的抬头显示(HUD):
头戴式显示器(HUD)仅在比赛中显示,并将包括以下界面组件:
-
涡轮表
-
速度表
-
伤害表
-
倒计时计时器
-
赛道进度条
通常,游戏菜单和 HUD 的最终设计是在生产后期阶段完成的。主要是因为游戏的整体设计、艺术风格和结构在生产早期阶段可能会改变几次,因此,菜单的流程和 HUD 组件的布局会不断变化。
摘要
在本章中,我们花时间回顾了本书游戏项目的当前设计文档草案。这个 GDD 的草案可能并不完整或完美,但通常游戏项目都是从设计文档不完整和想法阶段开始的。所以,我们花时间来审查它仍然是好的。我们不会实现本书中描述的每一个系统、机制和成分,因为我们的主要目标是通过对游戏进行构建来学习设计模式,而不一定是构建一个完整的游戏。
尽管游戏设计不是本书的主要内容,但学会像设计师一样思考是一种只能帮助任何人成为更好的游戏程序员的技能。阅读设计文档有助于我们理解游戏愿景、结构、机制和范围。在做出软件架构决策时,这些都是需要记住的事情。
下一章包括对基本 Unity 编程基础的一个简要介绍。如果你已经是一位经验丰富的程序员,你可以跳到书中的实践部分。但如果你是初学者,我建议在实施即将到来的章节的设计模式之前先阅读这个简介。
进一步阅读
如需更多信息,你可以参考以下书籍:
-
由 Jesse Schell 所著的《游戏设计艺术》
-
由 Marc Saltzman 所著的《游戏设计:圣贤的秘密》
-
由 Scott Rogers 所著的《提升游戏设计水平:游戏设计指南》
-
由 Katie Salen 和 Eric Zimmerman 所著的《游戏设计基础:游戏规则》
Unity 编程简明指南
本章是一个简短的入门指南,帮助你熟悉高级 C# 语言和 Unity 引擎特性。我不会尝试深入解释这里呈现的任何主题,因为这超出了本书的范围。尽管如此,我至少会介绍一些核心概念,以避免在即将到来的章节中引用它们时的混淆。我鼓励那些对 C# 和 Unity 编程有深入了解的人跳过这一章。但我建议初学者和中级开发者花时间回顾本章内容,以获得我们将用于实现和设计游戏系统的语言和引擎特性的总体概念。
在所有情况下,对 C# 和 Unity 的完全掌握不是理解本书的必要条件,只需对一些关键概念有一般意识和熟悉即可。
在本章中,我们将探讨以下主题:
-
你应该已经知道的内容
-
C# 语言特性
-
Unity 引擎特性
本章展示的代码仅用于教学目的。它未经优化,不应用于生产代码。
第五章:你应该已经知道的内容
在本节中,我列举了一些你应该在继续阅读本书更高级部分之前已经熟悉的 C# 语言和 Unity 引擎的核心特性。
以下是一些 C# 的核心特性:
-
熟悉类访问修饰符,如 public 和 private
-
基本原始数据类型(int、string、bool、float 和数组)的基本知识
-
对继承和基类与派生类之间关系的概念理解
以下是一些 Unity 的核心特性:
-
基本了解如何编写
MonoBehaviour脚本并将其作为组件附加到GameObject上 -
能够从头创建新的 Unity 场景并在编辑器中操作 GameObject
-
熟悉 Unity 的基本事件函数(
Awake、Start、Update)及其执行顺序
如果你不太熟悉前面列出的概念,我建议阅读本章 进一步阅读 部分中列出的书籍和文档。
C# 语言特性
事件和委托等语言特性可能对初学者来说过于高级,所以如果你认为自己属于这一类别,请不要担心;你仍然可以享受这本书。只需阅读入门级章节,例如解释单例、状态、外观和适配器等模式的章节。
以下 C# 高级语言特性对于我们在即将到来的章节中实现的一些设计模式的最佳实现是基本的:
- 静态:带有
static关键字的类的方法和成员可以直接通过其名称访问,而无需初始化一个实例。静态方法和成员非常有用,因为它们可以从代码的任何地方轻松访问。以下是一个使用该关键字建立全局可访问事件总线的类示例:
using UnityEngine.Events;
using System.Collections.Generic;
namespace Chapter.EventBus
{
public class RaceEventBus
{
private static readonly
IDictionary<RaceEventType, UnityEvent>
Events = new Dictionary<RaceEventType, UnityEvent>();
public static void Subscribe(
RaceEventType eventType, UnityAction listener)
{
UnityEvent thisEvent;
if (Events.TryGetValue(eventType, out thisEvent))
{
thisEvent.AddListener(listener);
}
else
{
thisEvent = new UnityEvent();
thisEvent.AddListener(listener);
Events.Add(eventType, thisEvent);
}
}
public static void Unsubscribe(
RaceEventType eventType, UnityAction listener)
{
UnityEvent thisEvent;
if (Events.TryGetValue(eventType, out thisEvent))
{
thisEvent.RemoveListener(listener);
}
}
public static void Publish(RaceEventType eventType)
{
UnityEvent thisEvent;
if (Events.TryGetValue(eventType, out thisEvent))
{
thisEvent.Invoke();
}
}
}
}
- 事件:事件允许一个充当发布者的对象发送其他对象可以接收的信号;这些监听特定事件的对象被称为订阅者。当你想要构建事件驱动架构时,事件非常有用。以下是一个发布事件的类示例:
using UnityEngine;
using System.Collections;
public class CountdownTimer : MonoBehaviour
{
public delegate void TimerStarted();
public static event TimerStarted OnTimerStarted;
public delegate void TimerEnded();
public static event TimerEnded OnTimerEnded;
[SerializeField]
private float duration = 5.0f;
void Start()
{
StartCoroutine(StartCountdown());
}
private IEnumerator StartCountdown()
{
if (OnTimerStarted != null)
OnTimerStarted();
while (duration > 0)
{
yield return new WaitForSeconds(1f);
duration--;
}
if (OnTimerEnded != null)
OnTimerEnded();
}
void OnGUI()
{
GUI.color = Color.blue;
GUI.Label(
new Rect(125, 0, 100, 20), "COUNTDOWN: " + duration)
}
}
- 委托:当你理解其底层低级机制时,委托的概念很简单。委托的高级定义是它们持有函数的引用。但这是对委托实际在幕后做什么的非常抽象的定义。它们是函数指针,这意味着它们持有其他函数的内存地址。因此,我们可以将它们想象成一个包含函数位置列表的地址簿。这就是为什么委托可以持有多个它们,并一次性调用它们。以下是一个通过将特定的本地函数分配给发布者的委托来订阅由发布者类触发的事件的类示例:
using UnityEngine;
public class Buzzer : MonoBehaviour
{
void OnEnable()
{
// Assigning local functions to delegates defined in the
// Timer class
CountdownTimer.OnTimerStarted += PlayStartBuzzer;
CountdownTimer.OnTimerEnded += PlayEndBuzzer;
}
void OnDisable()
{
CountdownTimer.OnTimerStarted -= PlayStartBuzzer;
CountdownTimer.OnTimerEnded -= PlayEndBuzzer;
}
void PlayStartBuzzer()
{
Debug.Log("[BUZZER] : Play start buzzer!");
}
void PlayEndBuzzer()
{
Debug.Log("[BUZZER] : Play end buzzer!");
}
}
- 泛型:C# 的一个相关特性,允许在类被客户端声明和实例化之前延迟指定类型。当我们说一个类是泛型的时候,它没有定义的对象类型。以下是一个泛型类示例,它可以作为一个模板使用:
// <T> can be any type.
public class Singleton<T> : MonoBehaviour where T : Component
{
// ...
}
- 序列化:序列化是将对象实例转换为二进制或文本形式的过程。这种机制允许我们将对象的状态保存在文件中。以下是一个将对象实例序列化并保存为文件的函数示例:
private void SerializePlayerData(PlayerData playerData)
{
// Serializing the PlayerData instance
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Create(Application.persistentDataPath +
"/playerData.dat");
bf.Serialize(file, playerData);
file.Close();
}
C# 是一门深度很大的编程语言,因此在此书的范围内不可能解释其所有核心特性。本书本节中介绍的都是非常有用的,并将帮助我们实现后续章节中描述的游戏系统和模式。
Unity 引擎特性
Unity 是一个功能齐全的引擎,包括全面的脚本 API、动画系统以及许多用于游戏开发的附加功能。我们无法在本书中涵盖所有内容,因此我只会列出我们在即将到来的设计模式章节中将要使用的核心 Unity 组件:
-
预制体:预制体是由组装好的 GameObject 和组件组成的预制容器。例如,你可以在游戏中为每种类型的车辆创建单独的预制体,并在场景中动态加载它们。预制体允许你将可重用的游戏实体作为构建块进行构建和组织。
-
Unity 事件和动作:Unity 有一个本地的事件系统;它与 C#事件系统非常相似,但具有额外的引擎特定功能,例如在检查器中查看和配置它们的能力。
-
ScriptableObjects:从
ScriptableObject基类派生的类可以作为数据容器使用。另一个名为 MonoBehaviour 的本地 Unity 基类用于实现行为。因此,建议使用 MonoBehaviours 来包含你的逻辑,使用 ScriptableObjects 来包含你的数据。ScriptableObject的实例可以保存为资产,通常用于创作工作流程。它允许非程序员无需一行代码即可创建特定类型实体的新变体。
以下是一个简单的ScriptableObject示例,允许创建新的可配置的Sword实例:
using UnityEngine;
[CreateAssetMenu(fileName = "NewSword", menuName = "Weaponary/Sword")]
public class Sword: ScriptableObject
{
public string swordName;
public string swordPrefab;
}
- 协程:协程的概念不仅限于 Unity,而且是 Unity API 的一个基本工具。函数的典型行为是从开始到结束执行自己。但协程是一个具有额外等待、计时甚至暂停其执行过程能力的函数。这些附加功能使我们能够实现用传统函数难以实现的行为。协程类似于线程,但提供的是并发而不是并行。以下代码示例展示了使用协程实现倒计时计时器的实现:
using UnityEngine;
using System.Collections;
public class CountdownTimer : MonoBehaviour
{
private float _duration = 10.0f;
IEnumerator Start()
{
Debug.Log("Timer Started!");
yield return StartCoroutine(WaitAndPrint(1.0F));
Debug.Log("Timer Ended!");
}
IEnumerator WaitAndPrint(float waitTime)
{
while (Time.time < _duration)
{
yield return new WaitForSeconds(waitTime);
Debug.Log("Seconds: " + Mathf.Round(Time.time));
}
}
}
如我们所见,Unity 有一些丰富且直观的功能,使我们能够实现系统和组织数据。由于这些核心功能超出了本书的预期范围,我们无法深入探讨每个功能。如果你在继续前进之前觉得需要更多关于引擎的信息,我建议你回顾进一步阅读部分下链接的材料。
摘要
本章旨在作为入门指南,在进入书籍的实践部分之前,建立一个共享知识库。但不需要掌握前几节中介绍的功能,就可以开始使用 Unity 中的设计模式。我们将在接下来的章节中更详细地回顾其中的一些,这次是在实际的应用场景中。
在下一章中,我们将回顾我们的第一个模式,臭名昭著的单例模式。我们将使用它来实现一个负责初始化游戏的GameManager类。
进一步阅读
如需更多信息,您可以参考以下材料:
-
通过 Unity 2020 开发游戏学习 C# 由 Harrison Ferrone 编写
-
Unity 用户手册 由 Unity Technologies 编写:
docs.unity3d.com/Manual/index.html
第六章
第二部分:核心模式
在本书的这一节中,我们将回顾一些基本模式,并利用它们构建我们游戏的核心系统和机制。
本节包含以下章节:
-
第四章,使用单例模式实现游戏管理器
-
第五章,使用状态模式管理角色状态
-
第六章,使用事件总线管理游戏事件
-
第七章,使用命令模式实现回放系统
-
第八章,使用对象池模式进行优化
-
第九章,使用观察者模式解耦组件
-
第十章,使用访问者模式实现加成功能
-
第十一章,使用策略模式实现无人机
-
第十二章,使用装饰者模式实现武器系统
-
第十三章,使用空间分区实现关卡编辑器
使用单例实现游戏管理器
在这个第一个实践性章节中,我们将回顾编程领域中臭名昭著的软件设计模式之一,即单例。许多人可能会争辩说,单例是 Unity 开发者中最广泛使用的模式,也许是因为它是学习起来最直接的模式。但它也可以迅速成为我们编程工具箱中的“胶带”,每次我们需要快速修复复杂的架构问题时,我们都会伸手去拿它。
例如,当使用这种模式时,我们可以快速建立一个简单的代码架构,围绕将我们游戏的所有核心系统封装和管理在单独的管理类中。然后我们可以让这些管理器提供干净、直接的接口,从而隐藏系统的内部复杂性。此外,为了确保这些管理器易于访问并且每次只有一个实例运行,我们将它们实现为单例。这种方法听起来很稳固且有益,但它充满了陷阱,因为它将在核心组件之间创建强耦合,并使单元测试变得非常困难。
在这本书中,我们将尝试摆脱这种类型的架构,并使用设计模式来建立一个更健壮、模块化和可扩展的代码库。但这并不意味着我们将忽视单例并认为它本质上是有缺陷的。相反,在本章中,我们将探讨一个这种模式非常适合的使用案例。
本章将涵盖以下主题:
-
单例模式的基本原理
-
在 Unity 中编写可重用的单例类
-
实现全局可访问的游戏管理器
第七章:技术要求
这是一个实践性章节;你需要对 Unity 和 C#有一个基本的了解。
我们将使用以下特定的 Unity 引擎和 C#语言概念:泛型。
如果不熟悉这个概念,请参阅第三章,Unity 编程简明指南。
本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter04。
查看以下视频以查看代码的实际应用:
泛型是 C#的一个引人入胜的特性,它允许我们在运行时延迟指定类的类型。当我们说一个类是泛型的时候,这意味着它没有定义的对象类型。这种方法的优势在于,当我们初始化它时,我们可以给它指定一个特定的类型。
理解单例模式
正如其名所示,单例模式的主要目标是保证唯一性。这种方法意味着如果一个类正确实现了这个模式,一旦初始化,它将在运行时内存中只有一个自己的实例。这种机制在有一个需要从单一且一致的入口点全局访问的系统管理的类时非常有用。
单例的设计相当简单。当你实现一个单例类时,它就负责确保在内存中只有一个自己的实例。一旦单例检测到与自身类型相同的对象实例,它将立即销毁它。因此,它相当无情,不容忍任何竞争。以下图表在一定程度上说明了这个过程:

图 4.1 – 单例模式的 UML 图
从对单例模式的描述中,最重要的收获是,如果实现得当,它确保只能有一个实例;如果不这样,它就失败了其目的。
好处与缺点
这些是单例模式的一些好处:
-
全局访问: 我们可以使用单例模式来创建对资源或服务的全局访问点。
-
控制并发: 这种模式可以用来限制对共享资源的并发访问。
这些是单例模式的一些缺点:
-
单元测试: 如果过度使用,单例可能会使单元测试变得非常困难。我们可能会遇到单例对象依赖于其他单例的情况。如果任何一个在某个时刻缺失,依赖链就会被打破。这个问题通常发生在将外观模式和单例结合使用来设置面向核心系统的前端接口时。我们最终会得到一系列管理类,每个管理游戏的一个特定核心组件,所有这些类都相互依赖才能运行。因此,单独测试和调试变得不可能。
-
惰性: 由于其易用性,单例是一种可以迅速养成不良编程习惯的模式。正如在单元测试的缺点中提到的,我们可以很容易地通过单例从任何地方访问一切。它提供的简单性也可能使我们不愿意在编写代码时尝试更复杂的方法。
在做出设计选择时,始终牢记你的架构是否可维护、可扩展和可测试是非常重要的。当涉及到可测试性时,我经常问自己是否可以轻松地单独和独立地测试我的核心系统、组件和机制。如果不能,那么我知道我可能做出了某些可能不明智的决定。
设计游戏管理器
在 Unity 项目中,我们经常看到的标准类是游戏管理器。开发者通常将其实现为单例,但其责任因代码库而异。一些程序员用它来管理顶级游戏状态,或者作为全局可访问的前端接口来访问核心游戏系统。
在本章的上下文中,我们将赋予它管理游戏会话的单一责任。类似于桌面游戏中的游戏主持人,它将负责为玩家设置游戏。它还可以承担额外的责任,例如与后端服务通信、初始化全局设置、记录日志以及保存玩家的进度。
需要牢记的关键点是游戏管理器将在整个游戏生命周期中存活。因此,在内存中始终将有一个单一但持久的实例。
以下图示说明了整体概念:

图 4.2 – 说明游戏管理器生命周期的图示
在下一节中,我们将把刚刚审查的设计转换为代码。
实现游戏管理器
在本节中,我们将实现单例和游戏管理器类。我们将尝试利用一些核心 Unity API 功能来适应在引擎中使用该模式:
- 在这个过程的第一步,我们将实现
Singleton类。为了更容易理解其复杂性,我们将将其分为两个不同的部分:
using UnityEngine;
namespace Chapter.Singleton
{
public class Singleton<T> :
MonoBehaviour where T : Component {
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
_instance = FindObjectOfType<T>();
if (_instance == null)
{
GameObject obj = new GameObject();
obj.name = typeof(T).Name;
_instance = obj.AddComponent<T>();
}
}
return _instance;
}
}
在Singleton<T>类的第一部分中,我们可以看到我们实现了一个public static属性,并带有get访问器。在这个访问器中,我们确保在初始化新实例之前没有现有的实例。FindObjectOfType<T>()搜索指定类型的第一个已加载对象。如果我们找不到,那么我们将创建一个新的GameObject,重命名它,并添加一个非指定类型的组件。
当我们实现GameManager类时,这个过程将更为明显。
- 让我们实现
Singleton类的最后一段:
public virtual void Awake()
{
if (_instance == null)
{
_instance = this as T;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
}
}
对于类的最后一段,我们有一个标记为virtual的Awake()方法,这意味着它可以被派生类覆盖。需要理解的是,当引擎调用Awake()方法时,单例组件将检查内存中是否已经初始化了自身的实例。如果没有,那么它将成为当前实例。但如果已经存在一个实例,它将销毁自己以防止重复。
因此,在场景中一次只能有一个特定类型的单例实例。如果你尝试添加两个,其中一个将被自动销毁。
另一个需要审查的重要细节是以下这一行:
DontDestroyOnLoad(gameObject);
DontDestroyOnLoad 是 Unity API 中包含的一个公共静态方法;它防止目标对象在新场景加载时被销毁。换句话说,它确保对象的当前实例在场景切换时仍然存在。这个 API 功能对我们来说很有用,因为它保证了对象将在整个应用程序的生命周期内可用,在这个上下文中,是游戏。
- 对于我们实现的最后一步,我们将编写
GameManager类的骨架版本。我们将只关注验证我们的Singleton实现的代码,出于简洁的考虑:
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Chapter.Singleton
{
public class GameManager : MonoBehaviour
{
private DateTime _sessionStartTime;
private DateTime _sessionEndTime;
void Start() {
// TODO:
// - Load player save
// - If no save, redirect player to registration scene
// - Call backend and get daily challenge and rewards
_sessionStartTime = DateTime.Now;
Debug.Log(
"Game session start @: " + DateTime.Now);
}
void OnApplicationQuit() {
_sessionEndTime = DateTime.Now;
TimeSpan timeDifference =
_sessionEndTime.Subtract(_sessionStartTime);
Debug.Log(
"Game session ended @: " + DateTime.Now);
Debug.Log(
"Game session lasted: " + timeDifference);
}
void OnGUI() {
if (GUILayout.Button("Next Scene")) {
SceneManager.LoadScene(
SceneManager.GetActiveScene().buildIndex + 1);
}
}
}
}
为了给 GameManager 提供更多上下文,我们留下了一个潜在任务的 TODO 列表,以便于该类完成。但我们还添加了一个计时器和 GUI 按钮。这两个都将帮助我们验证在开始测试阶段时我们的 Singleton 是否正常工作。
但目前,我们的 GameManager 不是一个 Singleton;要使其成为单一实例,我们只需对代码中的一行进行修改,如下所示:
public class GameManager : Singleton<GameManager>
如此简单;我们从一个普通的 MonoBehaviour 类转换成了一个 Singleton,只用了五行代码。这是因为我们使用了泛型。因此,我们的 Singleton 类可以是任何东西,直到我们给它分配一个特定的类型。
- 因此,在我们的最后一步中,我们将
GameManager类转换成了Singleton,如下所示:
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Chapter.Singleton
{
public class GameManager : Singleton<GameManager>
{
private DateTime _sessionStartTime;
private DateTime _sessionEndTime;
void Start() {
// TODO:
// - Load player save
// - If no save, redirect player to registration scene
// - Call backend and get daily challenge and rewards
_sessionStartTime = DateTime.Now;
Debug.Log(
"Game session start @: " + DateTime.Now);
}
void OnApplicationQuit() {
_sessionEndTime = DateTime.Now;
TimeSpan timeDifference =
_sessionEndTime.Subtract(_sessionStartTime);
Debug.Log(
"Game session ended @: " + DateTime.Now);
Debug.Log(
"Game session lasted: " + timeDifference);
}
void OnGUI() {
if (GUILayout.Button("Next Scene")) {
SceneManager.LoadScene(
SceneManager.GetActiveScene().buildIndex + 1);
}
}
}
}
现在我们已经准备好了所有配料,是时候开始测试阶段了,我们将在下一步进行。
测试游戏管理器
如果你希望测试你在 Unity 实例中编写的类,那么你应该按照以下步骤进行:
-
创建一个名为
Init的新空 Unity 场景。 -
在
Init场景中,添加一个空的GameObject并将其GameManager类附加到它上。 -
创建多个空 Unity 场景,数量不限。
-
在 文件 菜单下的 构建设置 中,将 Init 场景添加到索引 0:

图 4.3 – 构建设置
- 然后将你新创建的空 Unity 场景添加到 构建设置 列表中,数量不限。
如果你现在启动 Init 场景,你应该看到一个名为 下一场景 的 GUI 按钮,如下截图所示:

图 4.4 – 代码示例执行截图
如果你点击 下一场景 按钮,你将循环浏览在构建设置中添加的每个场景,并且 GUI 将持续显示在屏幕上。如果你停止运行游戏,你应该在控制台日志中看到你的会话时长。如果你尝试在任何场景中为 GameObject 添加额外的 GameManager,你会注意到它们被销毁,因为在整个游戏生命周期中只能存在一个。
这就完成了我们的测试;我们现在有了 GameManager 类的第一个草稿和一个可重用的 Singleton 实现。
概述
在本章中,我们探讨了最具有争议的设计模式之一。但我们找到了一种以一致和可重用的方法来实现它的方法。单例模式是完美适合 Unity 编码模型的模式,但过度使用它可能会导致你过度依赖它。
在下一章中,我们将回顾状态模式,我们将使用它来实现我们游戏主要成分——赛车自行车——的控制类。
使用状态模式管理角色状态
在视频游戏中,实体根据玩家输入或事件不断从一个状态转换到另一个状态。一个敌人角色可能会从空闲状态转换到攻击状态,这取决于它是否看到玩家在地图上移动。玩家角色会不断从一个动画转换到另一个动画,以响应玩家的输入。在本章中,我们将回顾一个允许我们定义实体的单个状态及其状态行为的模式。
首先,我们将使用传统的状态模式来管理我们主要角色的单个有限状态。在我们的赛车游戏项目中,主要角色是一辆摩托车,因此它有一套机械行为和动画。随着我们对状态模式的实现进展,我们很快就会看到其局限性,我们将通过引入 FSM(有限状态机)概念来克服这些局限性。
我们不会手动编写 FSM,而是探索 Unity 原生动画系统中的原生状态机实现。因此,本章将提供两种方法,介绍状态模式的核心概念,以及如何重新利用 Unity 的动画系统来管理角色状态和动画。
在本章中,我们将涵盖以下主题:
-
状态模式概述
-
将状态模式实现为管理主要角色状态
第八章:技术要求
本章是实践性的。你需要对 Unity 和 C#有基本的了解。
本章的代码文件可以在 GitHub 上找到,地址为github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter05。
查看以下视频以查看代码的实际运行效果:bit.ly/36EbbHe
状态模式概述
我们使用状态设计模式来实现一个系统,允许对象根据其内部状态改变其行为。因此,上下文的变化将导致行为的变化。
状态模式在其结构中有三个核心参与者:
-
Context类定义了一个接口,允许客户端请求改变对象的内部状态。它还持有当前状态的指针。 -
IState接口为具体状态类建立了一个实现合同。 -
ConcreteState类实现了IState接口,并公开了一个名为handle()的方法,Context对象可以调用此方法来触发状态的行为。
现在我们来回顾一下这个模式定义的图表,但是在实际实现的环境中:

图 5.1 – 状态模式的 UML 图
要更新一个对象的状态,客户端可以通过Context对象设置预期的状态并请求转换到新状态。因此,上下文总是知道它所处理的对象的当前状态。然而,它不需要熟悉每个具体的州类。我们可以添加尽可能多的州类,而无需修改Context类中的一行代码。
例如,这种方法比在单个类中定义所有状态并使用 switch case 管理它们之间的转换具有更好的可扩展性,正如我们可以在以下伪代码示例中看到:
public class BikeController
{
....
switch (state)
{
case StopState:
...
break;
case StarState:
...
break;
case TurnState:
...
break;
}
现在我们已经对状态模式的结构有了基本的了解,我们可以开始定义我们主要角色的状态和行为,在这个案例中,是自行车,正如我们将在下一节中看到的。
定义角色状态
在我们的游戏中,将在状态之间转换最多的实体是我们的赛车。它处于玩家的控制之下;它与环境中的几乎所有元素互动,包括障碍物和敌机。因此,它将不会长时间停留在同一状态。
以下图表展示了我们自行车的有限状态列表:

图 5.2 – 说明自行车有限状态的图表
现在让我们定义一些对于所列状态的一些预期行为:
-
停止:在这个状态下,自行车没有移动。它的当前速度为零。它的档位设置为空档。如果我们决定在那个状态下发动机应该开启,但以怠速模式运行,我们可以在自行车底盘上添加一个振动的动画来显示发动机正在运行。
-
启动:在这个状态下,自行车以全速行驶,车轮转动以匹配前进运动。
-
转向:在转向状态下,自行车根据玩家的输入向左或向右转向。
-
碰撞:如果自行车处于碰撞状态,这意味着它着火了,并且侧翻在地。车辆将不再对玩家的输入做出反应。
这些只是对我们车辆潜在状态的未经打磨的定义。我们可以根据需要细化到非常具体,但就我们的用例而言,这已经足够了。
必须记住,每个状态的定义都包含了行为和动画的描述。在下一节中编写和审查我们实现状态模式时,这些要求将是必须考虑的。
实现状态模式
在本章的这一部分,我们将实现状态模式,明确的目标是封装我们在上一节中定义的每个车辆有限状态的预期行为。
我们将专注于编写简洁的骨架类,以简明扼要和清晰。这种方法将使我们能够专注于模式的结构,而不会被实现细节所拖累。
还需要注意的是,本书中展示的状态模式版本可能有些不寻常,因为它与传统方法有些偏差。因此,它应该被视为适应 Unity 项目上下文和特定用例的状态模式的排列组合。
实现状态模式
我们将分几个步骤回顾我们的代码示例:
- 让我们先写下每个具体状态类将实现的接口:
namespace Chapter.State
{
public interface IBikeState
{
void Handle(BikeController controller);
}
}
我们应该注意,我们在Handle()方法中传递了一个BikeController的实例。这种方法允许状态类访问BikeController的公共属性。这种方法可能略偏离传统,因为通常是将Context对象传递给状态。
然而,没有任何阻止我们将Context对象和BikeController实例传递给状态类。或者,我们可以在初始化每个状态类时设置BikeController的实例引用。
然而,对于这个用例来说,这样做更简单。
- 现在我们有了接口,让我们来实现上下文类:
namespace Chapter.State
{
public class BikeStateContext
{
public IBikeState CurrentState
{
get; set;
}
private readonly BikeController _bikeController;
public BikeStateContext(BikeController bikeController)
{
_bikeController = bikeController;
}
public void Transition()
{
CurrentState.Handle(_bikeController);
}
public void Transition(IBikeState state)
{
CurrentState = state;
CurrentState.Handle(_bikeController);
}
}
}
如我们所见,BikeStateContext类公开了一个属性,指向自行车的当前状态;因此,它知道任何状态变化。因此,我们可以通过其属性更新我们实体的当前状态,并通过调用Transition()方法进入该状态。
例如,如果我们想通过让每个状态类声明链中的下一个状态来将状态链接在一起,这种机制是有益的。然后,我们可以通过简单地调用Context对象的Transition()方法来遍历链接的状态。然而,对于我们的用例来说,这种方法是不必要的,因为我们将会调用重载的Transition()方法,并简单地传递我们想要转换到的状态。
- 接下来是
BikeController。这个类初始化Context对象和状态,并且触发状态变化:
using UnityEngine;
namespace Chapter.State {
public class BikeController : MonoBehaviour {
public float maxSpeed = 2.0f;
public float turnDistance = 2.0f;
public float CurrentSpeed { get; set; }
public Direction CurrentTurnDirection {
get; private set;
}
private IBikeState
_startState, _stopState, _turnState;
private BikeStateContext _bikeStateContext;
private void Start() {
_bikeStateContext =
new BikeStateContext(this);
_startState =
gameObject.AddComponent<BikeStartState>();
_stopState =
gameObject.AddComponent<BikeStopState>();
_turnState =
gameObject.AddComponent<BikeTurnState>();
_bikeStateContext.Transition(_stopState);
}
public void StartBike() {
_bikeStateContext.Transition(_startState);
}
public void StopBike() {
_bikeStateContext.Transition(_stopState);
}
public void Turn(Direction direction) {
CurrentTurnDirection = direction;
_bikeStateContext.Transition(_turnState);
}
}
}
如果我们没有将自行车的行为封装在单独的状态类中,我们可能会在BikeController中实现它们。这种方法可能会导致控制器类膨胀,难以维护。因此,通过使用状态模式,我们使我们的类更小,更容易维护。
我们还将把BikeController的原始职责,即控制自行车的核心组件,归还给它。它的存在是为了提供一个控制自行车的接口,公开其可配置属性,并管理其结构依赖。
- 以下三个类将成为我们的状态;它们相当直观。注意,每个都实现了
IBikeState接口。让我们从BikeStopState开始:
using UnityEngine;
namespace Chapter.State
{
public class BikeStopState : MonoBehaviour, IBikeState
{
private BikeController _bikeController;
public void Handle(BikeController bikeController)
{
if (!_bikeController)
_bikeController = bikeController;
_bikeController.CurrentSpeed = 0;
}
}
}
- 下一个状态类是
BikeStartState:
using UnityEngine;
namespace Chapter.State
{
public class BikeStartState : MonoBehaviour, IBikeState
{
private BikeController _bikeController;
public void Handle(BikeController bikeController)
{
if (!_bikeController)
_bikeController = bikeController;
_bikeController.CurrentSpeed =
_bikeController.maxSpeed;
}
void Update()
{
if (_bikeController)
{
if (_bikeController.CurrentSpeed > 0)
{
_bikeController.transform.Translate(
Vector3.forward * (
_bikeController.CurrentSpeed *
Time.deltaTime));
}
}
}
}
}
- 最后,还有
BikeTurnState,它可以使自行车向左或向右转弯:
using UnityEngine;
namespace Chapter.State
{
public class BikeTurnState : MonoBehaviour, IBikeState
{
private Vector3 _turnDirection;
private BikeController _bikeController;
public void Handle(BikeController bikeController)
{
if (!_bikeController)
_bikeController = bikeController;
_turnDirection.x =
(float) _bikeController.CurrentTurnDirection;
if (_bikeController.CurrentSpeed > 0)
{
transform.Translate(_turnDirection *
_bikeController.turnDistance);
}
}
}
}
- 对于我们的最终类
BikeController,它引用了一个名为Direction的枚举,我们将在下面实现它:
namespace Chapter.State
{
public enum Direction
{
Left = -1,
Right = 1
}
}
我们现在已经准备好了所有配料,可以测试我们的状态模式实现。
测试状态模式实现
要快速测试你在 Unity 实例中状态模式的实现,你需要遵循以下步骤:
-
将我们刚刚审查的所有脚本复制到你的 Unity 项目中。
-
创建一个新的空场景。
-
向场景中添加一个 3D GameObject,例如一个立方体,确保它对主摄像机可见。
-
将
BikeController脚本附加到 GameObject 上。 -
还将以下客户端脚本附加到 GameObject 上:
using UnityEngine;
namespace Chapter.State
{
public class ClientState : MonoBehaviour
{
private BikeController _bikeController;
void Start()
{
_bikeController =
(BikeController)
FindObjectOfType(typeof(BikeController));
}
void OnGUI()
{
if (GUILayout.Button("Start Bike"))
_bikeController.StartBike();
if (GUILayout.Button("Turn Left"))
_bikeController.Turn(Direction.Left);
if (GUILayout.Button("Turn Right"))
_bikeController.Turn(Direction.Right);
if (GUILayout.Button("Stop Bike"))
_bikeController.StopBike();
}
}
}
一旦你开始场景,你应该在你的屏幕上看到以下 GUI 按钮,你可以使用它们通过触发状态变化来控制 GameObject:

图 5.3 – 代码示例的截图
在本书的下一节中,我们将回顾状态模式的优点,但也会讨论其局限性。
状态模式的优缺点
以下使用状态模式的优点:
-
封装:状态模式允许我们将一个实体的状态行为实现为一个组件集合,当它改变状态时,可以动态地分配给对象。
-
维护:我们可以轻松实现新状态,而无需修改长的条件语句或臃肿的类。
然而,当我们使用状态模式来管理一个动画角色时,它确实有其局限性。
这里是一个潜在局限性的简短列表:
-
混合:在原生形式中,状态模式不提供混合动画的解决方案。当你想要在角色的动画状态之间实现平滑的视觉过渡时,这种限制可能会成为一个问题。
-
转换:在我们的模式实现中,我们可以轻松地在状态之间切换,但我们没有定义它们之间的关系。因此,如果我们希望根据关系和条件定义状态之间的转换,我们可能需要编写更多的代码;例如,如果我想让空闲状态转换到行走状态,然后行走状态转换到奔跑状态。这会根据触发器或条件自动和顺畅地来回转换。在代码中这样做可能会很耗时。
然而,上述限制可以通过使用 Unity 动画系统和其原生状态机来克服。我们可以轻松定义动画状态,并将动画剪辑和脚本附加到配置的每个状态上。但它的更重要特性是,它允许我们通过可视化编辑器定义和配置状态之间的一系列转换,条件触发器,正如我们在这里看到的:

图 5.4 – Unity 动画系统编辑器的截图
呈现的矩形代表单个动画状态,箭头表示关系和转换。深入探讨如何使用 Unity 动画超出了本书的范围。本章的目标是在 Unity 引擎的上下文中介绍状态模式。正如我们所见,Unity 为我们提供了一个原生解决方案,我们可以利用它来管理我们角色的动画状态。
必须记住,这个工具不仅限于动画人形角色。我们可以用它来处理机械实体,如汽车,甚至背景元素,如自动售货机;因此,任何具有状态、动画和行为的东西。
在下一节中,我们将审查在使用状态模式之前需要考虑的替代模式短名单。
关于 Unity 动画系统的更多信息,您可以在以下链接的官方文档中阅读:
docs.unity3d.com/Manual/AnimationSection.html。
审查替代方案
下面的列表是相关或作为状态模式替代方案的模式的列表:
-
黑板/行为树:如果你计划为 NPC 角色实现复杂的 AI 行为,我建议考虑黑板或行为树等模式。例如,如果你需要实现具有动态决策行为的 AI,那么行为树(BT)是一个更合适的方法,因为它允许你使用动作树来实现行为。
-
有限状态机(FSM):在讨论状态模式时,经常出现的一个问题是有限状态机(FSM)与状态模式之间的核心区别。简短的回答是,状态模式关注封装对象的状态相关行为。然而,FSM 在基于特定输入触发器在有限状态之间转换方面涉及更深。因此,FSM 通常被认为更适合实现类似自动机的系统。
-
备忘录(Memento):备忘录与状态模式类似,但具有一个额外功能,使对象能够回滚到之前的状态。这种模式在实现需要能够撤销对其自身所做的更改的系统时可能很有用。
摘要
在本章中,我们能够利用状态模式来定义和实现我们主要角色的状态化行为。在我们的案例中,这个角色是一辆车。在特定代码示例的上下文中审查其结构后,我们看到了它在处理动画实体时的局限性。然而,Unity 为我们提供了一个原生解决方案,允许我们使用复杂的状态机和可视化编辑器来管理动画角色的状态。
然而,这并不意味着在 Unity 中状态模式本身是无用的。我们可以轻松地将其用作构建状态化系统或机制的基础。
在下一章中,我们将定义我们赛车游戏的全球状态,并使用事件总线来管理它们。
使用事件总线管理游戏事件
事件总线充当一个中心枢纽,管理一组特定的全局事件,对象可以选择订阅或发布。这是我在工具箱中与事件管理相关最直接的模式。它将分配对象作为订阅者或发布者的过程简化为单行代码。正如你可以想象的那样,当你需要快速得到结果时,这可能会很有益。但像大多数简单解决方案一样,它也有一些缺点和限制,我们将在后面的内容中进一步探讨。
在本章提供的代码示例中,我们将使用事件总线向需要监听比赛整体状态变化的组件广播特定的比赛事件。但重要的是要记住;我提出使用事件总线作为管理全局比赛事件解决方案的原因是其简单性,而不是其可扩展性。因此,它可能不是所有情况下的最佳解决方案,但它是最直接的模式之一,我们将在接下来的章节中看到。
本章将涵盖以下主题:
-
事件总线模式的概述
-
Race Event Bus 的实现
为了简洁和清晰,本章包含简化的代码示例。如果你希望在一个实际的游戏项目中查看该模式的完整实现,请打开 GitHub 项目中的FPP文件夹。你可以在技术要求部分找到链接。
第九章:技术要求
我们还将使用以下特定的 Unity 引擎 API 功能:
-
Static -
UnityEvents -
UnityActions
如果你对这些概念不熟悉,请查阅第三章,《Unity 编程简明指南》。
本章的代码文件可以在 GitHub 上找到,地址为github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter06。
查看以下视频,以查看代码的实际应用:
如果你发现自己难以理解委托背后的机制,请记住,它们在 C/C++中类似于函数指针。简单来说,委托指向一个方法。委托通常用于实现事件处理程序和回调。动作是一种可以与具有 void 返回类型的方法一起使用的委托类型。
理解事件总线模式
当一个对象(发布者)引发事件时,它会发送一个信号,其他对象(订阅者)可以接收。该信号以通知的形式出现,可以指示动作的发生。在事件系统中,一个对象广播事件。只有订阅了该事件的那些对象才会收到通知并选择如何处理它。因此,我们可以将其想象为一种突然爆发的无线电信号,只有那些将天线调谐到特定频率的人才能检测到。
事件总线模式是消息系统和发布-订阅模式的一个近亲,后者是事件总线所做事情的更准确名称。这个模式标题中的关键字是术语总线。在简单的计算机术语中,总线是组件之间的连接。在本章的上下文中,这些组件将是可以作为事件发布者或监听者的对象。
因此,事件总线是通过使用发布-订阅模型通过事件连接对象的一种方式。使用观察者模式和本地 C#事件等模式也可以实现类似的功能。然而,这些替代方案存在一些缺点。例如,在观察者模式的一个典型实现中,可能会发生紧密耦合,因为观察者(监听器)和主题(发布者)可能会相互依赖并意识到对方的存在。
但事件总线,至少在我们将在 Unity 中实现的方式中,抽象并简化了发布者和订阅者之间的关系,因此它们彼此完全不知情。另一个优点是它减少了将发布者或订阅者角色分配给单行代码的过程。因此,事件总线是一个值得学习和拥有的宝贵模式。您可以从以下图中看到,它确实在发布者和订阅者之间充当中间人:

图 6.1 – 事件总线模式的图示
如您从图中可以看出,有三个主要成分:
-
发布者:这些对象可以向订阅者发布事件总线声明的特定类型的事件。
-
事件总线:此对象负责协调发布者和订阅者之间的事件传输。
-
订阅者:这些对象通过事件总线将自己注册为特定事件的订阅者。
事件总线模式的优缺点
事件总线模式的优点如下:
-
解耦:使用事件系统的主要好处是它解耦了您的对象。对象可以通过事件进行通信,而不是直接引用彼此。
-
简单性:事件总线通过抽象发布或订阅事件机制,为客户端提供简单性。
事件总线模式的缺点如下:
-
性能:在任何一个事件系统的底层,都有一个管理对象间消息传递的低级机制。因此,使用事件系统可能会带来轻微的性能开销,但根据您的目标平台,这可能是微不足道的。
-
全局:在本章中,我们使用静态方法和属性实现事件总线,以便更容易从代码的任何地方访问它。使用全局可访问的变量和状态总会有风险,因为它们可能会使调试和单元测试变得更加困难。然而,这是一个非常具体的问题,而不是绝对的。
UnityEvents 实际上可以接受多达四个泛型类型参数。这使得将特定事件的数据传递给订阅者成为可能。请参阅以下 Unity API 文档部分以获取更多详细信息:docs.unity3d.com/2021.2/Documentation/ScriptReference/Events.UnityEvent.html.
何时使用事件总线
我过去曾使用事件总线来完成以下任务:
-
快速原型设计:我在快速原型设计新的游戏机制或功能时经常使用事件总线模式。使用这个模式,我可以轻松地让组件通过事件触发彼此的行为,同时保持它们解耦。这个模式允许我们通过一行代码添加和删除对象作为订阅者或发布者,这在你想快速轻松地原型设计某物时总是很有帮助。
-
生产代码:如果我没有找到合理的理由来实现更复杂的管理游戏事件的方法,我会在生产代码中使用事件总线。如果你不需要处理复杂的事件类型或结构,这个模式可以很好地完成任务。
我会避免使用本章中展示的类似的全局可访问事件总线来管理没有“全局范围”的事件。例如,如果我有一个在 HUD 中显示伤害警报的 UI 组件,当玩家与障碍物碰撞时,通过事件总线发布碰撞事件将是不高效的,因为这仅仅是自行车与赛道上的物体之间的局部交互。相反,我会使用类似观察者模式的东西,因为在那种情况下,我只需要一个 UI 组件来观察对象状态的一个特定变化,在这种情况下,是自行车。作为一个经验法则,每次你必须实现一个使用事件系统的系统时,都要通过已知模式的列表,选择一个适合你用例的模式,但不要总是退回到最简单的一个,在我看来,那就是事件总线。
管理全球赛车活动
我们正在开发的项目是一款赛车游戏,大多数比赛都分为几个阶段。以下是一些典型的赛车阶段简短列表:
-
倒计时:在这个阶段,自行车停在起跑线后,同时倒计时计时器正在运行。
-
赛事开始:一旦时钟达到零,绿灯信号被点亮,自行车开始在赛道上前进。
-
赛事结束:当玩家通过终点线的那一刻,赛事就结束了。
在赛事的开始和结束之间,可能会触发某些事件,这些事件可能会改变赛事的当前状态:
-
赛事暂停:玩家可以在比赛过程中暂停游戏。
-
赛事退出:玩家可以在任何时候退出赛事。
-
赛事停止:如果玩家参与致命碰撞,赛事可能会突然停止。
因此,我们希望广播一个通知,以表示赛事每个阶段的发生以及任何其他在赛事过程中发生的重要事件,这些事件将改变赛事的整体状态。这种方法将允许我们触发特定组件的特定行为,这些组件需要根据赛事的当前上下文以特定方式行为。
例如,以下是一些将根据赛事的特定状态进行更新的组件:
-
HUD:赛事状态指示器将根据赛事的上下文改变HUD(抬头显示)。
-
赛事计时器:赛事计时器仅在赛事开始时启动,并在玩家通过终点线时停止。
-
自行车控制器:一旦赛事开始,自行车控制器将释放自行车的刹车,这种机制防止玩家在绿灯亮起之前冲上赛道。
-
输入记录器:在赛事开始时,输入回放系统将开始记录玩家的输入,以便稍后回放。
所有这些组件在赛事的特定阶段都有特定的行为需要触发。因此,我们将使用事件总线来实现这些全局赛事事件。
实现赛事事件总线
我们将分两步实现赛事事件总线:
- 首先,我们需要暴露我们支持的具体赛事事件类型,我们将使用以下枚举来完成:
namespace Chapter.EventBus
{
public enum RaceEventType
{
COUNTDOWN, START, RESTART, PAUSE, STOP, FINISH, QUIT
}
}
重要的是要注意,前面的枚举值代表从开始到结束的赛事各个阶段的具体事件。因此,我们限制自己只处理具有全局作用域的事件。
- 下一个部分是模式的核心理念,实际的赛事事件总线类,我们将称之为
RaceEventBus,以使我们的类命名约定更具领域特定性:
using UnityEngine.Events;
using System.Collections.Generic;
namespace Chapter.EventBus
{
public class RaceEventBus
{
private static readonly
IDictionary<RaceEventType, UnityEvent>
Events = new Dictionary<RaceEventType, UnityEvent>();
public static void Subscribe
(RaceEventType eventType, UnityAction listener) {
UnityEvent thisEvent;
if (Events.TryGetValue(eventType, out thisEvent)) {
thisEvent.AddListener(listener);
}
else {
thisEvent = new UnityEvent();
thisEvent.AddListener(listener);
Events.Add(eventType, thisEvent);
}
}
public static void Unsubscribe
(RaceEventType type, UnityAction listener) {
UnityEvent thisEvent;
if (Events.TryGetValue(type, out thisEvent)) {
thisEvent.RemoveListener(listener);
}
}
public static void Publish(RaceEventType type) {
UnityEvent thisEvent;
if (Events.TryGetValue(type, out thisEvent)) {
thisEvent.Invoke();
}
}
}
}
我们类中的关键成分是Events字典。它充当一个账本,在其中我们维护事件类型和订阅者之间关系列表。通过将其保持为private和readonly,我们确保它不能被另一个对象直接覆盖。
因此,客户端必须调用Subscribe()公共静态方法来将自己添加为特定事件类型的订阅者。Subscribe()方法接受两个参数;第一个是赛事事件类型,第二个是回调函数。因为UnityAction是一个委托类型,它为我们提供了一种将方法作为参数传递的方式。
因此,当客户端对象调用Publish()方法时,特定赛车事件类型的所有订阅者的注册回调方法将同时被调用。
Unsubscribe()方法很容易理解,因为它允许对象将自己从特定事件类型的订阅者中移除。因此,当对象发布事件时,事件总线不会调用它们的回调方法。
如果这仍然看起来很抽象,我们将在下一节实现客户端类,并看到我们如何使用事件总线以正确的顺序在特定时刻触发对象的行为。
测试赛车事件总线
现在我们已经设置了模式的核心元素,我们可以编写一些代码来测试我们的RaceEventBus类。为了简洁起见,我已经从每个客户端类中删除了所有行为代码,以专注于模式的使用:
- 首先,我们将编写一个倒计时计时器,它订阅了
COUNTDOWN赛车事件类型。一旦发布COUNTDOWN事件,它将触发一个 3 秒的倒计时,直到比赛开始。当计数达到结束时,它将发布START事件,以表示比赛的开始:
using UnityEngine;
using System.Collections;
namespace Chapter.EventBus
{
public class CountdownTimer : MonoBehaviour
{
private float _currentTime;
private float duration = 3.0f;
void OnEnable() {
RaceEventBus.Subscribe(
RaceEventType.COUNTDOWN, StartTimer);
}
void OnDisable() {
RaceEventBus.Unsubscribe(
RaceEventType.COUNTDOWN, StartTimer);
}
private void StartTimer() {
StartCoroutine(Countdown());
}
private IEnumerator Countdown() {
_currentTime = duration;
while (_currentTime > 0) {
yield return new WaitForSeconds(1f);
_currentTime--;
}
RaceEventBus.Publish(RaceEventType.START);
}
void OnGUI() {
GUI.color = Color.blue;
GUI.Label(
new Rect(125, 0, 100, 20),
"COUNTDOWN: " + _currentTime);
}
}
}
上述类中最关键的代码行如下:
void OnEnable() {
RaceEventBus.Subscribe(
RaceEventType.COUNTDOWN, StartTimer);
}
void OnDisable() {
RaceEventBus.Unsubscribe(
RaceEventType.COUNTDOWN, StartTimer);
}
每当CountdownTimer对象被启用时,就会调用Subscribe()方法。而当它被禁用时,它会自己取消订阅。我们这样做是为了确保对象在活动时正在监听事件,或者在禁用或销毁时不会被RaceEventBus调用。
Subscribe()方法接受两个参数——事件类型和回调函数。这意味着每当发布COUNTDOWN事件时,RaceEventBus都会调用CountdownTimer的StartTimer()方法。
- 接下来,我们将实现
BikeController类的骨架来测试START和STOP事件:
using UnityEngine;
namespace Chapter.EventBus
{
public class BikeController : MonoBehaviour
{
private string _status;
void OnEnable() {
RaceEventBus.Subscribe(
RaceEventType.START, StartBike);
RaceEventBus.Subscribe(
RaceEventType.STOP, StopBike);
}
void OnDisable() {
RaceEventBus.Unsubscribe(
RaceEventType.START, StartBike);
RaceEventBus.Unsubscribe(
RaceEventType.STOP, StopBike);
}
private void StartBike() {
_status = "Started";
}
private void StopBike() {
_status = "Stopped";
}
void OnGUI() {
GUI.color = Color.green;
GUI.Label(
new Rect(10, 60, 200, 20),
"BIKE STATUS: " + _status);
}
}
}
- 最后,我们将编写一个
HUDController类。这个类除了显示一个按钮来停止比赛之外,没有做太多的事情:
using UnityEngine;
namespace Chapter.EventBus
{
public class HUDController : MonoBehaviour
{
private bool _isDisplayOn;
void OnEnable() {
RaceEventBus.Subscribe(
RaceEventType.START, DisplayHUD);
}
void OnDisable() {
RaceEventBus.Unsubscribe(
RaceEventType.START, DisplayHUD);
}
private void DisplayHUD() {
_isDisplayOn = true;
}
void OnGUI() {
if (_isDisplayOn)
{
if (GUILayout.Button("Stop Race"))
{
_isDisplayOn = false;
RaceEventBus.Publish(RaceEventType.STOP);
}
}
}
}
}
- 为了测试事件序列,我们需要将以下客户端类附加到一个空的 Unity 场景中的 GameObject 上。有了这个,我们就可以触发倒计时计时器:
using UnityEngine;
namespace Chapter.EventBus
{
public class ClientEventBus : MonoBehaviour
{
private bool _isButtonEnabled;
void Start()
{
gameObject.AddComponent<HUDController>();
gameObject.AddComponent<CountdownTimer>();
gameObject.AddComponent<BikeController>();
_isButtonEnabled = true;
}
void OnEnable()
{
RaceEventBus.Subscribe(
RaceEventType.STOP, Restart);
}
void OnDisable()
{
RaceEventBus.Unsubscribe(
RaceEventType.STOP, Restart);
}
private void Restart()
{
_isButtonEnabled = true;
}
void OnGUI()
{
if (_isButtonEnabled)
{
if (GUILayout.Button("Start Countdown"))
{
_isButtonEnabled = false;
RaceEventBus.Publish(RaceEventType.COUNTDOWN);
}
}
}
}
}
前面的示例可能看起来过于简化,但其目的是展示我们如何通过事件触发特定顺序中单个对象的行为,同时保持它们解耦。没有任何对象之间直接通信。
它们唯一的共同参考点是RaceEventBus。因此,我们可以轻松地添加更多的订阅者和发布者;例如,我们可以让一个TrackController对象监听RESTART事件,以知道何时重置赛道。因此,我们现在可以使用事件来按顺序触发核心组件的行为,每个事件都代表比赛的特定阶段。
动作是一个委托,它指向一个接受一个或多个参数但不返回任何值的方法。例如,你应该在委托指向返回 void 的方法时使用 Action。UnityAction 的行为类似于原生 C#中的 Actions,但 UnityAction 是为了与 UnityEvents 一起使用而设计的。
回顾事件总线实现
通过使用事件总线,我们可以在保持核心组件解耦的同时触发行为。对我们来说,添加或删除作为订阅者或发布者的对象非常简单。我们还定义了一个特定的全局事件列表,代表比赛的每个阶段。因此,我们现在可以开始对核心组件的行为进行排序和触发,从比赛开始到结束,以及任何中间阶段。
在下一节中,我们将回顾一些替代事件总线的方法,每个解决方案都提供了一种可能根据上下文更好的不同方法。
回顾一些替代解决方案
事件系统和模式是一个广泛的话题,我们无法在这本书中深入探讨这个话题。因此,我们准备了一份简短的列表,供你在实现事件系统或机制时考虑,但请记住,还有更多内容,我们鼓励你作为读者在本书有限的范围内继续探索这个话题:
-
观察者:这是一个老式但实用的模式,其中一个对象(主题)维护一个对象(观察者)列表,并在内部状态发生变化时通知它们。当你需要在一组实体之间建立一对一的关系时,这是一个值得考虑的模式。
-
事件队列:这个模式允许我们将发布者生成的事件存储在队列中,并在方便的时候将它们转发给订阅者。这种方法解耦了发布者和订阅者之间的时间关系。
-
ScriptableObjects:在 Unity 中,你可以使用 ScriptableObjects 创建一个事件系统。这种方法的关键优势是它使得创建新的自定义游戏事件变得更加容易。如果你需要构建一个可扩展和可定制的的事件系统,这可能是一个不错的选择。
如果你自己在想为什么这本书没有展示更多高级的事件系统,包括使用 ScriptableObjects 实现的事件系统,答案是这本书的核心目的是向读者介绍设计模式,而不是用复杂性让他们感到不知所措。我们提供了一个核心概念的初步介绍,但鼓励读者在进步的过程中寻找更高级的材料。
概述
在本章中,我们回顾了事件总线,这是一种简化在 Unity 中发布和订阅事件过程的简单模式。这是一个在快速原型设计或当你有一个明确的全局事件列表需要管理时非常有用的工具。然而,它有其使用的局限性,因此在决定使用全局可访问的事件总线之前探索其他选项总是明智的。
在下一章中,我们将实现一个系统,使我们能够回放玩家输入。许多赛车游戏都有回放和倒退功能,而通过命令模式,我们将尝试从头开始构建一个这样的系统。
使用命令模式实现回放系统
在本章中,我们将使用一个经典的设计模式——命令,来实现我们赛车游戏的回放系统。其机制将记录玩家的控制器输入和相应的时间戳。这种方法将允许我们以正确的顺序和正确的时间播放记录的输入。
在赛车游戏中,回放功能通常是必需的。使用命令模式,我们将以可扩展和模块化的方式实现这个系统。这个模式提出了一种机制,允许封装执行“动作”或触发状态改变所需的信息。它还解耦了请求“动作”的对象与执行它的对象。这种封装和解耦使我们能够排队动作请求,以便我们可以在稍后执行它们。
所有这些都可能听起来非常抽象,但一旦我们实现了系统的核心组件,一切都将变得清晰。
本章将涵盖以下主题:
-
理解命令模式
-
设计回放系统
-
实现回放系统
-
审查替代解决方案
第十章:技术要求
下一章是实践性的,因此你需要对 Unity 和 C#有基本的了解。
本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter07。
查看以下视频以查看代码的实际运行效果:bit.ly/3wAWYpb.
理解命令模式
想象一个平台游戏,当你按下空格键时,你可以跳过障碍物。在这种情况下,每次你按下这个输入键,你都是在要求屏幕上的角色改变状态并执行跳跃动作。在代码中,我们可以实现一个简单的InputHandler,它会监听玩家从空格键的输入,当玩家按下它时,我们会调用CharacterController来触发跳跃动作。
以下非常简化的伪代码总结了我们的想法:
using UnityEngine;
using System.Collections;
public class InputHandler : MonoBehaviour
{
void Update()
{
if (Input.GetKeyDown("space"))
{
CharacterController.Jump();
}
}
}
如我们所见,这种方法可以完成任务,但如果我们要在以后的时间记录、撤销或回放玩家的输入,可能会变得复杂。然而,命令模式允许我们将调用操作的对象与知道如何执行它的对象解耦。换句话说,我们的InputHandler不需要知道当玩家按下空格键时需要采取什么具体动作。它只需要确保执行正确的命令,并让命令模式机制在幕后施展其魔法。
以下伪代码显示了当我们使用命令模式时,我们实现InputHandler的方式的差异:
using UnityEngine;
using System.Collections;
public class InputHandler : MonoBehaviour
{
[SerializedField]
private Controller _characterController;
private Command _spaceButton;
void Start()
{
_spaceButton = new JumpCommand();
}
void Update()
{
if (Input.GetKeyDown("space"))
_spaceButton.Execute(_characterController);
}
}
如我们所见,当玩家按下空格键时,我们并不是直接调用 CharacterController。我们实际上是将执行跳跃动作所需的所有信息封装到一个对象中,我们可以将其放入队列并在稍后重新调用。
让我们回顾以下图表,它展示了命令模式的实现:

图 7.1 – 命令模式的 UML 图
通过查看图表来学习命令模式不是正确的方法,但它确实帮助我们隔离了参与此模式的根本类:
-
Invoker是一个知道如何执行命令并且可以记录已执行命令的对象。 -
Receiver是一种可以接收命令并执行它们的对象类型。 -
CommandBase是一个抽象类,一个具体的ConcreteCommand类必须继承它,并且它暴露了一个Execute()方法,Invoker可以调用它来执行特定的命令。
模式中的每个参与者都有独特的责任和角色。一个良好的命令模式实现应该允许我们将动作请求封装为一个对象,该对象可以被排队并在稍后立即或执行。
命令模式是行为模式家族的一部分;它的近亲是备忘录、观察者和访问者。这些类型的模式通常关注责任的分配以及对象之间如何相互通信。
命令模式的优缺点
这些是命令模式的一些优点:
-
解耦:该模式允许将调用操作的对象与知道如何执行它的对象解耦。这种分离层允许添加一个中间件,它将能够记录和排队操作。
-
顺序:命令模式促进了排队用户输入的过程,这允许实现撤销/重做功能、宏和命令队列。
这是命令模式的一个潜在缺点:
- 复杂性:由于每个命令本身就是一个类,因此实现此模式需要许多类。并且需要很好地理解该模式才能维护使用它构建的代码。在大多数情况下,这不是问题,但如果你没有特定的目标而使用命令模式,它可能会成为代码库中不必要的复杂性和冗余层。
优缺点通常是情境性的;本书中提出的是一般的,而不是绝对的。因此,在选择模式时,分析其在自己的项目中的优缺点是至关重要的,不要基于一般陈述考虑或拒绝某个模式。
何时使用命令模式
这里是一个命令模式可能的用途简短列表:
-
撤销:实现大多数文本和图像编辑器中找到的撤销/重做系统。
-
宏:一个宏录制系统,玩家可以使用它来录制一系列攻击或防御组合。然后,将它们分配到输入键上以自动执行。
-
自动化:通过记录一组命令,使机器人能够自动和顺序地执行这些命令,从而自动化流程或行为。
总结来说,这是一个与存储、定时和用户输入排序相关的功能的好模式。如果你能非常富有创意地使用它,你甚至可以创建一些引人入胜的游戏系统和机制。
如果你不过分担心保持与原始学术描述的一致性,设计模式很有趣。如果你在实验中不失去模式的原意,你应该保留其核心优势。
设计重放系统
在描述我们将在本章中实现的重放系统设计之前,我们必须声明一些可能影响我们实现方式的游戏规格。
需要记住的规格如下:
-
确定性:我们游戏中的所有内容都是确定性的,这意味着我们没有具有随机行为的实体,这使得我们的重放系统更容易实现,因为我们不必担心记录场景中移动的实体(如敌机)的位置或状态。我们知道它们在重放序列中将以相同的方式移动和表现。
-
物理:我们尽量减少使用 Unity 引擎的物理特性,因为我们的实体运动不由任何物理属性或交互决定。因此,我们不必担心物体碰撞时出现意外行为。
-
数字:我们所有的输入都是数字的,所以我们不会费心去捕捉或处理来自摇杆或触发按钮的细粒度模拟输入数据。
-
精度:我们对重放输入的时间精度缺乏容忍。因此,我们不会期望输入在记录时精确地重现在同一时间范围内。这种容忍水平将根据与所需重放功能精度相关的几个因素而变化。
考虑到所有这些规格,我们将实现的重放系统将只记录玩家的输入,而不是自行车的当前位置。因为没有中间位置,自行车可能处于,这取决于玩家的输入,它将位于一条轨道或另一条轨道上。另外,还有一个需要注意的重要细节是,自行车不会向前移动。是轨道向玩家的位置移动,以产生移动和速度的错觉。
理论上,如果我们从比赛的开始和结束记录玩家的控制器输入,那么我们可以在新比赛的开始时重放记录的输入,从而模拟玩家的游戏过程。我们有一个图解说明了重放系统背后的机制:

图 7.2 – 重放系统的示意图
如图中所示,InputRecorder 记录并序列化输入,以便 ReplaySystem 可以稍后回放它们。在回放序列中,ReplaySystem 的行为类似于机器人,因为它控制自行车并通过回放玩家的输入来自动操纵它。这是一种简单的自动化形式,给人一种我们在观看回放视频的错觉。
在下一节中,我们将实现我们刚刚审查的系统的简化版本,并使用命令模式作为其基础。
实现回放系统
为了简化起见,本节包含伪代码。如果您希望在真实游戏项目的上下文中查看完整的实现,请打开 GitHub 项目中的 FPP 文件夹。链接可以在 技术要求 部分找到。
在本节中,我们将使用命令模式作为基础构建一个简单的输入回放系统原型。
实现回放系统
实现将分为两部分。在第一部分,我们将编写命令模式的核心理念,然后我们将集成测试回放系统所必需的元素:
- 首先,我们正在实现一个名为
Command的基抽象类,它有一个名为Execute()的单一方法:
public abstract class Command
{
public abstract void Execute();
}
- 现在我们将编写三个具体的命令类,它们将派生自
Command基类,然后我们将实现Execute()方法。每个类封装了一个要执行的操作。
第一个操作是在 BikeController 上打开涡轮增压:
namespace Chapter.Command
{
public class ToggleTurbo : Command
{
private BikeController _controller;
public ToggleTurbo(BikeController controller)
{
_controller = controller;
}
public override void Execute()
{
_controller.ToggleTurbo();
}
}
}
- 以下两个命令是
TurnLeft和TurnRight命令。每个命令代表不同的动作,并映射到特定的输入键,正如我们将在实现InputHandler时看到的那样:
namespace Chapter.Command
{
public class TurnLeft : Command
{
private BikeController _controller;
public TurnLeft(BikeController controller)
{
_controller = controller;
}
public override void Execute()
{
_controller.Turn(BikeController.Direction.Left);
}
}
}
- 以下命令表示右转动作,正如其名称所暗示的,这将自行车转向右边:
namespace Chapter.Command
{
public class TurnRight : Command
{
private BikeController _controller;
public TurnRight(BikeController controller)
{
_controller = controller;
}
public override void Execute()
{
_controller.Turn(BikeController.Direction.Right);
}
}
}
现在我们已经将每个命令封装到单独的类中,是时候编写使我们的回放系统工作的关键成分了——Invoker。
Invoker 是一个细心的簿记员;它跟踪已执行的命令,并在账簿中记录。我们在代码中以 SortedList 的形式表示这个账簿,这是一个具有键/值结构的本地 C# 排序列表。这个列表将跟踪特定命令何时被执行。
- 因为这个类非常长,我们将分两部分来审查。以下是一部分:
using UnityEngine;
using System.Linq;
using System.Collections.Generic;
namespace Chapter.Command
{
class Invoker : MonoBehaviour
{
private bool _isRecording;
private bool _isReplaying;
private float _replayTime;
private float _recordingTime;
private SortedList<float, Command> _recordedCommands =
new SortedList<float, Command>();
public void ExecuteCommand(Command command)
{
command.Execute();
if (_isRecording)
_recordedCommands.Add(_recordingTime, command);
Debug.Log("Recorded Time: " + _recordingTime);
Debug.Log("Recorded Command: " + command);
}
public void Record()
{
_recordingTime = 0.0f;
_isRecording = true;
}
在 Invoker 类的这一部分,每次 Invoker 执行一个新的命令时,我们都会将其添加到 _recordedCommands 排序列表中。然而,我们只在开始录制时这样做,因为我们希望在特定的时刻记录玩家输入,例如在比赛开始时。
- 对于
Invoker类的下一部分,我们将实现回放行为:
public void Replay()
{
_replayTime = 0.0f;
_isReplaying = true;
if (_recordedCommands.Count <= 0)
Debug.LogError("No commands to replay!");
_recordedCommands.Reverse();
}
void FixedUpdate()
{
if (_isRecording)
_recordingTime += Time.fixedDeltaTime;
if (_isReplaying)
{
_replayTime += Time.deltaTime;
if (_recordedCommands.Any())
{
if (Mathf.Approximately(
_replayTime, _recordedCommands.Keys[0])) {
Debug.Log("Replay Time: " + _replayTime);
Debug.Log("Replay Command: " +
_recordedCommands.Values[0]);
_recordedCommands.Values[0].Execute();
_recordedCommands.RemoveAt(0);
}
}
else
{
_isReplaying = false;
}
}
}
}
}
如您可能已经注意到的,我们正在使用FixedUpdate()来记录和回放命令。这可能会显得有些奇怪,因为我们通常使用Update()来监听玩家输入。然而,FixedUpdate()具有在固定时间步长中运行的优点,这对于时间依赖但帧率无关的任务非常有帮助。
因此,我们知道默认的引擎时间步长是 0.02 秒,并且我们的时间戳将以类似的增量增加,因为我们使用Time.fixedDeltaTime来记录执行命令的时间。
然而,这也意味着我们在记录阶段会失去精度,因为我们的时间戳受限于 Unity 的时间步长设置。在这个例子中,这种精度损失是可以容忍的。然而,如果游戏玩法和回放序列之间存在重大不一致,这可能会成为一个问题。
在这种情况下,我们可能需要考虑一个包括Update()、Time.deltaTime以及允许我们设置比较记录和回放时间精度程度的值的解决方案。然而,这超出了本章的范围。
我们应该注意,我们正在赋予Invoker记账和回放的责任。有人可能会认为,通过给Invoker赋予过多的责任,我们已经违反了单一职责原则。在这个上下文中,这不是一个问题;这只是一个用于教育目的的代码示例。尽管如此,将记录、保存和回放命令的责任封装在单独的类中仍然是一个明智的选择。
测试回放系统
现在我们已经建立了命令模式和回放系统的核心成分,是时候测试它是否正常工作了:
- 我们将要实现的第一类是
InputHandler。其主要职责是监听玩家的输入并调用适当的命令。然而,由于其长度,我们将分两部分来审查它:
using UnityEngine;
namespace Chapter.Command
{
public class InputHandler : MonoBehaviour
{
private Invoker _invoker;
private bool _isReplaying;
private bool _isRecording;
private BikeController _bikeController;
private Command _buttonA, _buttonD, _buttonW;
void Start()
{
_invoker = gameObject.AddComponent<Invoker>();
_bikeController = FindObjectOfType<BikeController>();
_buttonA = new TurnLeft(_bikeController);
_buttonD = new TurnRight(_bikeController);
_buttonW = new ToggleTurbo(_bikeController);
}
void Update()
{
if (!_isReplaying && _isRecording)
{
if (Input.GetKeyUp(KeyCode.A))
_invoker.ExecuteCommand(_buttonA);
if (Input.GetKeyUp(KeyCode.D))
_invoker.ExecuteCommand(_buttonD);
if (Input.GetKeyUp(KeyCode.W))
_invoker.ExecuteCommand(_buttonW);
}
}
在这个类的这个部分,我们初始化我们的命令并将它们映射到特定的输入。请注意,我们在命令的构造函数中传递了一个BikeController的实例。InputHandler只知道BikeController的存在,但不需要了解其功能。根据所需操作,调用自行车控制器的适当公共方法是个别命令类的责任。在Update()循环中,我们监听特定的按键输入,并调用Invoker执行与特定输入关联的命令。
这段代码使得记录玩家输入成为可能。我们并没有直接调用BikeController,也没有执行命令。相反,我们允许Invoker充当一个中介,并完成所有工作。这种方法允许它在后台记录玩家输入,以备后用。
- 对于
InputHandler类的最后一部分,我们添加了一些 GUI 调试按钮,这将帮助我们测试回放系统。此段代码仅用于调试和测试目的:
void OnGUI()
{
if (GUILayout.Button("Start Recording"))
{
_bikeController.ResetPosition();
_isReplaying = false;
_isRecording = true;
_invoker.Record();
}
if (GUILayout.Button("Stop Recording"))
{
_bikeController.ResetPosition();
_isRecording = false;
}
if (!_isRecording)
{
if (GUILayout.Button("Start Replay"))
{
_bikeController.ResetPosition();
_isRecording = false;
_isReplaying = true;
_invoker.Replay();
}
}
}
}
}
- 对于我们的最终类,我们将实现
BikeController类的骨架版本以供测试。在命令模式的上下文中,它充当接收者:
using UnityEngine;
public class BikeController : MonoBehaviour
{
public enum Direction
{
Left = -1,
Right = 1
}
private bool _isTurboOn;
private float _distance = 1.0f;
public void ToggleTurbo()
{
_isTurboOn = !_isTurboOn;
Debug.Log("Turbo Active: " + _isTurboOn.ToString());
}
public void Turn(Direction direction)
{
if (direction == Direction.Left)
transform.Translate(Vector3.left * _distance);
if (direction == Direction.Right)
transform.Translate(Vector3.right * _distance);
}
public void ResetPosition()
{
transform.position = new Vector3(0.0f, 0.0f, 0.0f);
}
}
类的总体目的和结构是显而易见的。ToggleTurbo() 和 Turn() 是被命令类调用的公共方法。然而,ResetPosition() 仅用于调试和测试目的,可以忽略。
要在您的 Unity 实例中测试此代码,您需要完成以下步骤:
-
开始一个新的空 Unity 场景,其中至少包含一个灯光和一个摄像机。
-
在新场景中添加一个 3D GameObject,例如一个立方体,并确保它可以从场景的主摄像机中看到。
-
将
InputHandler和BikeController类附加到新的 GameObject 上。 -
在运行时,如果您已将我们刚刚审查的所有类复制到您的项目中,您应该在屏幕上看到以下内容:

图 7.3 – Unity 中代码执行截图
当您开始录制时,您将能够沿水平轴移动立方体。每个输入都可以按记录的相同顺序和时序进行回放。
实现回顾
我们通过使用命令模式作为基础,完成了构建快速输入回放系统的过程。当然,本章中的代码示例并不是生产就绪的,而且非常有限。尽管如此,本章的目标是学习如何使用命令模式与 Unity 结合,而不是设计一个完整的回放系统。
在 GitHub 项目的 FPP 文件夹中的游戏原型项目中,我们确实实现了一个包含序列化和倒退功能的更高级的回放系统示例。我们建议您查看它,当然,您可以根据自己的意愿进行修改。
在下一节中,我们将回顾一些替代解决方案和方法,我们本可以使用它们来构建我们的回放系统。
替代解决方案回顾
即使命令模式非常适合我们的用例,我们还可以考虑一些替代模式和解决方案:
-
备忘录模式(Memento):备忘录模式提供了将对象回滚到先前状态的能力。这并不是我们为回放系统选择的第一方案,因为我们更关注记录输入并将它们排队以供稍后回放,这与命令模式的意图非常兼容。然而,如果我们实现了一个具有回滚到先前状态功能的系统,备忘录模式可能将是我们的首选。
-
队列/栈:队列和栈不是模式,而是数据结构,但我们可以简单地编码所有输入,并将它们直接存储在我们的
InputHandler类中的队列中。这将比使用命令模式更直接、更简洁。在实现具有或没有传统设计模式的系统之间的选择非常具体。如果一个系统足够简单,那么设计模式可能带来的额外冗长和复杂性可能会超过使用它的潜在好处。
摘要
在本章中,我们通过使用命令模式实现了一个简单但功能性的回放系统。我们编写本章的目标不是展示如何构建一个健壮的回放系统,而是展示如何使用命令模式在 Unity 中创建可能对游戏项目有用的东西。
我希望你会研究实现命令模式的替代方法,这些方法可能比本书中展示的更好,因为,就像编程中的大多数事情一样,没有一种唯一的方法来做事情。然而,至少这一章提供了使用 Unity 中的命令模式的第一种方法。
在本书的下一部分,我们将开始使用对象池来优化我们的代码。一款优秀的赛车游戏的一个重要方面是保持一致的性能和帧率。每一刻都必须运行顺畅,否则可能会让我们的游戏感觉缓慢和笨拙。
使用对象池模式进行优化
在大多数视频游戏中,屏幕上发生了很多事情。子弹在四处飞行,敌人正在地图周围生成,粒子在玩家周围弹出,这些各种对象在瞬间被加载和渲染到屏幕上。因此,为了避免在保持一致帧率的同时对中央处理器(CPU)造成压力,为我们的频繁生成的实体预留一些内存是一个好习惯。所以,我们不是从内存中释放最近被摧毁的敌人,而是将它们添加到对象池中以便以后重用。使用这种技术,我们避免了加载实体新实例的初始初始化成本。此外,因为我们没有摧毁可重用实体,所以垃圾收集器(GC)不会浪费周期清理一组定期重新初始化的对象。
这就是我们将在本章中要做的,幸运的是,自从 Unity 版本 2021 以来,对象池已经原生集成到应用程序编程接口(API)中。因此,我们不需要像之前章节那样手动实现该模式;相反,我们将专注于学习如何使用它,并让引擎完成所有工作。
本章将涵盖以下主题:
-
理解对象池模式
-
实现对象池模式
-
检查替代解决方案
垃圾收集器(GC)作为一个自动的内存管理器,是大多数现代面向对象语言(如 C#)的一个基本组件。为了继续本章的学习,不需要理解它是如何工作的,但如果您对此感兴趣,可以在此处获取更多相关信息:docs.microsoft.com/en-us/dotnet/standard/garbage-collection/。
第十一章:技术要求
本章是实践性的,因此您需要对 Unity 和 C#有一个基本的了解。
本章的代码文件可以在 GitHub 上找到,地址为github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter08。
查看以下视频以查看代码的实际运行情况:
需要注意的是,下一节中的代码示例在低于 2021.1 版本的 Unity 中无法工作,因为我们使用了最近添加的 API 功能。
理解对象池模式
该模式的核心概念很简单——以容器形式存在的池在内存中保存了一组初始化后的对象。客户端可以请求对象池为其提供特定类型的对象实例;如果可用,它将从池中移除并分配给客户端。如果在某个时间点池中实例不足,新的实例将被动态创建。
离开池的对象将在不再被客户端使用后尝试返回池中。如果对象池没有更多空间,它将销毁尝试返回的对象实例。因此,池会不断被填充,只能暂时排空,但永远不会溢出。因此,其内存使用是稳定的。
以下图示了客户端与对象池之间的交互:

图 8.1 – 对象池模式的统一建模语言(UML)图
在图中,我们可以看到对象池通过向客户端提供特定类型对象实例的池的访问来服务于客户端——例如,客户端可以是请求特定敌人类型实例的生成器。
对象池模式的好处和缺点
这些是对象池模式的一些好处:
-
可预测的内存使用:使用对象池,我们可以以可预测的方式分配一些内存来保存特定类型对象的一定数量的实例。
-
性能提升:由于对象已经在内存中初始化,因此避免了初始化新对象时的加载成本。
这些是对象池模式的一些潜在缺点:
-
在已管理的内存上增加层级:有些人批评对象池模式在大多数情况下是不必要的,因为现代托管编程语言如 C#已经最优地控制了内存分配。然而,这个说法在某些情况下可能是正确的,但在其他情况下可能是错误的。
-
不可预测的对象状态:对象池模式的一个潜在陷阱是,如果处理不当,对象可能会以当前状态而不是初始状态返回池中。当池中的实体可受损或可破坏时,这种情况可能成为问题。例如,如果你有一个被玩家击败的敌人实体,如果你在恢复其健康之前将其返回池中,当对象池将其拉回给客户端时,它将以已损坏的状态重新生成到场景中。
何时使用对象池模式
为了更好地理解何时使用对象池模式,让我们回顾一下何时不应使用它。例如,如果你在地图上需要一次性生成实体,比如最终 Boss,将其放入对象池就是浪费内存,这些内存本可以用于更有用的地方。
此外,我们还应该记住,对象池不是一个缓存。它有一个类似的目的——对象的复用。核心区别在于,对象池有一个机制,在实体使用后自动将其返回到池中,并且如果实现得当,对象池会根据池的可用大小来处理对象的创建和删除。
但假设我们在游戏过程中经常生成和销毁子弹、粒子以及敌人角色等实体。在这种情况下,对象池可以减轻我们对 CPU 施加的压力,通过减少重复的生命周期调用,例如创建和销毁,因此 CPU 将能够为更关键的任务保留处理能力。
在下一节中,我们将把刚刚学到的概念转化为代码。
实现对象池模式
在开始本节之前,阅读以下链接下UnityEngine.Pool命名空间中IObjectPool<T0>类的官方 API 文档是个好主意:
docs.unity3d.com/2021.1/Documentation/ScriptReference/Pool.ObjectPool_1.html
在实现以下代码示例时,我们将尽量避免陷入 API 规范中。相反,我们将专注于与对象池核心概念直接相关的关键元素。此外,原生对象池是 Unity API 的一个相对较新的功能,因此它可能会受到更改和更新的影响。因此,短期内关注文档是明智的。
实现对象池模式的步骤
本节的代码示例应该是相对直接的,我们应该能够分两步完成它,如下所示:
- 让我们从实现我们的无人机开始,因为这是我们将要池化的实体。由于这个类非常长,我们将将其分为两个部分。您可以看到第一部分如下:
using UnityEngine;
using UnityEngine.Pool;
using System.Collections;
namespace Chapter.ObjectPool
{
public class Drone : MonoBehaviour
{
public IObjectPool<Drone> Pool { get; set; }
public float _currentHealth;
[SerializeField]
private float maxHealth = 100.0f;
[SerializeField]
private float timeToSelfDestruct = 3.0f;
void Start()
{
_currentHealth = maxHealth;
}
void OnEnable()
{
AttackPlayer();
StartCoroutine(SelfDestruct());
}
void OnDisable()
{
ResetDrone();
}
在这个类段中,我们调用ResetDrone()方法是在OnDisable()事件函数中。我们这样做是因为我们想在将无人机返回池之前将其重置到初始状态。
并且正如我们将在实现对象池模式时看到的那样,当一个 GameObject 返回池中时,它会被禁用,包括所有其子组件。因此,如果我们有任何需要执行的重新初始化代码,我们可以在OnDisable()调用中执行。
在本章的上下文中,我们保持简单;我们只恢复无人机的健康。但在高级实现中,我们可能需要重置视觉标记,例如移除损坏的标记。
- 在我们
Drone类的最后一段中,我们将实现核心行为,如下所示:
IEnumerator SelfDestruct()
{
yield return new WaitForSeconds(timeToSelfDestruct);
TakeDamage(maxHealth);
}
private void ReturnToPool()
{
Pool.Release(this);
}
private void ResetDrone()
{
_currentHealth = maxHealth;
}
public void AttackPlayer()
{
Debug.Log("Attack player!");
}
public void TakeDamage(float amount)
{
_currentHealth -= amount;
if (_currentHealth <= 0.0f)
ReturnToPool();
}
}
}
我们的无人机有两个关键行为,如下所述:
-
自我销毁: 我们的无人机的寿命很短;当它被启用时,会调用
SelfDestruct()协程。几秒钟后,无人机通过耗尽其生命值并通过调用ReturnToPool()方法将自己返回池中而自我销毁。 -
攻击: 方法内的逻辑尚未实现,为了简洁起见。但想象一下,一旦无人机被生成,它会寻找并攻击玩家。
- 接下来是我们的
ObjectPool类,它负责管理无人机实例的池。由于它是一个较长的类,我们将分两部分来审查它,第一部分在此处提供:
using UnityEngine;
using UnityEngine.Pool;
namespace Chapter.ObjectPool
{
public class DroneObjectPool : MonoBehaviour
{
public int maxPoolSize = 10;
public int stackDefaultCapacity = 10;
public IObjectPool<Drone> Pool
{
get
{
if (_pool == null)
_pool =
new ObjectPool<Drone>(
CreatedPooledItem,
OnTakeFromPool,
OnReturnedToPool,
OnDestroyPoolObject,
true,
stackDefaultCapacity,
maxPoolSize);
return _pool;
}
}
private IObjectPool<Drone> _pool;
在脚本的第一部分,我们设置了一个名为 maxPoolSize 的关键变量;正如其名称所暗示的,这设置了我们将保留在池中的无人机实例的最大数量。stackDefaultCapacity 变量设置了默认的堆栈容量;这是我们用来存储无人机实例的堆栈数据结构的一个属性。我们可以暂时忽略它,因为它对我们实现不是关键的。
在以下代码片段中,我们正在初始化对象池,这是我们类中最关键的部分:
public IObjectPool<Drone> Pool
{
get
{
if (_pool == null)
_pool =
new ObjectPool<Drone>(
CreatedPooledItem,
OnTakeFromPool,
OnReturnedToPool,
OnDestroyPoolObject,
true,
stackDefaultCapacity,
maxPoolSize);
return _pool;
}
}
重要的是要注意,我们在 ObjectPool<T> 类的构造函数中传递了回调方法,并且在这些回调中我们将实现驱动我们的对象池的逻辑。
- 在
DroneObjectPool类的最后一段,我们将实现我们在ObjectPool<T>构造函数中声明的回调,如下所示:
private Drone CreatedPooledItem()
{
var go =
GameObject.CreatePrimitive(PrimitiveType.Cube);
Drone drone = go.AddComponent<Drone>();
go.name = "Drone";
drone.Pool = Pool;
return drone;
}
private void OnReturnedToPool(Drone drone)
{
drone.gameObject.SetActive(false);
}
private void OnTakeFromPool(Drone drone)
{
drone.gameObject.SetActive(true);
}
private void OnDestroyPoolObject(Drone drone)
{
Destroy(drone.gameObject);
}
public void Spawn()
{
var amount = Random.Range(1, 10);
for (int i = 0; i < amount; ++i)
{
var drone = Pool.Get();
drone.transform.position =
Random.insideUnitSphere * 10;
}
}
}
}
下面是对 ObjectPool 类将在特定时间调用的每个回调的简要说明:
-
CreatedPooledItem(): 在这个回调中,我们正在初始化我们的无人机实例。在本章的上下文中,为了简单起见,我们从头开始创建一个 GameObject,但在更实际的情况下,我们可能会只是加载一个预制体。 -
OnReturnedToPool(): 方法的名称暗示了其用途。注意我们并没有销毁 GameObject;我们只是将其停用以从场景中移除。 -
OnTakeFromPool(): 当客户端请求无人机实例时,会调用此方法。实际上并没有返回实例——GameObject 被启用。 -
OnDestroyPoolObject(): 这是一个重要的方法,需要理解。当池中没有更多空间时,它会调用。在这种情况下,返回的实例会被销毁以释放内存。
我们的 DroneObjectPool 类承担了额外的责任,并充当生成器,正如我们在 Spawn() 方法中看到的。当请求时,它会从池中获取一个 drone 实例,并在场景中的特定范围内随机位置生成它。
测试对象池实现
要在您自己的 Unity 实例中测试我们的对象池实现,您需要执行以下步骤:
-
创建一个新的空 Unity 场景。
-
将我们刚刚审查的所有脚本复制并保存在您的项目中。
-
在场景中添加一个空的 GameObject。
-
将以下客户端脚本附加到您的空 GameObject 上:
using UnityEngine;
namespace Chapter.ObjectPool
{
public class ClientObjectPool : MonoBehaviour
{
private DroneObjectPool _pool;
void Start()
{
_pool = gameObject.AddComponent<DroneObjectPool>();
}
void OnGUI()
{
if (GUILayout.Button("Spawn Drones"))
_pool.Spawn();
}
}
}
一旦你启动场景,你应该在左上角看到一个名为Spawn Drones的图形用户界面(GUI)按钮,如下截图所示:

图 8.2 – 代码示例的实际截图
通过按下 Spawn Drones 按钮,你现在可以在场景中的随机位置生成无人机。如果你想看到对象池机制的实际应用,请关注场景层次结构——你将能够看到无人机实体在进入和退出池时被启用和禁用。
检查对象池实现
通过使用对象池模式,我们自动化了创建、销毁和池化无人机实例的过程。现在我们可以预留一定量的内存来生成无人机波次,同时避免给 CPU 带来负担。通过实现这个模式,我们在不牺牲可读性或增加复杂性的情况下,为我们的代码添加了优化和可扩展性。
在下一节中,我们将回顾一些可以考虑的替代方案;在决定特定的模式之前考虑其他选项总是一个好习惯。
检查替代方案
对象池模式的近亲是原型模式;这两个都被认为是创建型模式。使用原型模式,你可以通过使用克隆机制来避免创建新对象固有的成本。因此,而不是初始化新对象,你可以从称为原型的引用对象中克隆它们。但在本章所展示的使用案例的上下文中,对象池提供了更好的优化优势。
创建型模式关注对象创建的机制。工厂、构建、单例、对象池和原型都是创建型设计模式。
摘要
我们刚刚将对象池模式添加到我们的工具箱中——这是 Unity 开发者最有价值的模式之一。正如我们在代码示例中看到的,我们可以轻松地回收常用对象的实例。当处理需要快速且重复生成的大量实体时,这个模式可以帮助我们避免 CPU 峰值和延迟。这些好处只能帮助我们使游戏变得更好,因为玩家确实喜欢运行流畅的游戏。
在下一章中,我们将使用观察者模式将组件彼此解耦。
使用观察者模式解耦组件
在 Unity 开发中,一个常见的挑战是找到优雅的方法来解耦组件。在引擎中编写代码时,这是一个重大的障碍,因为它为我们提供了通过 API 和直接在检查器中引用组件的多种方式。但这种灵活性可能会带来代价,并且可能会使你的代码变得脆弱,因为在某些情况下,一个缺失的引用就足以破坏你的游戏。
因此,在本章中,我们将使用观察者模式来设置与核心组件的关系。这些关系将通过分配对象作为主题或观察者的角色来映射。这种方法不会完全消除我们组件之间的耦合,但会将其放松并逻辑化组织。它还将建立一个具有一对一结构的处理系统,这正是本章用例中需要实现的内容。
如果你正在寻找一种使用事件在多对多关系中解耦对象的方法,请查看第六章,使用事件总线管理游戏事件。
本章将涵盖以下主题:
-
理解观察者模式
-
使用观察者模式解耦核心组件
-
实现观察者模式
-
审查替代方案
第十二章:技术要求
本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter09。
查看以下视频以查看代码的实际操作:bit.ly/3xDlBDa。
理解观察者模式
观察者模式的核心目的是在对象之间建立一对一的关系,其中一个充当主题,而其他则充当观察者。然后,主题承担通知观察者的责任,当其内部发生变化并可能影响它们时。
它与发布者和订阅者关系有些相似,其中对象订阅并监听特定事件通知。核心区别在于观察者模式中,主题和观察者彼此知晓对方,因此它们仍然轻度耦合在一起。
让我们回顾一个典型观察者模式实现的 UML 图,看看它在代码中实现时可能如何工作:

图 9.1 – 观察者模式的 UML 图
如您所见,主题和观察者都有自己的接口实现,但最重要的一个是ISubject,它包括以下方法:
-
AttachObserver(): 此方法允许你将观察者对象添加到要通知的观察者列表中。 -
DetachObserver(): 此方法从观察者列表中移除一个观察者。 -
NotifyObservers(): 此方法通知所有已添加到主题观察者列表中的对象。
扮演观察者的对象必须实现一个名为 Notify() 的公共方法,该方法将由主题用于在它改变状态时通知它。
观察者模式的优缺点
这些是观察者模式的一些好处:
-
动态性: 观察者允许主题根据需要添加任意数量的观察者对象。但也可以在运行时动态地移除它们。
-
一对一: 观察者模式的主要好处是它优雅地解决了实现一个事件处理系统的问题,其中对象之间存在一对一的关系。
以下是一些观察者模式的潜在缺点:
-
无序性: 观察者模式不保证通知观察者的顺序。因此,如果有两个或更多观察者对象共享依赖项并且必须按特定顺序一起工作,那么观察者模式在其原生形式中并不是为处理这种执行上下文而设计的。
-
泄漏: 观察者可能导致内存泄漏,因为主题对其观察者持有强引用。如果实现不当,并且观察者对象在不再需要时没有正确地分离和释放,它可能会引起垃圾收集问题,并且某些资源将不会被释放。
要了解此处指出的潜在内存泄漏缺陷,我建议阅读以下关于该主题的维基百科文章:en.wikipedia.org/wiki/Lapsed_listener_problem。
但请注意,与任何与优化相关的内容一样,它是上下文相关的,因此你应该在优化潜在的性能问题之前对代码进行性能分析。
何时使用观察者模式
观察者模式的优势在于它解决了与对象之间一对一关系相关的特定问题。因此,如果你有一个核心组件经常改变状态并且有许多依赖项需要对这些变化做出反应,那么观察者模式允许你定义这些实体之间的关系以及一个使它们能够被通知的机制。
因此,如果你不确定何时使用观察者模式,你应该分析你对象之间的关系,以确定这种模式是否适合你试图解决的问题。
使用观察者模式解耦核心组件
我们游戏的核心元素是赛车。它是场景中状态变化最频繁的实体,因为它在玩家控制下在世界各地移动并与其他实体交互时,经常更新其属性。它有多个依赖项需要管理,例如跟随它的主摄像头和显示其当前速度的用户界面。
竞速自行车是我们游戏的主要主题,许多系统必须观察它,以便在它状态改变时更新自己。例如,每次自行车与障碍物碰撞时,HUD 必须更新护盾当前的健康值,而相机则显示一个全屏着色器,使屏幕边缘变暗,以展示减少的耐力。
在 Unity 中实现这种行为很容易。我们可以让 BikeController 告诉 HUDController 和 CameraController 当它受到伤害时应该做什么。但为了使这种方法有效,BikeController 必须知道在各个控制器上调用哪些公共方法。
如你所想,这并不容易扩展,因为随着 BikeController 的复杂性增加,我们需要管理的对其依赖的调用也会增多。但有了观察者模式,我们将打破控制器之间的这种耦合。首先,我们将给每个组件分配一个角色;BikeController 将成为主题,并负责管理依赖列表并在必要时通知它们。
HUD 和相机控制器将作为 BikeController 的观察者。它们的核心责任是监听来自 BikeController 的通知并相应地行动。BikeController 并不告诉它们做什么;它只是告诉它们有变化发生,并让它们自行决定如何反应。
以下图表说明了我们刚刚讨论的概念:

图 9.2 – 控制器观察主题的示意图
如我们所见,我们可以有任意数量的控制器观察自行车(主题)。在下一节中,我们将把这些概念转化为代码。
实现观察者模式
现在,让我们以简单且可重用的方式实现观察者模式,以便在各种场景中使用:
- 我们将从这个代码示例开始,实现模式的两个元素。让我们从
Subject类开始:
using UnityEngine;
using System.Collections;
namespace Chapter.Observer
{
public abstract class Subject : MonoBehaviour
{
private readonly
ArrayList _observers = new ArrayList();
public void Attach(Observer observer)
{
_observers.Add(observer);
}
public void Detach(Observer observer)
{
_observers.Remove(observer);
}
public void NotifyObservers()
{
foreach (Observer observer in _observers)
{
observer.Notify(this);
}
}
}
}
Subject 抽象类有三个方法。前两个方法,Attach() 和 Detach(),分别负责将观察者对象添加到观察者列表或从列表中移除。第三个方法 NotifyObservers() 负责遍历观察者对象列表,并调用它们的公共方法,即 Notify()。在接下来的步骤中实现具体的观察者类时,这一点将变得有意义。
- 接下来是
Observer抽象类:
using UnityEngine;
namespace Chapter.Observer
{
public abstract class Observer : MonoBehaviour
{
public abstract void Notify(Subject subject);
}
}
希望成为观察者的类必须继承这个 Observer 类并实现名为 Notify() 的抽象方法,该方法接收主题作为参数。
- 现在我们已经有了核心成分,让我们编写一个作为主题的
BikeController类的骨架。然而,因为它太长了,我们将将其分成三个部分。第一部分只是初始化代码:
using UnityEngine;
namespace Chapter.Observer
{
public class BikeController : Subject
{
public bool IsTurboOn
{
get; private set;
}
public float CurrentHealth
{
get { return health; }
}
private bool _isEngineOn;
private HUDController _hudController;
private CameraController _cameraController;
[SerializeField]
private float health = 100.0f;
void Awake()
{
_hudController =
gameObject.AddComponent<HUDController>();
_cameraController =
(CameraController)
FindObjectOfType(typeof(CameraController));
}
private void Start()
{
StartEngine();
}
下面的部分很重要,因为我们是在 BikeController 启用时附加我们的观察者,并在它禁用时解除它们;这避免了我们保留不再需要的引用:
void OnEnable()
{
if (_hudController)
Attach(_hudController);
if (_cameraController)
Attach(_cameraController);
}
void OnDisable()
{
if (_hudController)
Detach(_hudController);
if (_cameraController)
Detach(_cameraController);
}
对于最后一部分,我们有一些核心行为的初步实现。请注意,我们只在自行车参数更新时通知观察者,例如它受到伤害或涡轮增压器激活时:
private void StartEngine()
{
_isEngineOn = true;
NotifyObservers();
}
public void ToggleTurbo()
{
if (_isEngineOn)
IsTurboOn = !IsTurboOn;
NotifyObservers();
}
public void TakeDamage(float amount)
{
health -= amount;
IsTurboOn = false;
NotifyObservers();
if (health < 0)
Destroy(gameObject);
}
}
}
BikeController 从不直接调用 HUDController 或 CameraController;它只通知它们有变化——它从不告诉它们该做什么。这很重要,因为观察者可以独立选择在收到通知时的行为。因此,它们在一定程度上与主题解耦。
- 现在,让我们实现一些观察者并观察它们在主题发出信号时的行为。我们将从
HUDController开始,它负责显示用户界面:
using UnityEngine;
namespace Chapter.Observer {
public class HUDController : Observer {
private bool _isTurboOn;
private float _currentHealth;
private BikeController _bikeController;
void OnGUI() {
GUILayout.BeginArea (
new Rect (50,50,100,200));
GUILayout.BeginHorizontal ("box");
GUILayout.Label ("Health: " + _currentHealth);
GUILayout.EndHorizontal ();
if (_isTurboOn) {
GUILayout.BeginHorizontal("box");
GUILayout.Label("Turbo Activated!");
GUILayout.EndHorizontal();
}
if (_currentHealth <= 50.0f) {
GUILayout.BeginHorizontal("box");
GUILayout.Label("WARNING: Low Health");
GUILayout.EndHorizontal();
}
GUILayout.EndArea ();
}
public override void Notify(Subject subject) {
if (!_bikeController)
_bikeController =
subject.GetComponent<BikeController>();
if (_bikeController) {
_isTurboOn =
_bikeController.IsTurboOn;
_currentHealth =
_bikeController.CurrentHealth;
}
}
}
}
HUDController 的 Notify() 方法接收一个指向通知它的主题的引用。因此,它可以访问其属性并选择在界面中显示哪一个。
- 最后,我们将实现
CameraController。相机的预期行为是在自行车涡轮增压器激活时开始晃动:
using UnityEngine;
namespace Chapter.Observer
{
public class CameraController : Observer
{
private bool _isTurboOn;
private Vector3 _initialPosition;
private float _shakeMagnitude = 0.1f;
private BikeController _bikeController;
void OnEnable()
{
_initialPosition =
gameObject.transform.localPosition;
}
void Update()
{
if (_isTurboOn)
{
gameObject.transform.localPosition =
_initialPosition +
(Random.insideUnitSphere * _shakeMagnitude);
}
else
{
gameObject.transform.
localPosition = _initialPosition;
}
}
public override void Notify(Subject subject)
{
if (!_bikeController)
_bikeController =
subject.GetComponent<BikeController>();
if (_bikeController)
_isTurboOn = _bikeController.IsTurboOn;
}
}
}
CameraController 检查刚刚通知它的主题的公共布尔属性,如果为真,则开始晃动相机,直到再次被 BikeController 通知并确认涡轮增压开关关闭。
这个实现的要点是记住,BikeController(主题)并不知道一旦它们被通知,HUD 和相机控制器(观察者)会如何行为。因此,观察者可以选择如何响应主题的变更通知。
这种方法将这些控制器组件彼此解耦。因此,单独实现和调试它们要容易得多。
测试观察者模式实现
为了测试我们的实现,我们必须做以下事情:
-
打开一个空的 Unity 场景,但确保它至少包含一个相机和一个光源。
-
将一个 3D GameObject,例如一个立方体,添加到场景中,并使其对相机可见。
-
将
BikeController脚本作为组件附加到新的 3D 对象。 -
将
CameraController脚本附加到主场景相机。 -
创建一个空的 GameObject,将其添加以下
ClientObserver脚本,然后开始场景:
using UnityEngine;
namespace Chapter.Observer
{
public class ClientObserver : MonoBehaviour
{
private BikeController _bikeController;
void Start()
{
_bikeController =
(BikeController)
FindObjectOfType(typeof(BikeController));
}
void OnGUI()
{
if (GUILayout.Button("Damage Bike"))
if (_bikeController)
_bikeController.TakeDamage(15.0f);
if (GUILayout.Button("Toggle Turbo"))
if (_bikeController)
_bikeController.ToggleTurbo();
}
}
}
我们应该在屏幕上看到类似于以下内容的 GUI 按钮和标签:

图 9.3 – 运行中的 Unity 场景中的 GUI 元素
如果我们按下涡轮增压按钮,我们会看到相机晃动,HUD 显示涡轮增压器的状态。而“损坏自行车”按钮会减少自行车的健康值。
实现观察者模式有许多不同的方法,每种方法都有其固有的优点。我无法在本章中涵盖所有这些方法。因此,出于教育目的,我编写了本章中的代码示例。因此,这并不是最优化方法,但是一种易于理解的方法。
审查替代解决方案
观察者模式的替代方案是本地的 C# 事件系统。这个事件系统的一个显著优点是它比观察者模式更细粒度,因为对象可以监听另一个对象发出的特定事件,而不是从主体获得一般通知。
如果您需要组件通过事件进行交互,尤其是如果您不需要在它们之间建立特定关系时,应始终考虑使用本地事件系统。
Unity 有自己的本地事件系统;它与 C# 版本非常相似,但增加了引擎功能,例如通过检查器连接事件和动作的能力。要了解更多信息,请访问 docs.unity3d.com/2021.2/Documentation/Manual/UnityEvents.html。
摘要
在本章中,我们学习了如何通过将它们分配为主体或观察者的角色来使用观察者模式将BikeController与其依赖项解耦。现在我们的代码更容易管理和扩展,因为我们能够轻松地将BikeController与其他控制器进行交互,而耦合度最小。
在下一章中,我们将探讨访问者模式,这是最难学习的一种模式。我们将使用它来构建增强功能,这是我们游戏的核心机制和成分。
使用访问者模式实现升级
在本章中,我们将为我们的游戏实现一个升级机制。自游戏早期开始,升级一直是视频游戏的核心成分之一。第一个实现升级的游戏之一是 1980 年的吃豆人。在游戏中,你可以吃掉能量豆,使 Pac-Man 获得暂时的无敌状态。另一个经典例子是马里奥兄弟中的蘑菇,它使马里奥变得更高大更强壮。
我们将要构建的升级成分将与经典类似,但具有更多的粒度。我们不仅可以使一个实体的单一能力得到升级,还可以创建组合,一次提供多个好处。例如,我们可以有一个名为“保护者”的升级,它增加了面向前方的护盾的耐久性,并增加了主要武器的强度。
因此,在本章中,我们将实现一个可扩展和可配置的升级机制,不仅对我们程序员来说如此,而且对于可能被分配创建和调整独特升级成分的设计师来说也是如此。我们将通过结合访问者模式和独特的 Unity API 功能 ScriptableObjects 来实现这一点。
本章将涵盖以下主题:
-
访问者模式背后的基本原理
-
为赛车游戏实现一个升级机制
为了简化代码示例并提高可读性,本节包含了一些简化的代码示例。如果你希望在真实游戏项目的上下文中查看完整的实现,请打开 GitHub 项目中的FPP文件夹,该链接可以在技术要求部分找到。
第十三章:技术要求
本章是实践性的。你需要对 Unity 和 C#有一个基本的了解。我们将使用以下 Unity 引擎和 C#语言概念:
-
接口
-
ScriptableObjects
如果你对这些概念不熟悉,请在开始本章之前复习它们。本章的代码文件可以在github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter10找到.
查看以下视频以查看代码的实际效果:bit.ly/3eeknGC.
我们在这本书的代码示例中经常使用 ScriptableObjects,因为在构建游戏系统和机制时,使非程序员能够轻松配置它们是至关重要的。平衡系统和编写新成分的过程通常属于游戏和关卡设计师的责任。因此,我们使用 ScriptableObjects,因为它提供了一种一致的方式来建立创作管道,以创建和配置游戏中的资产。
理解访问者模式
一旦你掌握了它,访问者模式的初衷就很简单;一个可访问对象允许一个访问者对其结构中的特定元素进行操作。这个过程允许被访问的对象从访问者那里获得新的功能,而无需直接修改。这种描述一开始可能看起来非常抽象,但如果我们将对象想象成一个结构而不是一个封闭的数据和逻辑容器,它就更容易可视化。因此,使用访问者模式,我们可以遍历对象的结构,对其元素进行操作,并扩展其功能,而无需修改它。
另一种想象访问者模式在游戏中发挥作用的方法是想象我们的游戏中的自行车与一个能量提升器相撞。就像电子电流一样,能量提升器流经车辆的内部结构。标记为可访问的组件会被能量提升器访问,并添加新的功能,但没有任何修改。
在以下图中,我们可以可视化这些原则:

图 10.1 - 访问者模式的 UML 图
在这个模式中,我们需要熟悉两个关键参与者:
-
IVisitor是希望成为访问者的类必须实现的接口。访问者类将必须为每个可访问元素实现一个访问者方法。
-
IVisitable是希望成为可访问的类必须实现的接口。它包括一个
accept()方法,为访问者对象提供一个入口点来访问。
在继续之前,我们必须声明,我们将在接下来的部分中审查的代码示例违反了访问者模式的一个潜在规则。在这个例子中,访问者对象更改了一些被访问对象的属性。然而,这种违规是否破坏了模式的完整性并使其原始设计意图无效,是一个值得讨论的问题。
尽管如此,在本章中,我们更关注访问者模式如何使我们能够遍历组成可访问对象结构的元素。在我们的用例中,这个结构代表了我们自行车的核心元素,包括引擎、护盾和主要武器。
访问者被认为是最难理解的模式之一。所以如果你一开始没有掌握其核心概念,请不要感到难过。我相信它之所以难以学习,是因为它是最难解释的模式之一。
访问者模式的优缺点
我已经列出了一份使用此模式的好处和缺点的简短列表。
以下是一些益处:
-
开放/封闭:你可以添加新的行为,这些行为可以与不同类的对象一起工作,而无需直接修改它们。这种方法遵循面向对象编程原则中的开放/封闭原则,即实体应该对扩展开放,但对修改封闭。
-
单一职责:访问者模式可以在某种意义上遵守单一职责原则,即你可以有一个对象(可访问者)来存储数据,另一个对象(访问者)负责引入特定的行为。
以下是一些潜在的缺点:
-
可访问性:访问者可能缺乏访问它们访问的特定元素的私有字段和方法所需的必要权限。因此,如果我们不使用该模式,我们可能需要在我们的类中暴露比通常更多的公共属性。
-
复杂性:我们可以争论,访问者模式在结构上比像单例、状态和对象池这样的直接模式更复杂。因此,它可能会给代码库带来其他程序员可能觉得令人困惑的复杂性,如果他们不熟悉该模式的结构和复杂性。
访问者使用了一种名为双重分派的软件工程概念来设计其基本架构。这个概念最简单的定义是,它是一种机制,根据运行时调用中涉及的两个对象类型将方法调用委托给不同的具体方法。完全理解这个概念并不是理解本章中展示的模式的示例所必需的。
设计增强效果机制
如本章开头所述,增强效果是视频游戏的一个基本元素。它是我们游戏的核心成分。但首先,我们将回顾我们机制的一些关键规范:
-
粒度:我们的增强实体将能够同时增强多个属性。例如,我们可能有一个增强效果,它增加攻击能力,如主要武器的射程,同时修复前向护盾。
-
时间:增强效果不是时间相关的,因此它们不会在一段时间后过期。下一个增强效果的益处会叠加到前一个效果之上,直到达到增强属性的极限设置。
注意,这些规范仅限于以下代码示例,但并非最终版本。我们可以轻松地将增强效果的益处设置为临时性的,或者通过修改下一节中展示的代码示例的微小变化来改变整个机制的整体设计。
我们的水平设计师将在赛道上的战略点上定位增强效果;它们将具有在高速下易于识别的 3D 形状。玩家必须与增强效果发生碰撞以激活其包含的能力和益处。
我们将结合使用访问者模式和 ScriptableObjects 来实现这个游戏机制,以便设计师可以编写新的提升变体,而无需编写任何代码。
在视频游戏中,物品和提升之间的核心区别是玩家可以收集物品、存储它们并在需要时使用它们的益处。但相比之下,提升在玩家接触后立即生效。
实现提升机制
在本节中,我们将编写必要的骨架代码来实现具有访问者模式的提升系统。我们的目标是到本节结束时有一个有效的概念证明。
实现提升系统
让我们看看实现步骤:
- 我们将首先编写模式的核心元素,即
Visitor接口:
namespace Pattern.Visitor
{
public interface IVisitor
{
void Visit(BikeShield bikeShield);
void Visit(BikeEngine bikeEngine);
void Visit(BikeWeapon bikeWeapon);
}
}
- 接下来,我们将编写一个接口,每个可访问元素都必须实现:
namespace Pattern.Visitor
{
public interface IBikeElement
{
void Accept(IVisitor visitor);
}
}
- 现在我们已经有了主要接口,让我们实现使提升机制工作的主要类;由于其长度,我们将分两部分进行回顾:
using UnityEngine;
namespace Pattern.Visitor
{
[CreateAssetMenu(fileName = "PowerUp", menuName = "PowerUp")]
public class PowerUp : ScriptableObject, IVisitor
{
public string powerupName;
public GameObject powerupPrefab;
public string powerupDescription;
[Tooltip("Fully heal shield")]
public bool healShield;
[Range(0.0f, 50f)]
[Tooltip(
"Boost turbo settings up to increments of 50/mph")]
public float turboBoost;
[Range(0.0f, 25)]
[Tooltip(
"Boost weapon range in increments of up to 25 units")]
public int weaponRange;
[Range(0.0f, 50f)]
[Tooltip(
"Boost weapon strength in increments of up to 50%")]
public float weaponStrength;
首先要注意的是,这个类是一个带有 CreateAssetMenu 属性的 ScriptableObject 类。因此,我们将能够从资产菜单中创建新的提升资产。然后,我们可以在引擎的检查器中配置每个新提升的参数。但另一个重要的细节是,这个类实现了 IVisitor 接口,我们将在下一部分进行回顾:
public void Visit(BikeShield bikeShield)
{
if (healShield)
bikeShield.health = 100.0f;
}
public void Visit(BikeWeapon bikeWeapon)
{
int range = bikeWeapon.range += weaponRange;
if (range >= bikeWeapon.maxRange)
bikeWeapon.range = bikeWeapon.maxRange;
else
bikeWeapon.range = range;
float strength =
bikeWeapon.strength +=
Mathf.Round(
bikeWeapon.strength
* weaponStrength / 100);
if (strength >= bikeWeapon.maxStrength)
bikeWeapon.strength = bikeWeapon.maxStrength;
else
bikeWeapon.strength = strength;
}
public void Visit(BikeEngine bikeEngine)
{
float boost = bikeEngine.turboBoost += turboBoost;
if (boost < 0.0f)
bikeEngine.turboBoost = 0.0f;
if (boost >= bikeEngine.maxTurboBoost)
bikeEngine.turboBoost = bikeEngine.maxTurboBoost;
}
}
}
如我们所见,对于每个可访问元素,我们都有一个与之关联的独特方法;在它们内部,我们实现了在访问特定元素时要执行的操作。
在我们的情况下,我们正在更改访问对象的具体属性,同时考虑到定义的最大值。因此,我们将提升在访问自行车结构特定可访问元素时预期的行为封装在单个 Visit() 方法中。
我们需要更改特定值、修改操作和为特定可访问元素添加新行为;我们可以在单个类中完成这些事情。
- 接下来是
BikeController类,它负责控制构成自行车结构的自行车关键组件:
using UnityEngine;
using System.Collections.Generic;
namespace Pattern.Visitor
{
public class BikeController : MonoBehaviour, IBikeElement
{
private List<IBikeElement> _bikeElements =
new List<IBikeElement>();
void Start()
{
_bikeElements.Add(
gameObject.AddComponent<BikeShield>());
_bikeElements.Add(
gameObject.AddComponent<BikeWeapon>());
_bikeElements.Add(
gameObject.AddComponent<BikeEngine>());
}
public void Accept(IVisitor visitor)
{
foreach (IBikeElement element in _bikeElements)
{
element.Accept(visitor);
}
}
}
}
注意,该类正在实现 IBikeElement 接口的 Accept() 方法。当自行车与位于赛道上的提升物品相撞时,将自动调用此方法。通过此方法,提升实体将能够将访客对象传递给 BikeController。
控制器将依次将接收到的访客对象转发给其每个可访问元素。可访问元素将根据访客对象实例中的配置更新其属性。因此,这就是提升机制被触发和运行的方式。
- 现在是时候实现我们各自的访问元素了,从我们的骨骼
BikeWeapon类开始:
using UnityEngine;
namespace Pattern.Visitor
{
public class BikeWeapon : MonoBehaviour, IBikeElement
{
[Header("Range")]
public int range = 5;
public int maxRange = 25;
[Header("Strength")]
public float strength = 25.0f;
public float maxStrength = 50.0f;
public void Fire()
{
Debug.Log("Weapon fired!");
}
public void Accept(IVisitor visitor)
{
visitor.Visit(this);
}
void OnGUI()
{
GUI.color = Color.green;
GUI.Label(
new Rect(125, 40, 200, 20),
"Weapon Range: " + range);
GUI.Label(
new Rect(125, 60, 200, 20),
"Weapon Strength: " + strength);
}
}
}
- 以下是
BikeEngine类;请注意,在完整的实现中,这个类将负责模拟一些引擎的行为,包括激活涡轮增压,管理冷却系统,以及控制速度:
using UnityEngine;
namespace Pattern.Visitor
{
public class BikeEngine : MonoBehaviour, IBikeElement
{
public float turboBoost = 25.0f; // mph
public float maxTurboBoost = 200.0f;
private bool _isTurboOn;
private float _defaultSpeed = 300.0f; // mph
public float CurrentSpeed
{
get
{
if (_isTurboOn)
return _defaultSpeed + turboBoost;
return _defaultSpeed;
}
}
public void ToggleTurbo()
{
_isTurboOn = !_isTurboOn;
}
public void Accept(IVisitor visitor)
{
visitor.Visit(this);
}
void OnGUI()
{
GUI.color = Color.green;
GUI.Label(
new Rect(125, 20, 200, 20),
"Turbo Boost: " + turboBoost);
}
}
}
- 最后,
BikeShield的名称暗示了其主要功能,其实现如下:
using UnityEngine;
namespace Pattern.Visitor
{
public class BikeShield : MonoBehaviour, IBikeElement
{
public float health = 50.0f; // Percentage
public float Damage(float damage)
{
health -= damage;
return health;
}
public void Accept(IVisitor visitor)
{
visitor.Visit(this);
}
void OnGUI()
{
GUI.color = Color.green;
GUI.Label(
new Rect(125, 0, 200, 20),
"Shield Health: " + health);
}
}
}
如我们所见,每个单独的可访问自行车元素类都实现了Accept()方法,从而使自己变得可访问。
测试PowerUp系统实现
要快速测试您自己的 Unity 实例中的实现,您需要遵循以下步骤:
-
将我们刚刚审查的所有脚本复制到您的 Unity 项目中。
-
创建一个新的场景。
-
将一个 GameObject 添加到场景中。
-
将以下
ClientVisitor脚本附加到新的 GameObject 上:
using UnityEngine;
namespace Pattern.Visitor
{
public class ClientVisitor : MonoBehaviour
{
public PowerUp enginePowerUp;
public PowerUp shieldPowerUp;
public PowerUp weaponPowerUp;
private BikeController _bikeController;
void Start()
{
_bikeController =
gameObject.
AddComponent<BikeController>();
}
void OnGUI()
{
if (GUILayout.Button("PowerUp Shield"))
_bikeController.Accept(shieldPowerUp);
if (GUILayout.Button("PowerUp Engine"))
_bikeController.Accept(enginePowerUp);
if (GUILayout.Button("PowerUp Weapon"))
_bikeController.Accept(weaponPowerUp);
}
}
}
-
转到资产/创建菜单选项并创建三个
PowerUp资产。 -
使用您期望的参数配置并命名新的
PowerUp资产。 -
在检查器中为新
PowerUp资产命名并调整其参数。 -
将新的
PowerUp资产添加到ClientVisitor组件的公共属性中。
一旦开始场景,您应该在屏幕上看到以下 GUI 按钮和调试输出:

图 10.2 - 代码示例运行时的截图
但你可能想知道,我该如何创建一个实际的拾取实体,我可以在赛道上生成它?
做这件事的一个快速方法是简单地创建一个类似于以下的Pickup类:
using System;
using UnityEngine;
public class Pickup : MonoBehaviour
{
public PowerUp powerup;
private void OnTriggerEnter(Collider other)
{
if (other.GetComponent<BikeController>())
{
other.GetComponent<BikeController>().Accept(powerup);
Destroy(gameObject);
}
}
}
通过将此脚本附加到一个配置为触发器的碰撞器组件的 GameObject 上,我们可以检测具有BikeController组件的实体何时进入触发器。然后我们只需调用它的Accept()方法并传递PowerUp实例。设置触发器超出了本章的范围,但我建议查看 Git 仓库中的 FPP 项目,以了解我们如何在游戏的可玩原型中设置它。
检查PowerUp系统实现
我们能够结合访问者模式的结构和 ScriptableObjects 的 API 功能,创建一个允许我们项目中的任何人为其编写和配置新的PowerUp而无需编写任何代码的机制。
如果我们需要调整PowerUp对我们车辆各个组件的影响,我们可以通过修改单个类来实现。因此,总的来说,我们在保持代码易于维护的同时,实现了一定程度的可扩展性。
本书中对标准软件设计模式的实现是实验性的,并且以创新的方式进行了调整。我们将它们调整为利用 Unity API 功能,并调整以适应游戏开发用例。因此,我们不应将示例视为学术或标准化的参考,而应将其视为解释。
摘要
在本书的这一章节中,我们使用访问者模式作为基础,为我们的游戏构建了一个增强机制。我们还建立了一个工作流程来创建和配置增强功能。因此,我们结合了技术和创造性思维,这是游戏开发的核心。
在下一章中,我们将设计和实现敌方无人机的攻击机动。我们将使用策略模式作为我们系统的基石。
使用策略模式实现无人机
在本章中,我们将实现绕着赛道飞行并射击激光束攻击玩家的敌机无人机。它们是小小的讨厌的机器人害虫,将测试玩家的反应能力。我们的无人机将有一种单一的攻击方式,即以 45 度角发射连续的激光束。为了创造自主智能的错觉,无人机可以在运行时分配三种不同的攻击动作。每个动作都是一个可预测运动的重复序列。单独来看,无人机的行为可能看起来很机械,但当我们把它们放在赛道特定位置上时,它们可能看起来像是在制定策略以战胜玩家。
因此,我建议我们使用策略模式来实现各种无人机行为。选择这个模式的主要原因是因为它允许我们在运行时为对象分配特定的行为。但首先,让我们分析一下模式的规范和我们的敌机无人机的设计意图。
为了简洁和清晰,本章包括简化的代码示例。如果你希望在一个实际游戏项目中查看模式的完整实现,请打开 GitHub 项目中的 FPP 文件夹。你可以在 技术要求 部分找到链接。
在本章中,我们将涵盖以下主题:
-
策略模式的概述
-
实现敌机无人机攻击行为
第十四章:技术要求
本章是实践性的,因此你需要对 Unity 和 C# 有基本的了解。
本章的代码文件可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter11。
查看以下视频以查看代码的实际效果:bit.ly/2TdeoL4.
理解策略模式
策略模式的主要目标是推迟在运行时决定使用哪种行为的决策。这是因为策略模式允许我们定义一组封装在称为策略的单独类中的行为。每个策略都是可互换的,并且可以被分配给目标上下文对象以改变其行为。
让我们通过这个 UML 图来可视化模式的关键元素:

图 11.1 – 策略模式的 UML 图
下面是这个模式的关键角色的分解:
-
上下文 是使用各种具体策略类并通过策略接口与它们交互的类。
-
策略 接口对所有具体策略类都是通用的。它公开了一个方法,
上下文类可以使用它来执行策略。 -
具体策略类,也称为策略,是算法/行为变体的具体实现,这些变体可以在运行时应用于
上下文对象。
目前,这些概念可能听起来非常抽象,但在实践中,它们很容易理解,正如我们将在本书后面看到的。
策略模式是一种行为软件设计模式;其最接近的亲戚是状态模式。我们可以使用两者来封装一组行为在单独的类中。当你想在运行时选择一个行为并将其应用于对象时,你应该使用策略模式。你还可以在想要对象在内部状态改变时改变其行为时使用状态模式。
策略模式的优缺点
策略模式的一些优点如下:
-
封装: 这种模式的一个明显优点是它强制算法的变体被封装在单独的类中。因此,这有助于我们避免使用长条件语句,同时保持代码结构化。
-
运行时: 这种模式的主要优点是实现了一种机制,允许我们在运行时交换对象使用的算法。这种方法使我们的对象更加动态和易于扩展。
以下是一些策略模式的潜在缺点:
-
客户: 客户类必须了解它们所实现的算法的个体策略和变体,以便知道选择哪一个。因此,客户负责确保对象在其生命周期内表现如预期。
-
混淆: 由于策略模式和状态模式在结构上非常相似,但意图不同,因此在选择使用哪一个以及在使用上下文时可能会产生混淆。在大多数情况下,这不是问题,但如果你在与程序员团队合作,根据对主题知识的不同了解水平,一些同事可能不理解你选择的模式。
我认为与同事定期就架构、模式和最佳实践进行开放讨论是至关重要的。如果你能作为一个团队就使用特定设计模式时的共同方法达成一致,你最终将拥有更一致的整体架构和更干净的代码。
何时使用策略模式
当我被分配实现敌人角色的行为时,我首先考虑的选项是状态模式或有限状态机(FSM),因为大多数情况下,角色是有状态的。
但有时,如果满足以下条件,我可能会使用策略模式:
-
我有一个具有相同行为多个变体的实体,并且我希望将它们封装在单独的类中。
-
我希望在运行时为实体分配特定的行为变体,而无需考虑其当前内部状态。
-
我需要将一种行为应用到实体上,以便它可以根据在运行时定义的选择标准完成特定的任务。
第三个点可能是我选择使用策略模式而不是状态模式来实现本章中介绍的敌方无人机的主要原因。无人机的行为是机械的;它有一个单一的任务:攻击玩家。它不会根据内部状态的变化对其动作进行任何修改。它只需要在运行时分配攻击行为,以完成攻击玩家的任务,这使得它成为当前设计中策略模式的合适候选者。
重要的是要注意,策略模式的潜在用例不仅限于实现敌方角色。例如,我们可以用它来封装各种加密算法,以便根据目标平台应用保存的文件。或者,如果我们正在制作一款幻想游戏,我们可以用它来封装玩家可以应用于目标实体的魔法家族的各个行为。因此,这种模式的潜在用例很广泛,可以应用于各种上下文,从核心系统到游戏玩法机制。
在下一节中,我们将回顾敌方无人机的设计意图。
设计敌方无人机
我们游戏中的敌方无人机并不非常聪明;背后没有运行人工智能。这些是具有机器人行为的机器人,在视频游戏中,敌人具有可预测的自动化行为并在循环中运行是很常见的。例如,在原始《超级马里奥兄弟》中的 Goombas 只是朝一个方向走;他们没有意识到马里奥的存在,也不会对他做出反应。他们只是在运行一个算法,使他们沿着路径徘徊,直到撞到障碍物。单独来看,他们并不构成威胁,但如果将它们编队或定位在地图上导航困难的位置,它们就会变得难以躲避。
我们将使用相同的方法来设计我们的敌方无人机。单独来看,它们很容易被打败,因为它们不能根据玩家的动作改变行为,但在编队中,它们可能很难躲避。
我们的无人机有三种不同的攻击动作;每个动作都围绕一组特定的动作进行,这些动作是可预测的,但在无人机编队时仍然难以反击。
让我们来看看每个动作:
-
上下摆动动作:在上下摆动动作中,无人机以高速上下移动,同时发射激光束。
-
穿梭动作:对于穿梭动作,无人机以高速水平移动,同时射击。穿梭动作限制在轨道两条轨道之间的距离内。
-
回退动作:对于回退动作,无人机在射击的同时向后移动。无人机的最高速度可以与玩家的自行车相匹配,但只能向后移动有限的时间。
以下图示展示了前面的机动:

图 11.2 – 无人机攻击机动的示意图
敌人无人机有一件武器:一个向前发射的 45 度角激光束。以下图示展示了无人机的激光武器:

图 11.3 – 无人机武器攻击的示意图
如我们所见,玩家必须通过高速绕过无人机来避免攻击。如果被光束击中,自行车的正面护盾将损失一定量的能量。如果护盾耗尽,车辆将在下一次被击中时爆炸,游戏结束。
在下一节中,我们将把这个设计转换成代码。
实现敌人无人机
在本节中,我们将编写策略模式的基本实现和无人机敌人的单个攻击行为。本节中的代码在某些方面可能看起来过于简化。然而,最终目标不是实现完整的敌人无人机,而是理解策略模式的基本原理。
实现敌人无人机的步骤
让我们先实现策略模式的主要组成部分:
- 我们的第一个元素是策略接口;我们所有的具体策略都将使用它:
namespace Chapter.Strategy
{
public interface IManeuverBehaviour
{
void Maneuver(Drone drone);
}
}
注意,我们正在将Drone类型的参数传递给Maneuver()方法。这是一个我们稍后会回顾的重要细节。
- 接下来是我们的
Drone类;它将使用我们的具体策略,因此在策略模式的整体结构中,我们将将其视为我们的Context类:
using UnityEngine;
namespace Chapter.Strategy {
public class Drone : MonoBehaviour {
// Ray parameters
private RaycastHit _hit;
private Vector3 _rayDirection;
private float _rayAngle = -45.0f;
private float _rayDistance = 15.0f;
// Movement parameters
public float speed = 1.0f;
public float maxHeight = 5.0f;
public float weavingDistance = 1.5f;
public float fallbackDistance = 20.0f;
void Start() {
_rayDirection =
transform.TransformDirection(Vector3.back)
* _rayDistance;
_rayDirection =
Quaternion.Euler(_rayAngle, 0.0f, 0f)
* _rayDirection;
}
public void ApplyStrategy(IManeuverBehaviour strategy) {
strategy.Maneuver(this);
}
void Update() {
Debug.DrawRay(transform.position,
_rayDirection, Color.blue);
if (Physics.Raycast(
transform.position,
_rayDirection, out _hit, _rayDistance)) {
if (_hit.collider) {
Debug.DrawRay(
transform.position,
_rayDirection, Color.green);
}
}
}
}
}
这个类中的大多数代码行都是用于射线投射调试信息;我们可以安全地忽略它们。然而,以下部分是理解的关键:
public void ApplyStrategy(IManeuverBehaviour strategy)
{
strategy.Maneuver(this);
}
ApplyStrategy()方法包含了策略模式的核心机制。如果我们仔细观察,我们可以看到,相关的方法接受一个IManeuverBehaviour类型的具体策略作为参数。这里事情变得非常有趣。一个Drone对象可以通过IManeuverBehaviour接口与其接收到的具体策略进行通信。因此,它只需要调用Maneuver()在运行时执行策略。因此,一个Drone对象不需要知道策略的行为/算法是如何执行的——它只需要了解其接口。
现在,让我们实现具体策略类:
- 以下类实现了弹跳机动:
using UnityEngine;
using System.Collections;
namespace Chapter.Strategy {
public class BoppingManeuver :
MonoBehaviour, IManeuverBehaviour {
public void Maneuver(Drone drone) {
StartCoroutine(Bopple(drone));
}
IEnumerator Bopple(Drone drone)
{
float time;
bool isReverse = false;
float speed = drone.speed;
Vector3 startPosition = drone.transform.position;
Vector3 endPosition = startPosition;
endPosition.y = drone.maxHeight;
while (true) {
time = 0;
Vector3 start = drone.transform.position;
Vector3 end =
(isReverse) ? startPosition : endPosition;
while (time < speed) {
drone.transform.position =
Vector3.Lerp(start, end, time / speed);
time += Time.deltaTime;
yield return null;
}
yield return new WaitForSeconds(1);
isReverse = !isReverse;
}
}
}
}
- 以下类实现了编织机动:
using UnityEngine;
using System.Collections;
namespace Chapter.Strategy {
public class WeavingManeuver :
MonoBehaviour, IManeuverBehaviour {
public void Maneuver(Drone drone) {
StartCoroutine(Weave(drone));
}
IEnumerator Weave(Drone drone) {
float time;
bool isReverse = false;
float speed = drone.speed;
Vector3 startPosition = drone.transform.position;
Vector3 endPosition = startPosition;
endPosition.x = drone.weavingDistance;
while (true) {
time = 0;
Vector3 start = drone.transform.position;
Vector3 end =
(isReverse) ? startPosition : endPosition;
while (time < speed) {
drone.transform.position =
Vector3.Lerp(start, end, time / speed);
time += Time.deltaTime;
yield return null;
}
yield return new WaitForSeconds(1);
isReverse = !isReverse;
}
}
}
}
- 最后,让我们实现回退机动:
using UnityEngine;
using System.Collections;
namespace Chapter.Strategy
{
public class FallbackManeuver :
MonoBehaviour, IManeuverBehaviour {
public void Maneuver(Drone drone) {
StartCoroutine(Fallback(drone));
}
IEnumerator Fallback(Drone drone)
{
float time = 0;
float speed = drone.speed;
Vector3 startPosition = drone.transform.position;
Vector3 endPosition = startPosition;
endPosition.z = drone.fallbackDistance;
while (time < speed)
{
drone.transform.position =
Vector3.Lerp(
startPosition, endPosition, time / speed);
time += Time.deltaTime;
yield return null;
}
}
}
}
你可能已经注意到,每个类的代码相当相似,甚至在某些部分重复。这就是我们使用策略模式的原因之一——我们希望封装类似行为的变体,以便更容易单独维护。但同样,想象一下,如果我们试图在一个类中实现跳跃、穿梭和回退行为,我们的Drone类会多么混乱。我们可能会发现自己在一个充满条件语句的膨胀的Drone类中。
我不建议使用协程来动画化非人类实体。相反,我建议使用如 DOTween 之类的 Tween 引擎,因为你可以用更少的代码来动画化对象,并获得更好的效果。我们在这章中使用协程是为了避免外部依赖,并使我们的代码易于移植。要了解更多关于 DOTween 的信息,请访问 dotween.demigiant.com。
测试敌方无人机实现
现在是时候进行有趣的部分了——测试我们的实现。这将会很容易,因为我们需要做的只是将以下客户端类附加到 Unity 场景中的一个空GameObject上:
using UnityEngine;
using System.Collections.Generic;
namespace Chapter.Strategy {
public class ClientStrategy : MonoBehaviour {
private GameObject _drone;
private List<IManeuverBehaviour>
_components = new List<IManeuverBehaviour>();
private void SpawnDrone() {
_drone =
GameObject.CreatePrimitive(PrimitiveType.Cube);
_drone.AddComponent<Drone>();
_drone.transform.position =
Random.insideUnitSphere * 10;
ApplyRandomStrategies();
}
private void ApplyRandomStrategies() {
_components.Add(
_drone.AddComponent<WeavingManeuver>());
_components.Add(
_drone.AddComponent<BoppingManeuver>());
_components.Add(
_drone.AddComponent<FallbackManeuver>());
int index = Random.Range(0, _components.Count);
_drone.GetComponent<Drone>().
ApplyStrategy(_components[index]);
}
void OnGUI() {
if (GUILayout.Button("Spawn Drone")) {
SpawnDrone();
}
}
}
}
在你的 Unity 实例中,如果你在项目中包含了前面几节中编写的所有脚本,当你启动它时,你应该在屏幕上看到一个名为“Spawn Drone”的单个按钮,如下面的截图所示:

图 11.4 – 在 Unity 中运行的代码示例
如果你点击场景的主按钮,一个代表无人机实体的新立方体应该出现在一个随机位置,同时执行一个随机选择的攻击机动。
审查敌方无人机实现
在前面的代码示例中,客户端类充当一个生成器,随机分配一个策略给一个新的无人机实例。这对于现实世界的用例来说可能是一个有趣的方法。但是,我们还可以使用许多其他方法来选择分配给无人机的策略。这可以基于仅在运行时才知道的特定规则和因素。因此,它不仅限于随机性,还可以是确定性和基于规则的。
审查替代解决方案
本章中展示的代码示例有一个明显的问题。我们将攻击机动行为封装到不同的策略类中,但每个机动不过是一个在循环中运行的单一动画。因此,在一个由包括动画师在内的制作团队构建的实际游戏项目中,我不会通过使用协程或甚至一个 Tween 动画引擎在代码中动画化敌方无人机。相反,我会要求一个动画师在一个外部创作工具中创作一些详细的攻击机动动画,然后将它们作为动画剪辑导入 Unity。然后我会使用 Unity 的本地动画系统和其状态机功能来动态地将攻击机动动画分配给无人机。
如果我决定无人机可以在内部状态改变时切换攻击,那么使用这种方法,我将在动画质量和从一种攻击行为平滑过渡到另一种攻击行为之间的灵活性方面获得提升。因此,我会放弃将每个攻击行为封装到策略类中的想法,而是将它们定义为有限状态。这种切换不会在设计中引起重大变化,因为驱动有限状态机(FSM)、状态和策略模式的概念是紧密相关的。
尽管本章中展示的策略模式实现是有效的,但在管理实体的动画集时,首先考虑使用 Unity 的动画系统原生的功能是明智的。但想象另一种用例,其中我们需要实现运动检测算法的变体,并在运行时将它们分配给无人机。在这种情况下,策略模式将是构建该系统的绝佳选择。
您可以在docs.unity3d.com/2021.2/Documentation/Manual/AnimationOverview.html上阅读 Unity 原生动画系统的官方文档。
摘要
在本章中,我们使用了策略模式来实现游戏中的第一个敌人成分,一个会飞的、激光射击的无人机。通过使用这种模式,我们将无人机攻击动作的每个变体封装在单独的类中。这种方法通过避免拥有充满冗长条件语句的臃肿类,使我们的代码更容易维护。现在,我们可以快速编写新的攻击动作变体或调整现有的动作。因此,我们为自己提供了创造性和快速测试新想法的灵活性,这是游戏开发的重要部分。
在下一章中,我们将开始着手构建武器系统,并探索装饰者模式。
使用装饰器实现武器系统
在本章中,我们将构建一个可定制的武器系统。在整个游戏中,玩家将能够通过购买增强特定属性(如射程和强度)的附件来升级其自行车的初级武器。初级武器安装在自行车的正面,并有两个扩展插槽,玩家可以使用这些插槽构建各种组合。为了构建这个系统,我们将使用装饰器模式。这并不令人惊讶,因为它的名字暗示了它的用途,正如我们将在本章中进一步看到的。
本章将涵盖以下主题:
-
装饰器模式背后的基本原理
-
带有附件的武器系统的实现
第十五章:技术要求
你需要具备 Unity 和 C#的基本理解。
我们将使用以下 Unity 引擎和 C#语言概念:
-
构造函数
-
ScriptableObjects
如果你对这些概念不熟悉,请参阅第三章,《Unity 编程简明指南》。
本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter12。
查看以下视频以查看代码的实际效果:bit.ly/3r9rvJD。
在本书的代码示例中,我们经常使用ScriptableObjects,因为我们希望为我们的设计师建立一个创作工作流程,以便他们可以创建新的武器附件或配置现有的附件,而无需修改任何一行代码。对于非程序员来说,使系统、成分和机制易于配置是一种良好的实践。
理解装饰器模式
简而言之,装饰器是一种模式,它允许在不改变现有对象的情况下向其添加新功能。这是通过创建一个包装原始类的装饰器类来实现的。通过这种机制,我们可以轻松地附加但也可以从对象中移除新的行为。
在深入探讨主题之前,让我们回顾以下图表以可视化装饰器的结构:

图 12.1 – 装饰器模式的 UML 图
IWeapon接口建立了一个实现合同,它将在装饰对象及其装饰器之间保持一致的方法签名。WeaponDecorator包装目标对象,并通过增强或覆盖其行为来装饰它。
在过程中,装饰对象的方法签名和整体结构不会被修改,只是其行为或属性值。因此,我们可以轻松地从对象中移除装饰,并使其恢复到初始形式。
大多数关于这种模式的教科书示例都高度依赖于类构造函数。然而,原生的 Unity API 基类,如MonoBehaviour和ScriptableObject,并不使用构造函数的概念来初始化对象的实例。相反,对于 MonoBehaviours,引擎负责初始化附加到 GameObject 上的类。任何初始化代码都应实现于Awake()或Start()回调中。
因此,我们面临着找到一种方法来适应装饰器模式,使其能够使用核心 Unity API 功能,同时不失其主要优势的挑战。
装饰器模式的好处和缺点
以下是一些装饰器模式的好处:
-
子类化的替代方案:继承是一个静态的过程。与装饰器模式不同,它不允许在运行时扩展现有对象的行为。你只能用具有相同父类且具有所需行为的另一个实例来替换一个实例。因此,装饰器模式是子类化和过度继承的更动态的替代方案,并克服了继承的限制。
-
运行时动态性:装饰器模式允许我们通过向对象附加装饰器来在运行时向其添加功能。但这同时也意味着反向操作是可能的,你可以通过移除装饰器来将对象恢复到其原始形式。
以下是一些装饰器模式的潜在缺点:
-
关系复杂性:如果对象周围有多个装饰器层,跟踪初始化链和装饰器之间的关系可能会变得非常复杂。
-
代码复杂性:根据你如何实现装饰器模式,它可能会增加你的代码库的复杂性,因为你可能需要维护几个小的装饰器类。但是,何时以及如何成为实际的缺点是非常具体的,并不是恒定的。在我们即将审查的代码示例中,这不是一个问题,因为每个装饰器都是一个保存为可配置资源的
ScriptableObject实例。
装饰器模式是结构设计模式家族的一部分;其成员包括适配器模式、桥接模式、外观模式和享元模式。
何时使用装饰器模式
在本章中,我们正在实现一个带有附件的武器系统。我们需要考虑一些规范,如下所示:
-
我们需要能够将多个附件附加到武器上。
-
我们需要在运行时能够添加和删除它们。
装饰器模式为我们提供了一种满足这两个核心需求的方法。因此,当实现一个需要以动态方式支持向单个对象添加和删除行为时,这是一个值得考虑的模式。
例如,如果我们被分配去工作在一个CCG(可收集卡牌游戏),我们可能需要实现一个机制,让玩家可以通过堆叠在基础卡牌上的神器卡来增强基础卡牌的能力。另一个用例是想象需要实现一个衣柜系统,玩家可以用配件装饰他们的盔甲以增强特定的属性。
在这两种情况下,使用装饰器模式可能是构建这些类型机制和系统的良好起点。现在我们已经对装饰器模式有了基本的了解,我们将在编写代码之前回顾我们的武器附件系统设计。
设计武器系统
默认情况下,我们游戏中的每款自行车都配备了前置枪,可以发射激光束。玩家可以使用这种武器摧毁可能挡路的障碍物,而且非常熟练的玩家可以在做轮滑动作的同时射击飞行无人机。玩家还可以购买各种附件来增强自行车武器的基线属性,如下面的图所示:

图 12.2 – 武器附件系统图
让我们回顾一下玩家可能购买的潜在附件的简短列表:
-
注入器:一种等离子体注入器,可以增强武器的伤害能力。
-
稳定器:一种减少当自行车达到最高速度时产生的振动的附件。它稳定了武器的射击机制并扩大了其射程。
-
冷却器:一种水冷系统,附着在武器的射击机构上。它提高了射速并减少了冷却时间。
这些示例是潜在附件变体的高级概念。这些附件中的每一个都修改或换句话说,“装饰”了武器的特定属性。
在实现我们的系统时,还需要记住的一个要求是,我们的武器将有两个最大扩展槽位用于附件。玩家必须能够在调整他们的车辆设置时动态地添加和移除它们。
现在我们已经很好地概述了我们的系统设计的要求,是时候将其转化为代码了,我们将在下一节中这样做。
实现武器系统
在本节中,我们将实现我们武器系统的核心类。但是请注意,为了简洁起见,我们不会编写武器的核心行为。我们希望保持对理解装饰器模式如何工作的关注。但如果您想查看这个代码示例的更高级版本,请查看本书 GitHub 仓库中的FPP文件夹。链接可以在技术要求部分找到。
实现武器系统
我们需要执行许多步骤,因为我们有很多代码要一起审查:
- 首先,我们将实现
BikeWeapon类。因为它相当长,我们将将其分成三个部分:
using UnityEngine;
using System.Collections;
namespace Chapter.Decorator
{
public class BikeWeapon : MonoBehaviour
{
public WeaponConfig weaponConfig;
public WeaponAttachment mainAttachment;
public WeaponAttachment secondaryAttachment;
private bool _isFiring;
private IWeapon _weapon;
private bool _isDecorated;
void Start()
{
_weapon = new Weapon(weaponConfig);
}
第一部分是初始化代码。请注意,我们在 Start() 方法中设置了武器的配置。
对于类的第二个部分,它只是 GUI 标签,将帮助我们进行调试:
void OnGUI()
{
GUI.color = Color.green;
GUI.Label (
new Rect (5, 50, 150, 100),
"Range: "+ _weapon.Range);
GUI.Label (
new Rect (5, 70, 150, 100),
"Strength: "+ _weapon.Strength);
GUI.Label (
new Rect (5, 90, 150, 100),
"Cooldown: "+ _weapon.Cooldown);
GUI.Label (
new Rect (5, 110, 150, 100),
"Firing Rate: " + _weapon.Rate);
GUI.Label (
new Rect (5, 130, 150, 100),
"Weapon Firing: " + _isFiring);
if (mainAttachment && _isDecorated)
GUI.Label (
new Rect (5, 150, 150, 100),
"Main Attachment: " + mainAttachment.name);
if (secondaryAttachment && _isDecorated)
GUI.Label (
new Rect (5, 170, 200, 100),
"Secondary Attachment: " + secondaryAttachment.name);
}
然而,正是在最后一个部分,事情开始变得有趣:
public void ToggleFire() {
_isFiring = !_isFiring;
if (_isFiring)
StartCoroutine(FireWeapon());
}
IEnumerator FireWeapon() {
float firingRate = 1.0f / _weapon.Rate;
while (_isFiring) {
yield return new WaitForSeconds(firingRate);
Debug.Log("fire");
}
}
public void Reset() {
_weapon = new Weapon(weaponConfig);
_isDecorated = !_isDecorated;
}
public void Decorate() {
if (mainAttachment && !secondaryAttachment)
_weapon =
new WeaponDecorator(_weapon, mainAttachment);
if (mainAttachment && secondaryAttachment)
_weapon =
new WeaponDecorator(
new WeaponDecorator(
_weapon, mainAttachment),
secondaryAttachment);
_isDecorated = !_isDecorated;
}
}
}
Reset() 方法通过用默认武器配置初始化一个新的 Weapon 来将武器重置为其初始配置。这是一种快速简单的方法来移除我们在 Decorate() 方法中设置的附件。这并不总是最佳方法,但在本例的上下文中它是有效的。
然而,Decorate() 方法是魔法发生的地方,装饰者模式机制被触发。你可能注意到,我们可以通过在构造函数中链式调用它们来堆叠附件,就像我们在这里看到的那样:
if (mainAttachment && secondaryAttachment)
_weapon =
new WeaponDecorator(
new WeaponDecorator(
_weapon, mainAttachment),
secondaryAttachment);
这是一个装饰者和构造函数允许我们做的的小技巧。当开始实现其他类时,它的工作原理将变得更加明显。
- 下一个要实现的是
Weapon类。请注意,它不是一个MonoBehaviour。因此,我们将使用其构造函数来初始化它:
namespace Chapter.Decorator
{
public class Weapon : IWeapon
{
public float Range
{
get { return _config.Range; }
}
public float Rate
{
get { return _config.Rate; }
}
public float Strength
{
get { return _config.Strength; }
}
public float Cooldown
{
get { return _config.Cooldown; }
}
private readonly WeaponConfig _config;
public Weapon(WeaponConfig weaponConfig)
{
_config = weaponConfig;
}
}
}
与 BikeWeapon 不同,Weapon 类不实现任何行为;它只是武器可配置属性的表示。这是我们将会用附件装饰的对象。在其构造函数中,我们传递一个 WeaponConfig 对象的实例。正如我们稍后将看到的,这是一个 ScriptableObject。
- 我们需要在装饰器类和武器之间有一个公共接口,因此我们将实现一个名为
IWeapon的接口:
namespace Chapter.Decorator
{
public interface IWeapon
{
float Range { get; }
float Duration { get; }
float Strength { get; }
float Cooldown { get; }
}
}
- 现在我们有一个标准接口,它定义了我们能够装饰的一组属性,我们可以实现一个
WeaponDecorator类:
namespace Chapter.Decorator
{
public class WeaponDecorator : IWeapon
{
private readonly IWeapon _decoratedWeapon;
private readonly WeaponAttachment _attachment;
public WeaponDecorator(
IWeapon weapon, WeaponAttachment attachment) {
_attachment = attachment;
_decoratedWeapon = weapon;
}
public float Rate {
get { return _decoratedWeapon.Rate
+ _attachment.Rate; }
}
public float Range {
get { return _decoratedWeapon.Range
+ _attachment.Range; }
}
public float Strength {
get { return _decoratedWeapon.Strength
+ _attachment.Strength; }
}
public float Cooldown
{
get { return _decoratedWeapon.Cooldown
+ _attachment.Cooldown; }
}
}
}
注意 WeaponDecorator 是如何将自己包裹在一个具有相似接口的对象实例周围,在这个例子中,是 IWeapon 接口。它从不直接修改它所包裹的对象;它只是用 WeaponAttachment 的属性装饰其公共属性。
请记住,在我们的代码示例中,我们只是在修改武器的属性值。这与我们的设计相吻合,因为附件不会改变武器的核心机制。相反,它们只是通过附件槽增强特定的属性。
最后一个需要注意的细节是,我们在 BikeWeapon 类中定义了武器的行为,其行为由 Weapon 对象实例中设置的属性决定。因此,通过装饰 Weapon 对象,我们正在修改自行车武器的行为。
- 以下步骤是我们开始偏离传统 Decorator 模式实现的地方。我们不是定义单独的具体装饰器类,而是将实现一个名为
WeaponConfig的ScriptableObject。这种方法将允许我们编写单独的附件并通过检查器配置它们的属性。然后,我们将使用这些附件资产来装饰武器,如下所示:
using UnityEngine;
namespace Chapter.Decorator
{
[CreateAssetMenu(fileName = "NewWeaponAttachment",
menuName = "Weapon/Attachment", order = 1)]
public class WeaponAttachment : ScriptableObject, IWeapon
{
[Range(0, 50)]
[Tooltip("Increase rate of firing per second")]
[SerializeField] public float rate;
[Range(0, 50)]
[Tooltip("Increase weapon range")]
[SerializeField] float range;
[Range(0, 100)]
[Tooltip("Increase weapon strength")]
[SerializeField] public float strength;
[Range(0, -5)]
[Tooltip("Reduce cooldown duration")]
[SerializeField] public float cooldown;
public string attachmentName;
public GameObject attachmentPrefab;
public string attachmentDescription;
public float Rate {
get { return rate; }
}
public float Range {
get { return range; }
}
public float Strength {
get { return strength; }
}
public float Cooldown {
get { return cooldown; }
}
}
}
重要的是要注意,WeaponAttachment 实现了 IWeapon 接口。这使其与 WeaponDecorator 和 Weapon 类保持一致,因为这三个类都共享相同的接口。
- 在最后一步,我们将实现名为
WeaponConfig的ScriptableObject类。我们用它来创建各种配置,以便我们可以用附件来配置我们的Weapon对象:
using UnityEngine;
namespace Chapter.Decorator
{
[CreateAssetMenu(fileName = "NewWeaponConfig",
menuName = "Weapon/Config", order = 1)]
public class WeaponConfig : ScriptableObject, IWeapon
{
[Range(0, 60)]
[Tooltip("Rate of firing per second")]
[SerializeField] private float rate;
[Range(0, 50)]
[Tooltip("Weapon range")]
[SerializeField] private float range;
[Range(0, 100)]
[Tooltip("Weapon strength")]
[SerializeField] private float strength;
[Range(0, 5)]
[Tooltip("Cooldown duration")]
[SerializeField] private float cooldown;
public string weaponName;
public GameObject weaponPrefab;
public string weaponDescription;
public float Rate {
get { return rate; }
}
public float Range {
get { return range; }
}
public float Strength {
get { return strength; }
}
public float Cooldown {
get { return cooldown; }
}
}
}
我们现在拥有了武器附件系统的所有关键成分,现在是时候在 Unity 引擎中测试它们了。
测试武器系统
如果你希望在自己的 Unity 实例中测试我们刚刚审查的代码,你需要遵循以下步骤:
-
将我们刚刚审查的所有类复制到你的 Unity 项目中。
-
创建一个空的 Unity 场景。
-
将一个新的 GameObject 添加到场景中。
-
将以下客户端脚本附加到新的 GameObject:
using UnityEngine;
namespace Chapter.Decorator
{
public class ClientDecorator : MonoBehaviour
{
private BikeWeapon _bikeWeapon;
private bool _isWeaponDecorated;
void Start() {
_bikeWeapon =
(BikeWeapon)
FindObjectOfType(typeof(BikeWeapon));
}
void OnGUI()
{
if (!_isWeaponDecorated)
if (GUILayout.Button("Decorate Weapon")) {
_bikeWeapon.Decorate();
_isWeaponDecorated = !_isWeaponDecorated;
}
if (_isWeaponDecorated)
if (GUILayout.Button("Reset Weapon")) {
_bikeWeapon.Reset();
_isWeaponDecorated = !_isWeaponDecorated;
}
if (GUILayout.Button("Toggle Fire"))
_bikeWeapon.ToggleFire();
}
}
}
-
将我们在上一节中实现的
BikeWeapon脚本添加到新的 GameObject 中。 -
在 Assets | Create | Weapon 菜单选项下,创建一个新的 Config 并按你的意愿进行配置。
-
在 Assets | Create | Weapon 菜单选项下,创建几个具有各种配置的 Attachment 变体。
-
现在,你可以在
Inspector中的BikeWeapon组件属性中添加WeaponConfig和WeaponAttachment资产,如下面的截图所示:

图 12.3 – BikeWeapon 组件属性
当你启动你的场景时,你应该在屏幕的左上角看到以下按钮,如下面的截图所示:

图 12.4 – Unity 中代码示例的实际截图
你现在可以通过按按钮来测试武器附件系统,并根据你的意愿进行调整,同时观察随着装饰器设置的修改,武器统计数据的变化。
检查武器系统
我们的 Decorator 实现是非传统的,因为我们混合了原生 C# 语言特性,如构造函数,同时在尝试利用 Unity API 的最佳部分。通过将装饰器类转换为可配置的 ScriptableObject 资产,我们获得了使我们的武器附件可由非程序员编写和配置的能力。而且,在底层,我们的附件系统建立在稳固的设计模式基础之上。
因此,我们努力在可用性和可维护性之间取得平衡。然而,正如我们将在下一节中看到的,总有其他方法。
设计模式是指导原则,而非命令。因此,尝试不同的模式实现方式是完全可以接受的。本章中的代码示例是实验性的,因为它并不是传统装饰器模式的实现。但我们鼓励读者继续在本书中展示的模式上进行实验,并找到在 Unity 中使用它们的新方法。
审查替代方案
在本章所展示的使用案例的背景下,我们本可以使用装饰器模式以及可脚本化的对象来实现武器系统。我们可以遍历一个已获取的武器附件列表,并将它们的属性应用到Weapon类上。虽然我们会失去链式装饰的能力,但我们的代码将会更加直接。
在我们的案例中,使用装饰器模式的核心好处是它为我们提供了一个结构化和可重复的系统实现方法。然而,作为结果,我们也给我们的代码库增加了额外的复杂性。
摘要
在本章中,我们实现了一个可编写和可配置的武器附件系统。非程序员将能够创建和调整新的附件,而无需编写一行代码。因此,我们可以专注于构建系统,而设计师则可以专注于平衡工作。装饰器模式已被证明是游戏开发中的一个实用模式,因此它应该成为程序员工具箱中的一个模式。
在下一章中,我们将探讨空间分区模式——这是在构建大型地图游戏时理解的一个重要主题。
使用空间分区实现关卡编辑器
本章,我们将探讨空间分区概念。与前面的章节不同,主要主题不是传统上定义为软件设计模式,而更多是一个过程和技术。但由于它为我们提供了一种可重用和结构化的方法来解决重复出现的游戏编程问题,我们将将其视为本章中的设计模式。
本章我们将采取的方法与前面的章节不同,原因如下:
-
我们将采取一种放手的态度;换句话说,我们不会尝试实现代码示例,而是将审查一些代码片段。
-
我们不会试图忠实于任何学术定义,而是将使用空间分区的通用概念来构建我们的赛车游戏关卡编辑器。
空间分区的最简单定义是一个过程,它通过将对象按其位置在数据结构中整理,提供了一种高效定位对象的方法。本章中我们正在实现的关卡编辑器将使用栈数据结构构建,我们将保持特定顺序的栈中的对象类型是赛道段。这些赛道段将根据它们与玩家在地图上的位置的关系以特定的顺序生成或删除。
所有这些都可能听起来非常抽象,但使用 Unity 应用程序编程接口(API)实现这一点相当简单,正如我们将在本章中看到的那样。
本章中我们正在实施的系统过于复杂,无法简化为一个骨架代码示例。因此,与前面的章节不同,这里展示的代码并不是为了复制或用作模板。我们反而建议您查看 Git 项目/FPP文件夹中的完整代码示例,该链接可在本章的技术要求部分找到。
本章,我们将涵盖以下主题:
-
理解空间分区模式
-
设计关卡编辑器
-
实现关卡编辑器
-
审查替代解决方案
第十六章:技术要求
我们还将使用以下特定的 Unity 引擎 API 功能:
-
栈
-
可脚本化对象
如果不熟悉这些概念,请参阅第三章,《Unity 编程简明指南》。
本章的代码文件可以在 GitHub 上找到,链接如下:github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter13。
栈是一种线性数据结构,具有两个主要操作:Push,在栈顶添加一个元素,和Pop,从栈顶移除最近添加的元素。
理解空间分区模式
空间划分模式的名字来源于称为空间划分的过程,它在计算机图形学中起着至关重要的作用,并且常用于光线追踪渲染实现中。这个过程通过将对象存储在空间划分数据结构(如 二叉空间划分(BSP)树)中来组织虚拟场景中的对象;这使得对大量 三维(3D)对象进行几何查询更快。在本章中,我们将使用空间划分的一般概念,而不必忠实于它在计算机图形学中通常的实现方式。
使用以下图表以非常高级和概念化的方式可视化空间划分:

图 13.1 – 一个说明地图上空间划分的图表
第一个例子表示地图上没有进行任何划分的敌人位置。如果我们想快速查找玩家相对于敌人的位置,这可能是一个挑战。当然,我们可以使用射线投射来计算实体之间的距离,但随着敌人数量的增加,这可能会变得低效。
图表的下一部分显示,如果我们划分地图,现在我们可以轻松地可视化敌人相对于玩家位置的集群。在代码中,我们现在可以快速查找哪个敌人离玩家最近,以及它们最大的集群在哪里,因为我们不需要每个敌人的确切位置,只需要它们与玩家的一般地理关系,知道它们在网格的哪个近似单元格中就足够了。
BSP 是一种 3D 编程技术,它通过一系列超平面递归地将空间划分为凸对。该方法使用二叉树数据结构实现。约翰·卡马克(John Carmack)曾著名地使用 BSP 开发过如 Doom 和 Quake 等游戏。
何时使用空间划分模式
3D 编程超出了本书的范围,但空间划分描述的最重要收获是它提供了一种以最佳方式组织场景中大量对象的方法。因此,如果你发现自己需要一种快速查询场景中大量对象的同时跟踪它们的空间关系的方法,请记住空间划分的原则。
在下一节中,我们将回顾关卡编辑器的整体设计并检查其技术要求。
设计关卡编辑器
当在一个多学科游戏开发团队工作时,游戏程序员的职责不仅限于实现酷炫的游戏机制和功能。我们经常被要求构建资产集成管道和编辑工具。在制作周期早期,我们可能需要实现的最常见工具是为我们团队的水平设计师定制的自定义关卡编辑器。
在编写任何代码之前,我们需要记住,我们的游戏在核心游戏系统中没有集成随机性。这是一个技能游戏,玩家的主要目标是通过记住每条赛道的复杂性并尽可能快地到达终点线来达到排行榜的顶端。
因此,基于这些核心设计支柱,我们不能使用像基于特定规则和约束生成随机障碍的程序生成地图这样的解决方案。因此,我们团队的水平设计师将不得不手动设计每条赛道的布局。
这把我们带到了我们最大的挑战:在我们的游戏中,一辆自行车以非常高的速度在 3D 世界中直线行驶。如果我们希望比赛持续超过十几秒,我们将在内存中需要大量的资产,并且我们的设计师将不得不在编辑器中处理编辑大量水平的工作。
这种方法在编辑阶段和运行时都不高效。因此,我们不会将整个赛道作为一个单一实体来管理,而是将其划分为段,每个段可以单独编辑,然后按照特定的顺序组装成一个单一的赛道。
下一个图示说明了这个高级概念:

图 13.2 – 赛道段顺序图
从这个系统中,我们获得了两个关键的好处,如下所述:
-
我们的水平设计师可以通过创建新的段并在各种布局中编排它们来创建新的赛道。
-
我们不需要将整个赛道内容加载到内存中,只需在玩家当前位置的适当时刻生成所需的段。
下一个图示说明了赛道控制器如何使用堆栈数据结构来管理根据玩家当前位置卸载哪些赛道以及生成哪些赛道:

图 13.3 – 段堆叠图
在实施此系统时,我们必须牢记我们游戏中的两个显著特点,如下所述:
-
自行车始终保持在初始位置不动。是赛道段向玩家移动。因此,速度和移动的感觉是通过模拟来实现的,并提供了视觉错觉。
-
玩家只能看到前方。没有后视窗或回望摄像头。这种摄像机视角的限制意味着我们可以在赛道段位于玩家视野之后立即卸载它们。
总结来说,通过一个单一的系统,我们解决了我们项目中的两个潜在的核心问题。首先,我们正在建立一个水平设计流程,最后,我们有一个机制可以动态加载我们的水平,并在设计中嵌入一定的优化。
当设计本书中构建的赛车游戏时,我受到了微型电动玩具赛车的启发。这个玩具的一个独特之处在于你可以以各种配置组装单个轨道段。有时思考新的独特轨道布局比实际驾驶玩具车更有趣。
实现关卡编辑器
在本节中,我们将回顾一些代码,这些代码将实现我们关卡编辑器的核心组件。与前面的章节不同,我们不会尝试使这段代码可运行或可测试。相反,我们将回顾实现,以了解我们如何使用空间划分的一般思想来为设计师构建一个功能性的关卡编辑器,同时优化我们在运行时加载关卡的方式。
下一个部分中展示的代码将进行审查,但不会编译,因为它不是一个完整的自包含示例。
实现关卡编辑器的步骤
- 首先,我们将编写一个名为
Track的ScriptableObject类,如下所示:
using UnityEngine;
using System.Collections.Generic;
namespace Chapter.SpatialPartition
{
[CreateAssetMenu(fileName = "New Track", menuName = "Track")]
public class Track : ScriptableObject
{
[Tooltip("The expected length of segments")]
public float segmentLength;
[Tooltip("Add segments in expected loading order")]
public List<GameObject> segments = new List<GameObject>();
}
}
通过这个ScriptableObject类,我们的关卡设计师将能够通过将段添加到列表中并按特定顺序排列它们来设计新的赛道变体。每个赛道资产都将被送入TrackController类,该类将自动按设计师的顺序生成每个段。
对于玩家来说,这个过程是无缝的,因为它在后台运行,段在它们进入摄像机的视野之前就已经生成。因此,从玩家的角度来看,整个关卡看起来就像已经加载完毕。
- 接下来是
TrackController类。在其中,我们将实现段加载机制,但由于它是一个庞大的类,我们将将其拆分并分部分查看,如下所示:
using UnityEngine;
using System.Linq;
using System.Collections.Generic;
namespace Chapter.SpatialPartition
{
public class TrackController : MonoBehaviour
{
private float _trackSpeed;
private Transform _prevSeg;
private GameObject _trackParent;
private Transform _segParent;
private List<GameObject> _segments;
private Stack<GameObject> _segStack;
private Vector3 _currentPosition = new Vector3(0, 0, 0);
[Tooltip("List of race tracks")]
[SerializeField]
private Track track;
[Tooltip("Initial amount of segment to load at start")]
[SerializeField]
private int initSegAmount;
[Tooltip("Amount of incremental segments to load at run")]
[SerializeField]
private int incrSegAmount;
[Tooltip("Dampen the speed of the track")]
[Range(0.0f, 100.0f)]
[SerializeField]
private float speedDampener;
void Awake()
{
_segments =
Enumerable.Reverse(track.segments).ToList();
}
void Start()
{
InitTrack();
}
第一部分仅仅是初始化代码,是自我解释的,但代码的后续部分则更有趣:
void Update()
{
_segParent.transform.Translate(
Vector3.back * (_trackSpeed * Time.deltaTime));
}
private void InitTrack()
{
Destroy(_trackParent);
_trackParent =
Instantiate(
Resources.Load("Track", typeof(GameObject)))
as GameObject;
if (_trackParent)
_segParent =
_trackParent.transform.Find("Segments");
_prevSeg = null;
_segStack = new Stack<GameObject>(_segments);
LoadSegment(initSegAmount);
}
如我们所见,在Update()循环中,我们将轨道父对象移动到玩家附近以模拟移动。在InitTrack()方法中,我们实例化一个轨道GameObject,它将作为轨道段的容器。但函数中有一行重要的代码是段加载机制的关键组成部分,这里进行了说明:
_segStack = new Stack<GameObject>(_segments);
在这一行,我们将段列表注入到一个新的栈容器中。正如本章开头所提到的,空间划分技术的一个关键部分是将环境对象组织在数据结构中,以便更容易查询。
在下一个代码片段中,我们将看到我们如何使用栈数据结构按正确顺序加载段:
private void LoadSegment(int amount)
{
for (int i = 0; i < amount; i++)
{
if (_segStack.Count > 0)
{
GameObject segment =
Instantiate(
_segStack.Pop(), _segParent.transform);
if (!_prevSeg)
_currentPosition.z = 0;
if (_prevSeg)
_currentPosition.z =
_prevSeg.position.z
+
track.segmentLength;
segment.transform.position = _currentPosition;
segment.AddComponent<Segment>();
segment.GetComponent<Segment>().
trackController = this;
_prevSeg = segment.transform;
}
}
}
public void LoadNextSegment()
{
LoadSegment(incrSegAmount);
}
}
}
LoadSegment()私有方法是系统的核心。它接受一个特定数量的段落作为参数。这个值将决定在调用时它将加载多少个段落。如果有足够的段落剩余在堆栈上,它就会从顶部弹出一个并初始化它,位于之前加载的段落后面。它继续这个循环过程,直到加载了预期的数量。
你可能会问自己:我们如何销毁玩家后面的段落? 我们有多种方法可以计算或检测一个实体是否在另一个实体后面,但就我们的上下文而言,我们将使用一种双重解决方案。每个段落预制体在其边缘加载了一个名为段落标记的实体;这由两个柱子和一个不可见的触发器组成。
一旦自行车通过触发器,段落标记就会删除其父GameObject,正如我们在这里看到的:
using UnityEngine;
public class SegmentMarker : MonoBehaviour
{
private void OnTriggerExit(Collider other)
{
if (other.GetComponent<BikeController>())
Destroy(transform.parent.gameObject);
}
}
当具有BikeController组件的实体从一个段落标记的触发器中退出时,它请求销毁其父GameObject,在这种情况下将是一个Segment实体。
当具有BikeController组件的实体从一个段落标记的触发器中退出时,它请求销毁其父GameObject,在这种情况下将是一个Segment实体。
如TrackController类的LoadSegment()方法所示,每次我们从堆栈顶部弹出一个新的段落时,我们都会将其附加到一个名为Segment的脚本组件上,正如我们在这里看到的:
segment.transform.position = _currentPosition;
segment.AddComponent<Segment>();
segment.GetComponent<Segment>().trackController = this;
因为我们将TrackController类的当前实例传递给其trackController参数,所以Segment对象可以回调TrackController类,并在它被销毁之前请求加载下一个段落序列,正如我们在这里看到的:
using UnityEngine;
public class Segment : MonoBehaviour
{
public TrackController trackController;
private void OnDestroy()
{
if (trackController)
trackController.LoadNextSegments();
}
}
这种方法创建了一个循环机制,在特定间隔自动加载和卸载一定数量的段落。使用这种方法,我们管理在给定时间内场景中生成的实体数量。理论上,这将导致更一致的帧率。
这种方法的另一个好处,它与游戏玩法更相关,是段落标记可以作为检查点系统的地标。检查点通常用于有时间限制的赛车游戏模式中,玩家必须在特定时间内到达赛道上的几个点。
一个基于检查点的赛车游戏的优秀例子是 1987 年的Rad Racer。
使用关卡编辑器
你可以通过在 Git 仓库中打开/FPP文件夹来玩关卡编辑器,然后执行以下操作:
-
在
/Scenes/Gyms文件夹下,你应该找到一个名为Segment的场景。在这个场景中,你可以编辑和创建新的段落预制体。 -
在
Assets-> Create-> Track菜单下,你可以选择创建新的轨道资产。 -
最后,你可以通过在
Scenes/Main文件夹下打开Track场景来修改和附加新的轨道到TrackController类。
随意改进代码,更重要的是,享受乐趣!
审查关卡编辑器实现
本章中的实现是更复杂系统代码的简化版本,但如果您花时间审查 Git 项目 /FPP 文件夹中的高级版本关卡编辑器,我们会看到一些改进,例如以下内容:
-
段:有一个用于段落的创作流程,它使用 ScriptableObjects。
-
对象池:
TrackController类正在使用对象池来优化单个段加载时间。
我没有在章节中包含这些优化,以保持代码示例简短和简单,出于教育目的。
审查替代方案
在实际的生产环境中,如果时间允许,我会以不同的方式构建我们的游戏关卡编辑器。我会设计一个自上而下的轨道编辑器,允许关卡设计师绘制轨道并在其上拖放障碍物。设计师随后可以将他们的作品以序列化格式保存。
然后,使用空间分区原理,TrackController 类会自动将轨道分成若干段,并将它们放入对象池中。这种方法将自动化生成单个段的过程,同时优化生成过程。
因此,设计师无需作为预制件编写单个段,他们可以在编辑器中可视化整个布局的同时设计新的轨道。
当我构建工具和设置集成管道时,我的最终目标始终是自动化。我总是试图通过自动化来摆脱工作,这样我就不会浪费时间在手动任务上。
摘要
在本章中,我们采取了放手的态度,审查了如何使用空间分区模式的大致思想构建基本关卡编辑器。我们的目标不是忠实于模式的标准化定义。相反,我们将其作为构建我们系统的起点。我鼓励您花时间审查 /FPP 文件夹中的代码,并将其重构以使其更好。
在下一章中,我们将审查一些值得了解但具有通用用例的替代模式。因此,与前面的章节相比,用例将具有更广泛的范围,而不仅仅是针对游戏机制或系统。我们将首先解决适配器模式。正如其名称所暗示的,我们将使用它来在两个不兼容的系统之间集成适配器。
第十七章
第三部分:替代模式
在本书的这一部分,我们将回顾一些在编写游戏时我们没有使用的显著模式,因为这些模式是为更通用的用例设计的。了解这些模式对 Unity 开发者来说很有帮助,它们可以帮助解决各种架构问题。
本节包括以下章节:
-
第十四章,使用适配器调整系统
-
第十五章,使用外观模式隐藏复杂性
-
第十六章,使用服务定位器模式管理依赖
使用适配器适配系统
在一个充满各种类型电缆和插头的世界中,我们都已经习惯了适配器的概念。适配器模式将是那些你容易掌握的模式之一,因为它与我们在技术领域的真实世界经验完美相关联。适配器模式的名字完美地揭示了其核心目的;它通过在充当适配器的代码之间添加一个接口,为我们提供了一种无缝使用旧代码与新代码的方法。
本章将涵盖以下主题:
-
理解适配器模式
-
实现适配器模式
本章中展示的示例是骨架代码。为了学习目的而简化,以便我们可以专注于模式的结构。它可能没有足够优化或上下文化,不能直接用于你的项目。
第十八章:技术要求
以下章节是实践性的,因此你需要对 Unity 和 C#有一个基本的了解。
本章的代码文件可以在 GitHub 上找到:github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter14
查看以下视频,看看代码是如何在行动中应用的:
理解适配器模式
如其名所示,适配器模式适配两个不兼容的接口;就像插头适配器一样,它不会修改它调整的内容,而是将一个接口与另一个接口连接起来。当处理无法重构的脆弱遗留代码时,或者当你需要向第三方库添加功能但不想修改它以避免升级时,这种方法可能是有益的。
这里是两种实现适配器模式的主要方法的快速概述:
-
对象适配器:在这个版本中,模式使用对象组合,适配器作为被适配对象的包装器。如果我们有一个没有我们需要的方法的类,但我们不能直接修改它,这很有帮助。对象适配器采用原始类的方 法并将其适配到我们所需要的。
-
类适配器:在这个模式的版本中,适配器使用继承来适配现有类的接口以适应另一个接口。当我们需要调整一个类以便它可以与其他类一起工作,但不能直接修改它时,这很有用。
在我们的代码示例中,我们将使用类适配器的一种变体,因为它更具挑战性。如果我们理解了类适配器版本,那么我们可以推断出对象适配器版本的理解。
让我们看看对象适配器和类适配器的并排图;核心差异微妙,但相似之处明显:

图 14.1 – 重放系统图
正如你所见,在两种情况下,适配器类都位于客户端和适配实体(适配者)之间。但是,类适配器通过继承与适配者建立关系。相比之下,对象适配器使用组合来包装适配者的一个实例以进行适配。
在这两种情况下,我们正在适配的实体没有被修改。客户端也不知道我们在适配什么;它只知道它有一个一致的接口来与适配对象通信。
组合和继承是定义对象之间关系的方法;它们描述了它们如何相互关联。而这种关系结构的不同部分决定了对象适配器和类适配器之间的差异。
另一点需要说明的是,适配器有时会与外观模式混淆。我们必须理解,它们之间的核心区别在于,外观模式为复杂系统建立了一个简化的前端接口。但适配器适配不兼容的系统,同时为客户端保持一致的接口。
这两种模式相关,因为它们都是结构型模式,但具有完全不同的目的。
组合是面向对象编程(OOP)的核心概念之一;它指的是将简单类型组合成更复杂类型的概念。例如,摩托车有轮子、发动机和把手。因此,组合建立了一个“拥有”关系,而继承则建立了一个“是”关系。
适配器模式的好处和缺点
以下是一些适配器模式的好处:
-
无需修改即可适配:适配器模式的主要优点是它提供了一种标准方法来适配旧代码或第三方代码,而无需对其进行修改。
-
可重用性和灵活性:此模式允许在新系统上以最小的更改继续使用旧代码;这具有立即的投资回报。
以下是一些适配器模式的潜在缺点:
-
持久遗产:使用旧代码与新系统兼容的能力是成本效益的,但从长远来看,可能会成为一个问题,因为随着旧代码变得过时且与 Unity 或第三方库的新版本不兼容,它可能会限制你的升级选项。
-
轻微开销:因为,在某些情况下,你需要在对象之间重定向调用,这可能会带来轻微的性能损失,通常太小,不会成为问题。
适配器是结构型模式家族的一部分,包括外观(Facade)、桥接(Bridge)、组合(Composite)、享元(Flyweight)和装饰器(Decorator)。
何时使用适配器模式
在 Unity 中,适配器的一个潜在用例是当你有一个从 Unity Asset Store 下载的第三方库,你需要修改其一些核心类和接口以添加新特性。但是,每次从库所有者那里拉取更新时,更改第三方代码都会带来合并问题。
因此,你发现自己处于一个选择等待第三方库所有者集成所需更改或修改他们的代码并添加缺失特性的情况。这两种选择都有其风险与回报。但适配器模式通过允许我们在现有类之间放置适配器,从而提供了一个解决这个困境的方法,这样它们就可以在不直接修改它们的情况下一起工作。
让我们想象我们正在为一个项目编写代码库,该项目使用的是从 Unity Asset Store 下载的库存系统包。该系统非常出色;它将玩家的购买或赠送的库存项目保存到安全的云后端服务。但有一个问题,它不支持本地磁盘保存。这种限制已经成为我们项目中的一个问题,因为我们需要本地和云保存以实现冗余。
在这种情况下,我们可以轻松地修改供应商的代码并添加我们需要的特性。但是,当它们发布下一个更新时,我们必须将我们的代码与他们的代码合并。这种方法可能是一个容易出错的流程。相反,我们将使用适配器模式并实现一个包装器,它将保持对库存系统的统一接口,同时添加本地保存支持。在这个过程中,我们不需要修改任何现有的类。因此,我们将能够避免更改供应商的代码,同时仍然让您的本地保存系统处理库存项目的保存。
我们将在下一节中实现这个用例的示例。但总的来说,适配器模式在需要相互接口但不希望修改现有代码的不兼容系统的情况下非常有用。
实现适配器模式
代码示例将会很简单;我们不会尝试编写一个完整的本地磁盘保存系统,因为这不是本章的重点。相反,我们将编写系统的框架,以便专注于适配器模式的使用,并且不会因为无关的实现细节而受阻。
实现适配器模式
首先,我们将实现一个占位符类,它将模拟上一节场景中展示的第三方库存系统:
- 我们虚构的提供者提供的
InventorySystem类有三个方法,AddItem()、RemoveItem()和GetInventory()。所有这些方法都是硬编码为使用云存储,我们无法修改它们:
using UnityEngine;
using System.Collections.Generic;
namespace Chapter.Adapter
{
public class InventorySystem
{
public void AddItem(InventoryItem item)
{
Debug.Log(
"Adding item to the cloud");
}
public void RemoveItem(InventoryItem item)
{
Debug.Log(
"Removing item from the cloud");
}
public List<InventoryItem> GetInventory()
{
Debug.Log(
"Returning an inventory list stored in the cloud");
return new List<InventoryItem>();
}
}
}
- 接下来是将在这种情况下充当适配器的类。它增加了将库存项目保存到本地磁盘的能力。但它还暴露了新的功能,允许合并和同步玩家的本地和云库存:
using UnityEngine;
using System.Collections.Generic;
namespace Chapter.Adapter {
public class InventorySystemAdapter:
InventorySystem, IInventorySystem {
private List<InventoryItem> _cloudInventory;
public void SyncInventories() {
var _cloudInventory = GetInventory();
Debug.Log(
"Synchronizing local drive and cloud inventories");
}
public void AddItem(
InventoryItem item, SaveLocation location) {
if (location == SaveLocation.Cloud)
AddItem(item);
if (location == SaveLocation.Local)
Debug.Log("Adding item to local drive");
if (location == SaveLocation.Both)
Debug.Log(
"Adding item to local drive and on the cloud");
}
public void RemoveItem(
InventoryItem item, SaveLocation location) {
Debug.Log(
"Remove item from local/cloud/both");
}
public List<InventoryItem>
GetInventory(SaveLocation location) {
Debug.Log(
"Get inventory from local/cloud/both");
return new List<InventoryItem>();
}
}
}
注意,通过继承第三方的InventorySystem类,我们可以访问其所有属性和方法。因此,我们可以在添加自己的同时继续使用其核心功能。我们在过程中没有修改任何东西,只是在适配它。
- 我们将向我们的新库存系统提供一个接口:
using System.Collections.Generic;
namespace Chapter.Adapter
{
public interface IInventorySystem
{
void SyncInventories();
void AddItem(
InventoryItem item, SaveLocation location);
void RemoveItem(
InventoryItem item, SaveLocation location);
List<InventoryItem> GetInventory(SaveLocation location);
}
}
使用此接口的客户不会意识到它正在与另一个系统进行通信。适配者(Adaptee)也不知道我们在适配它。就像充电适配器一样,手机和电缆不知道它们连接的是哪个插头,只知道电流正在通过系统并为电池充电。
- 为了完成我们的实现,我们需要添加一个
enum来暴露保存位置:
namespace Chapter.Adapter
{
public enum SaveLocation
{
Disk,
Cloud,
Both
}
}
- 对于我们的最后一步,我们将实现一个占位符
InventoryItem类:
using UnityEngine;
namespace Chapter.Adapter
{
[CreateAssetMenu(
fileName = "New Item", menuName = "Inventory")]
public class InventoryItem : ScriptableObject
{
// Placeholder class
}
}
测试适配器模式实现
要测试你在 Unity 实例中的实现,将我们刚刚审查的所有类复制到你的项目中,并将以下客户端类附加到一个新 Unity 场景中的空 GameObject 上:
using UnityEngine;
namespace Chapter.Adapter
{
public class ClientAdapter : MonoBehaviour
{
public InventoryItem item;
private InventorySystem _inventorySystem;
private IInventorySystem _inventorySystemAdapter;
void Start()
{
_inventorySystem = new InventorySystem();
_inventorySystemAdapter = new InventorySystemAdapter();
}
void OnGUI()
{
if (GUILayout.Button("Add item (no adapter)"))
_inventorySystem.AddItem(item);
if (GUILayout.Button("Add item (with adapter)"))
_inventorySystemAdapter.
AddItem(item, SaveLocation.Both);
}
}
}
我们现在可以构建一个新的库存系统,该系统使用由第三方提供的旧功能。我们可以继续从第三方网站自信地拉取库更新,而无需担心合并问题。我们的系统可以在我们继续适配他们的同时增加功能,如果有一天我们想从我们的代码库中移除他们的系统并只使用我们自己的,我们可以通过更新adapter类开始弃用过程。
摘要
在本章中,我们将适配器模式添加到我们的工具箱中。这是一种非常有用的模式,应该放在我们的口袋里。对于专业程序员来说,最大的挑战之一是处理由外部供应商或其他团队在组织内部开发的不可兼容的系统。采用一致的方法来适配现有的类只能是有益的,尤其是在时间成为问题,直接重用旧代码用于新目的更快的时候。
在下一章中,我们将回顾适配器的近亲——外观模式(Facade pattern),我们将使用它来管理代码中的日益增长的复杂性。
使用外观模式隐藏复杂性
外观模式是一个容易理解的模式,因为其名称暗示了其目的。外观模式的主要目的是提供一个简化的前端接口,抽象出复杂系统的复杂内部工作。这种方法对游戏开发有益,因为游戏由各种系统之间的复杂交互组成。作为一个用例,我们将编写代码来模拟车辆引擎核心组件的行为和交互,然后提供一个简单的接口来与整个系统交互。
本章将涵盖以下主题:
-
理解外观模式
-
设计自行车引擎
-
实现自行车引擎
-
使用外观模式的基本车辆引擎实现
由于简单和简洁的原因,本节包括了一个引擎实现的简化版本。此代码示例的完整实现可以在 GitHub 项目的/FPP文件夹中找到——链接可在技术要求部分找到。
第十九章:技术要求
这是一个实践性章节,因此你需要对 Unity 和 C#有基本的了解。
本章的代码文件可以在 GitHub 上找到,链接为github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter15。
观看以下视频,查看代码的实际效果:
理解外观模式
外观模式的名称类似于建筑的立面——正如其名所示,它是一个外部面,隐藏着复杂的内部结构。与建筑结构相反,在软件开发中,外观模式的目标不是美化;相反,它是为了简化。正如我们将在以下图中看到的那样,外观模式的实现通常限于一个类,该类作为一组相互作用的子系统的简化接口:

图 15.1 – 外观模式的统一建模语言(UML)图
如前图所示,EngineFacade充当引擎各个组件的接口,因此当在EngineFacade上调用StartEngine()时,客户端对幕后发生的事情一无所知。它不知道构成引擎的组件以及如何访问它们;它只知道它需要知道的内容。这类似于当你转动汽车点火钥匙时发生的情况——你看不到引擎盖下发生了什么,你也不需要知道;你唯一关心的是引擎是否启动。因此,外观模式在代码中也提供了这种相同级别的抽象,保持系统引擎盖下的细节。
外观模式属于结构模式类别。这个分类中的模式关注于类和对象是如何组合成更大的结构的。
优点和缺点
下面是外观模式的一些优点:
-
简化对复杂代码体的接口:一个坚实的外观类将隐藏复杂性,同时提供一个简化的接口与复杂的系统交互。
-
易于重构:在背后修改组件的同时,由于系统的接口对客户端保持一致,因此隔离在外观背后的代码更容易重构。
下面是一些需要注意的缺点:
-
使其更容易隐藏混乱:使用外观模式在干净的面向用户界面后面隐藏混乱的代码,从长远来看可能会损害模式的核心优势,但这个模式确实提供了一种方法来掩盖一些代码问题,直到你有时间重构它们。然而,期望以后有足够的时间来修复问题是自身的一个陷阱,因为我们很少有时间正确地重构事物。
-
过多的外观:在 Unity 开发者中,全局可访问的管理器类作为核心系统的外观很受欢迎;他们通常通过结合单例和外观模式来实现它们。不幸的是,滥用这种组合很容易,最终导致代码库中包含过多的管理器类,每个类都依赖于其他类才能正常工作。因此,调试、重构和单元测试组件变得非常困难。
外观模式建立了一个新的接口,而适配器模式则适配一个旧接口。因此,在实现看似和听起来相似的模式时,牢记它们在目的上不一定相同是至关重要的。
设计自行车发动机
我们的目标不是为自行车实现一个实际的汽油发动机的完整模拟;这将花费太长时间,并且需要深入了解真实世界发动机的物理和机械原理。但我们将尝试以最小程度模拟高速车辆发动机的一些标准组件。因此,首先,让我们分解我们发动机每个部分的预期行为,如下所示:
-
冷却系统:冷却系统负责确保发动机不过热。当涡轮增压器被激活时,冷却系统在涡轮增压器工作期间关闭。这种行为意味着如果玩家过度使用涡轮增压器,这可能会导致发动机过热,进而导致发动机停止或爆炸,自行车将停止移动。
-
燃油泵:该组件负责管理自行车的燃油消耗。它知道剩余的汽油量,并在汽油耗尽时停止发动机。
-
涡轮增压器:如果涡轮增压器被激活,车辆的极速会增加,但冷却系统会暂时关闭,以便发动机的电路可以传递动力到充电器。
在下一节中,我们将为这些组件中的每一个实现一个骨架类,并建立一个发动机Facade类,以便客户端可以启动和停止发动机。
当涡轮增压器被激活时关闭冷却系统的设计意图是为了创造一种风险与回报的感觉。玩家必须平衡想要更快行驶的愿望与发动机过热的潜在后果。
实现自行车发动机
正如我们将看到的,Facade 模式很简单,所以我们将保持以下代码示例简单直接。首先,我们将为组成自行车发动机的核心组件编写类,如下所示:
- 我们将从燃油泵开始;这个组件的目的是模拟燃油消耗,以便它知道剩余的燃油量,并在燃油耗尽时关闭发动机。以下是所需的代码:
using UnityEngine;
using System.Collections;
namespace Chapter.Facade
{
public class FuelPump : MonoBehaviour
{
public BikeEngine engine;
public IEnumerator burnFuel;
void Start()
{
burnFuel = BurnFuel();
}
IEnumerator BurnFuel()
{
while (true)
{
yield return new WaitForSeconds(1);
engine.fuelAmount -= engine.burnRate;
if (engine.fuelAmount <= 0.0f) {
engine.TurnOff();
yield return 0;
}
}
}
void OnGUI()
{
GUI.color = Color.green;
GUI.Label(
new Rect(100, 40, 500, 20),
"Fuel: " + engine.fuelAmount);
}
}
}
- 接下来是冷却系统,它负责防止发动机过热,但如果涡轮增压器被激活,则会关闭。代码如下所示:
using UnityEngine;
using System.Collections;
namespace Chapter.Facade {
public class CoolingSystem : MonoBehaviour {
public BikeEngine engine;
public IEnumerator coolEngine;
private bool _isPaused;
void Start() {
coolEngine = CoolEngine();
}
public void PauseCooling() {
_isPaused = !_isPaused;
}
public void ResetTemperature() {
engine.currentTemp = 0.0f;
}
IEnumerator CoolEngine() {
while (true) {
yield return new WaitForSeconds(1);
if (!_isPaused) {
if (engine.currentTemp > engine.minTemp)
engine.currentTemp -= engine.tempRate;
if (engine.currentTemp < engine.minTemp)
engine.currentTemp += engine.tempRate;
} else {
engine.currentTemp += engine.tempRate;
}
if (engine.currentTemp > engine.maxTemp)
engine.TurnOff();
}
}
void OnGUI() {
GUI.color = Color.green;
GUI.Label(
new Rect(100, 20, 500, 20),
"Temp: " + engine.currentTemp);
}
}
}
- 最后,当涡轮增压器被激活时,它会增加自行车的极速,但为了使其工作,它需要暂时关闭冷却系统。以下是实现这一功能的代码:
using UnityEngine;
using System.Collections;
namespace Chapter.Facade
{
public class TurboCharger : MonoBehaviour
{
public BikeEngine engine;
private bool _isTurboOn;
private CoolingSystem _coolingSystem;
public void ToggleTurbo(CoolingSystem coolingSystem)
{
_coolingSystem = coolingSystem;
if (!_isTurboOn)
StartCoroutine(TurboCharge());
}
IEnumerator TurboCharge()
{
_isTurboOn = true;
_coolingSystem.PauseCooling();
yield return new WaitForSeconds(engine.turboDuration);
_isTurboOn = false;
_coolingSystem.PauseCooling();
}
void OnGUI()
{
GUI.color = Color.green;
GUI.Label(
new Rect(100, 60, 500, 20),
"Turbo Activated: " + _isTurboOn);
}
}
}
- 现在我们已经准备好了发动机的核心组件,我们需要实现一个类,允许客户端无缝地与之交互。因此,我们将实现一个名为
BikeEngine的 Facade 类,它将为客户端提供一个接口来启动和停止发动机以及切换涡轮增压。
using UnityEngine;
namespace Chapter.Facade
{
public class BikeEngine : MonoBehaviour
{
public float burnRate = 1.0f;
public float fuelAmount = 100.0f;
public float tempRate = 5.0f;
public float minTemp = 50.0f;
public float maxTemp = 65.0f;
public float currentTemp;
public float turboDuration = 2.0f;
private bool _isEngineOn;
private FuelPump _fuelPump;
private TurboCharger _turboCharger;
private CoolingSystem _coolingSystem;
void Awake() {
_fuelPump =
gameObject.AddComponent<FuelPump>();
_turboCharger =
gameObject.AddComponent<TurboCharger>();
_coolingSystem =
gameObject.AddComponent<CoolingSystem>();
}
void Start() {
_fuelPump.engine = this;
_turboCharger.engine = this;
_coolingSystem.engine = this;
}
这个类的第一部分是初始化代码,这部分是自解释的,但以下部分是重要的:
public void TurnOn() {
_isEngineOn = true;
StartCoroutine(_fuelPump.burnFuel);
StartCoroutine(_coolingSystem.coolEngine);
}
public void TurnOff() {
_isEngineOn = false;
_coolingSystem.ResetTemperature();
StopCoroutine(_fuelPump.burnFuel);
StopCoroutine(_coolingSystem.coolEngine);
}
public void ToggleTurbo() {
if (_isEngineOn)
_turboCharger.ToggleTurbo(_coolingSystem);
}
void OnGUI() {
GUI.color = Color.green;
GUI.Label(
new Rect(100, 0, 500, 20),
"Engine Running: " + _isEngineOn);
}
}
}
如我们所见,EngineFacade类公开了自行车发动机提供的可用功能,同时,它也隐藏了其组件之间的交互。如果我们想启动发动机,我们只需要调用StartEngine()方法。如果我们没有像我们刚刚实现的那样有一个 Facade 模式,我们就必须单独初始化每个发动机组件,并知道每个组件的设置参数和要调用的方法。Facade 模式允许我们将所有复杂性隐藏在干净的接口后面。
但假设我们希望添加另一个发动机组件,例如一个硝基喷射器;在这种情况下,我们只需要修改BikeFacade类,并公开一个新的公共方法,以便我们可以触发喷射器。
测试发动机 Facade
我们可以通过向一个空的 Unity 场景中的 GameObject 添加以下客户端脚本来快速测试我们刚刚实现的代码:
using UnityEngine;
namespace Chapter.Facade
{
public class ClientFacade : MonoBehaviour
{
private BikeEngine _bikeEngine;
void Start()
{
_bikeEngine =
gameObject.AddComponent<BikeEngine>();
}
void OnGUI()
{
if (GUILayout.Button("Turn On"))
_bikeEngine.TurnOn();
if (GUILayout.Button("Turn Off"))
_bikeEngine.TurnOff();
if (GUILayout.Button("Toggle Turbo"))
_bikeEngine.ToggleTurbo();
}
}
}
在客户端类中,我们看到它并不了解引擎的内部工作原理,这就是我们使用外观模式时想要达到的效果。客户端类唯一知道的是,它可以调用由BikeEngine类提供的公共方法来启动和停止引擎,以及切换涡轮增压功能。换句话说,就像现实生活中一样,我们不需要打开引擎盖来启动引擎;我们转动点火钥匙,组件就会开始协同工作,而我们不需要知道它们是如何相互作用的。
在下一节中,我们将回顾在决定使用外观模式之前需要考虑的备选解决方案。
在这个代码示例的更高级版本中,引擎将计算当前的每分钟转速(RPM)——也称为引擎速度——并且我们可以将其连接到一个由换挡输入调节的齿轮系统,玩家可以通过这个系统来控制自行车的速度。因此,我们可以在任何时候轻松地提高现实感水平。
审查备选解决方案
在考虑使用外观模式之前,有几个备选方案需要考虑,具体取决于你实际上想要实现什么。这些方案在此列出:
-
抽象工厂模式:如果你只想从客户端代码中隐藏子系统对象的初始化方式,你应该考虑使用抽象工厂模式而不是外观模式。
-
适配器:如果你打算编写一个“包装器”来覆盖现有的类,目的是连接两个不兼容的接口,那么你应该考虑使用适配器模式。
摘要
尽管外观模式有时被用来隐藏混乱的代码,但当你按照预期使用它时,它可以通过隐藏子系统背后的复杂交互,通过单一的前端界面来增强代码库的可读性和可用性。因此,它对于游戏编程来说是一个非常有益的模式,但需要谨慎且带有良好的意图来使用。
在即将到来的章节中,我们将探讨一个名为服务定位器(Service Locator)的模式,我们将使用它来管理全局依赖项并公开核心服务。
使用服务定位器模式管理依赖项
本章将简短,因为我们将要审查的服务定位器模式简单且高效。该模式的核心理念非常直接:它围绕着一个中央注册表,该注册表包含初始化的依赖项。但为了更精确,这些依赖项是提供特定服务的组件,我们可以通过我们称之为“服务合约”的接口来公开这些服务。因此,当客户端需要调用特定的服务时,它不需要知道如何定位和初始化它;它只需要询问服务定位器模式,然后它将完成所有工作以满足服务合约。
正如我们将在本章中看到的,这是一个相当简单的设计,易于实现。
在本章中,我们将涵盖以下主题:
-
理解服务定位器模式
-
实现服务定位器模式
-
查看替代解决方案
为了学习目的,我们简化了本章的代码示例,以展示模式的核心理念,而不被实现细节所分散。因此,显示的代码既未优化,也未充分上下文化,不能直接用于项目。
第二十章:技术要求
以下章节是实践性的,因此您需要对 Unity 和 C#有基本的了解。
我们将使用以下 Unity 特定的引擎和 C#语言概念:
-
静态
-
泛型
如果不熟悉这些概念,请查阅第三章,《Unity 编程简明指南》。
本章的代码文件可以在 GitHub 上找到,地址为github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter16。
查看以下视频以查看代码的实际运行情况:bit.ly/36AKWli
静态是一个关键字修饰符。在类中声明为静态的方法可以在不实例化对象的情况下调用。
理解服务定位器模式
与更传统的模式相比,服务定位器模式背后的学术理论较少,其整体设计非常实用。正如其名称所暗示的,其目的是为客户端定位服务。它通过维护一个提供特定服务的对象的中央注册表来实现这一点。
让我们回顾一个典型服务定位器实现的示意图:

图 16.1 – 服务定位器模式的示意图
如我们所见,我们很容易说服务定位器模式充当了客户端(请求者)和服务提供者之间的代理,这种方法在一定程度上将它们解耦。客户端只有在需要解决依赖并需要访问服务时才需要调用服务定位器模式。我们可以说,服务定位器模式的作用类似于餐厅中的服务员,从客户端接收订单,并在餐厅提供的各种服务与其客户之间充当中间人。
在某些圈子中,服务定位器模式有一个坏名声;专家们经常批评它是一种反模式。这种批评的核心原因是它违反了几个编码最佳实践,因为它隐藏了类依赖关系而不是暴露它们。因此,这可能会使你的代码更难维护和测试。
服务定位器模式的好处和缺点
下面是使用服务定位器模式的一些潜在好处:
-
运行时优化:服务定位器模式可以通过动态检测更优化的库或组件来优化应用程序,以完成特定的服务,这取决于运行时环境。
-
简单性:服务定位器是实现依赖管理模式中最直接的一种,它没有依赖注入(DI)框架那样陡峭的学习曲线。因此,你可以快速在项目中开始使用它,或者教给同事。
使用服务定位器模式的缺点如下:
-
黑盒化:服务定位器模式的注册表模糊了类依赖关系。因此,如果依赖项缺失或注册不正确,一些问题可能会在运行时而不是在编译时出现。
-
全局依赖:如果过度使用且意图不正确,服务定位器模式本身可能会成为一个难以管理的全局依赖。你的代码将过度依赖它,最终,将其从其他核心组件中解耦将变得困难。
服务定位器模式在 Java 开发者中很受欢迎;它在 2004 年马丁·福勒(Martin Fowler)发表的一篇博客文章中被部分定义,你可以在这里阅读:
martinfowler.com/articles/injection.html
何时使用服务定位器模式
关于何时使用服务定位器模式的问题,根据其描述是显而易见的。例如,如果你有一系列需要动态访问的服务,但又想封装获取这些服务的过程,那么这种模式可以提供解决方案。
但在考虑使用服务定位器模式时,我们还应该考虑不使用它的情况。因为服务定位器模式通常全局可访问,正如其名称所暗示的,它应该定位并提供对服务的访问。那么,我们只应该用它来暴露具有全局范围的服务。
例如,我们需要访问抬头显示(HUD)来更新其用户界面(UI)组件之一。我们应该考虑将 HUD 视为一个全局服务,通过服务定位器模式来访问吗?答案应该是否定的,因为 HUD 只在游戏的某些部分出现,并且应该仅在特定上下文中由特定的组件访问。但是,如果我们设计一个自定义的日志系统,我们可以通过服务定位器模式来证明其暴露的合理性,因为我们可能需要在代码的任何地方独立于上下文和范围来记录信息。
既然我们已经了解了理论,让我们动手编写一个服务定位器模式,以提供对日志记录器、分析系统和广告网络(ad network)提供者的访问。
实现服务定位器模式
我们将实现一个基本的服务定位器模式来公开三个特定的服务,如下所示:
-
日志记录器:一个充当集中式日志系统门面的服务
-
分析:一个将自定义分析信息发送到后端以提供对玩家行为的洞察的服务
-
广告:一个从网络拉取视频广告(ads)并将其显示出来以在特定时刻为游戏内容赚钱的服务
我们将这些服务添加到服务定位器模式的注册表中,是因为它们具有以下特点:
-
它们提供特定的服务。
-
它们需要从代码库的任何地方都可以访问。
-
它们可以被模拟或移除,而不会对游戏代码造成任何回归。
正如我们将在以下代码示例中看到的那样,实现基本的服务定位器模式是一个简单的过程。我们将采取以下步骤:
- 让我们从实现最重要的成分——
ServiceLocator类开始,如下所示:
using System;
using System.Collections.Generic;
namespace Chapter.ServiceLocator
{
public static class ServiceLocator
{
private static readonly
IDictionary<Type, object> Services =
new Dictionary<Type, Object>();
public static void RegisterService<T>(T service)
{
if (!Services.ContainsKey(typeof(T)))
{
Services[typeof(T)] = service;
}
else
{
throw new
ApplicationException
("Service already registered");
}
}
public static T GetService<T>()
{
try
{
return (T) Services[typeof(T)];
}
catch
{
throw new
ApplicationException
("Requested service not found.");
}
}
}
}
这种服务定位器模式有三个主要职责,如下所述:
-
它以
Dictionary的形式管理服务注册表。 -
它提供了一个名为
RegisterService()的静态函数,允许一个对象被注册为服务。 -
当通过
GetService()函数请求时,它返回特定类型的service实例。
它必须考虑到RegisterService()和GetService()都是静态函数,因此它们可以直接访问,无需初始化ServiceLocator类。
Services字典包含可用的服务列表,我们将其标记为readonly和private;因此,我们保护它不被覆盖或直接访问。相反,客户端必须通过 Service Locator 模式公开的方法来添加或获取服务。
现在我们有了准备好的 Service Locator 类,我们现在可以开始以接口的形式实现一些服务合同。
- 我们的第一个接口是为
Logger服务,如下面的代码片段所示:
namespace Chapter.ServiceLocator
{
public interface ILoggerService
{
void Log(string message);
}
}
- 下一个接口是为
Analytics服务,正如我们在这里看到的:
namespace Chapter.ServiceLocator
{
public interface IAnalyticsService
{
void SendEvent(string eventName);
}
}
- 最后,我们实现了我们的
Advertisement服务接口的代码,如下所示:
namespace Chapter.ServiceLocator
{
public interface IAdvertisement
{
void DisplayAd();
}
}
- 现在,我们将实现具体的服务类,从
Logger类开始。完成此任务的代码如下所示:
using UnityEngine;
namespace Chapter.ServiceLocator
{
public class Logger: ILoggerService
{
public void Log(string message)
{
Debug.Log("Logged: " + message);
}
}
}
- 接下来是
Analytics类。以下是实现此功能所需的代码:
using UnityEngine;
namespace Chapter.ServiceLocator
{
public class Analytics : IAnalyticsService
{
public void SendEvent(string eventName)
{
Debug.Log("Sent: " + eventName);
}
}
}
- 最后,我们实现了我们的具体
Advertisement服务类,如下所示:
using UnityEngine;
namespace Chapter.ServiceLocator
{
public class Advertisement : IAdvertisement
{
public void DisplayAd()
{
Debug.Log("Displaying video advertisement");
}
}
}
现在,我们有一个可以注册和从任何地方访问服务的 Service Locator 模式。
测试 Service Locator 模式
为了测试我们实现的 Service Locator 模式,让我们编写一个客户端类,我们将将其作为组件附加到一个空 Unity 场景中的 GameObject 上,如下所示:
using UnityEngine;
namespace Chapter.ServiceLocator
{
public class ClientServiceLocator : MonoBehaviour
{
void Start() {
RegisterServices();
}
private void RegisterServices() {
ILoggerService logger = new Logger();
ServiceLocator.RegisterService(logger);
IAnalyticsService analytics = new Analytics();
ServiceLocator.RegisterService(analytics);
IAdvertisement advertisement = new Advertisement();
ServiceLocator.RegisterService(advertisement);
}
void OnGUI()
{
GUILayout.Label("Review output in the console:");
if (GUILayout.Button("Log Event")) {
ILoggerService logger =
ServiceLocator.GetService<ILoggerService>();
logger.Log("Hello World!");
}
if (GUILayout.Button("Send Analytics")) {
IAnalyticsService analytics =
ServiceLocator.GetService<IAnalyticsService>();
analytics.SendEvent("Hello World!");
}
if (GUILayout.Button("Display Advertisement")) {
IAdvertisement advertisement =
ServiceLocator.GetService<IAdvertisement>();
advertisement.DisplayAd();
}
}
}
}
需要注意的是,在实际的 Unity 项目中实现服务定位器模式时,我们应该尽可能早地在游戏的生命周期中注册我们的服务,以确保它们始终可用——例如,这个任务可以分配给我们在项目第一个场景中初始化的GameManager对象。如果我们知道当玩家开始游戏时场景和游戏管理器对象总是会被加载,我们就确信服务定位器模式的注册会在客户端开始请求访问服务之前更新。
我们方法的一个关键好处是我们通过引用它们的接口来注册服务,这意味着在我们注册服务的那一刻,我们可以选择使用哪个具体实现。因此,我们可以在调试构建中轻松地运行每个服务的模拟版本。此外,这种方法将避免在质量保证(QA)阶段向日志和数据分析中添加噪音。
因此,这是该模式的一个酷特性;你可以根据运行时环境动态注入各种服务版本。
使用 Unity 作为你的引擎的主要好处之一是它提供了一系列集成服务,包括广告和数据分析服务,因此大多数时候,你不需要手动实现它们。你可以在以下链接中了解可用的 Unity 服务范围:docs.unity3d.com/Manual/UnityServices.html.
审查替代方案
如果您在代码库中管理依赖项时遇到问题,可能该开始调查使用 DI 框架了。DI 是一种技术,其中对象通过“注入机制”接收它需要的依赖项。对象可以通过几种方式接收其依赖项——通过构造函数、setter 或甚至是一个提供注入方法接口。
以结构化方式开始使用 DI 的最佳方式是通过框架,因为这为您在管理对象之间复杂关系、初始化过程和依赖项的生命周期提供了帮助。总之,当您看到类之间存在紧密耦合,并且它们的依赖项成为编写一致、可测试和可维护代码的瓶颈时,您应该开始考虑使用 DI 框架。
Extenject 是一个免费的 Unity DI 框架,可以从 Asset Store 下载:
assetstore.unity.com/packages/tools/utilities/extenject-dependency-injection-ioc-157735
摘要
在本章中,我们回顾了服务定位器模式。这个模式是解决对象之间依赖服务(功能)管理重复挑战的一个简单解决方案。在其最简单的形式中,服务定位器模式解耦了客户端(请求者)与服务提供者之间的关系。
我们的旅程已经到达终点,因为这是本书的最后一章。我们希望您喜欢每一章的内容。请记住,本书中提出的概念只是入门,不是关于主题的最终结论。关于设计模式、Unity 和游戏开发还有很多东西要学习——多到我们无法在一本书中定义清楚。因此,我们鼓励您继续学习,将每一章中我们一起回顾的内容做得更好,因为总有改进的空间。
关于 Packt
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助您规划个人发展并提升职业生涯。更多信息,请访问我们的网站。
第二十一章:为什么订阅?
-
使用来自超过 4,000 位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
全文可搜索,方便快速获取关键信息
-
复制粘贴,打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,并提供 PDF 和 ePub 文件吗?您可以在 www.packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。
在 www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。


浙公网安备 33010602011771号