Unity-2019-游戏开发模式实用指南-全-

Unity 2019 游戏开发模式实用指南(全)

原文:zh.annas-archive.org/md5/0d8069504d523342a2a766d28f0c7801

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

第一原理,克拉丽斯:简单。阅读马库斯·奥勒留斯,

“对每一件特定的事情,问:它本身是什么?它的本质是什么?”

~汉尼拔·莱克特

这句话来自我最喜欢的电影之一,总结了我的学习方法。在游戏行业工作十多年后,我发现掌握复杂系统的唯一有效方法是将其分解为其最基本的组成部分。换句话说,我试图在掌握最终形式之前理解核心成分。

你会看到,在这本书中,我在呈现每个模式的方式上采取了一种非常简单的方法。目标不是堆砌主题内容,而是通过隔离每个设计模式背后的核心概念来学习,这样我们就可以观察它们并学习它们的复杂性。我在游戏行业作为设计师和程序员工作时学到了这种方法。我们经常在被称为“体育馆”的独立级别中为我们的游戏构建组件和系统。我们会花费数周时间迭代、测试和调整我们游戏中的每个成分,直到我们理解了如何使它们作为一个整体工作。

我以我处理游戏开发的方式写了这本书,这样你作为读者就可以沉浸在主题中,同时培养一些有助于你职业生涯的好习惯。

即使每个章节中包含的代码不是我们所说的生产就绪,它仍然为构建你游戏中的强大系统提供了一个良好的起点。因此,重要的是要记住,本书中的代码不是教条,而是旨在由你,即读者,改进的学习材料。我希望你这样做!

本书面向对象

本书是为初学者和有经验的 Unity 开发者所写。但它也被设计成其他开发环境转向 Unity 的开发者的参考,他们希望了解如何在使用 Unity 的 API 核心功能的同时应用设计模式。

本书涵盖内容

第一章,Unity 引擎架构,解释了 Unity 游戏引擎的核心架构支柱。

第二章,游戏循环和更新方法,回顾了每个游戏程序员都需要理解的两个核心设计模式,即game循环和update方法。

第三章,原型,使用原型模式作为其基础实现了一个生成系统。

第四章,工厂方法,涵盖了工厂模式,它是抽象工厂的近亲。我们将用它来设计一个生成系统以生成非玩家角色。

第五章,抽象工厂,在构建高级生成系统时,涵盖了抽象工厂和工厂方法之间的核心差异。

第六章,单例,回顾了臭名昭著的单例模式,这可能是 Unity 中最广泛使用的模式。

第七章,策略,涵盖了策略模式的基本原理以及如何使用它来实现导弹系统的目标寻找行为集合。

第八章,命令,回顾了命令模式以及如何使用它来构建一个通用控制系统以控制远程设备。

第九章,观察者,解释了观察者模式是什么以及如何在 Unity 中使用 C#正确地使用它。

第十章,状态,涵盖了状态模式的基本原理以及如何使用它来实现围绕太空船的游戏的有限状态。

第十一章,访问者,涵盖了访问者模式的基本原理以及如何使用它来实现一个单臂工厂机器人的模拟。

第十二章,外观,使用外观模式来原型化一个系统,该系统允许玩家在游戏中的进度被保存。

第十三章,适配器,涵盖了适配器模式的基本原理以及如何使用它来调整在线用户管理系统而不修改其代码。

第十四章,装饰者,回顾了装饰者模式的基本原理以及如何使用它来原型化武器定制系统。

第十五章,事件总线,涵盖了事件总线模式的基本原理以及如何使用它来实现一个全局事件驱动消息系统。

第十六章,服务定位器,回顾了服务定位器模式的基本原理以及如何使用它来实现一个在运行时允许注册和定位特定服务的系统。

第十七章,依赖注入,研究了 IoC 容器背后的核心概念以及它们与 DI 的关系。然后我们将看到一个由允许定制赛车游戏超级摩托车初始配置的功能实现引发的依赖问题示例。

第十八章,对象池,回顾了对象池模式的基本原理以及如何使用它来优化僵尸游戏的生成系统。

第十九章,空间分区,回顾了空间分区模式的基本原理以及如何使用它来原型化一个捕食者在环境中猎捕猎物的迷你游戏。

第二十章,反模式,回顾了一系列常见反模式以及如何避免它们。

为了充分利用本书

为了充分利用本书,需要具备 Unity 和 C#的基本功能知识。

下载示例代码文件

您可以从www.packt.com的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册

  2. 选择“支持”标签页。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并按照屏幕上的说明操作。

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX

  • Linux 系统使用 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供使用,地址为github.com/PacktPublishing/。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789349337_ColorImages.pdf

代码实战

访问以下链接查看代码运行的视频:

bit.ly/2Wty3SJ

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“您可以通过以下代码示例看到实现 GameObject 组件引用并调用其public方法的简便性。”

代码块设置如下:

public class Tomahawk : Missile
{
    void Awake()
    {
        this.seekBehavior = new SeekWithGPS();
    }
}

粗体:表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“使用 Drone 或 Sniper 脚本附加到组件上的方式创建两个 GameObject。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至customercare@packtpub.com

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需更多关于 Packt 的信息,请访问 packt.com.

第一部分:基础知识

本节的目标是向读者提供一个 Unity 架构的概述。以下章节包含在本节中:

  • 第一章,Unity 引擎架构

第一章:Unity 引擎架构

我们即将开始一段旅程,这段旅程将教会我们如何在 Unity 引擎的开发环境中使用软件设计模式。本书采用非常实践的方法来学习和应用设计模式。我们将避免陷入模式的学术定义中,而是专注于使用 Unity 的 API 在真正的游戏开发用例中实现它们。对于那些想要深入研究特定模式的理论的人,在每一章的结尾,都会有进一步阅读材料的参考。

但最重要的注意事项是,这本书的重点是简洁而非复杂。这意味着代码示例和用例被设计得尽可能简单,这样我们就可以专注于模式的基本要素,同时避免陷入复杂的实现中。作为读者,我鼓励你获取每一章的源代码,对其进行扩展,然后使其成为你自己的。

然而,在深入一个新游戏引擎并开始使用其 API 进行编码之前,理解其架构是至关重要的。因此,在本章中,我们将回顾 Unity 引擎的核心工程支柱。但首先,对于那些对游戏开发还不太熟悉的人来说,我们将快速讨论大多数游戏引擎共有的核心组件以及它们如何影响我们编写游戏代码的方式。

本章将涵盖以下主题:

  • 引擎架构

  • Unity 的组件系统

  • Unity 的脚本 API

引擎架构

在本节中,我们将回顾游戏引擎背后的基本原理。当然,本书的重点不是掌握引擎架构。尽管如此,在用这个引擎制作游戏之前花时间熟悉其核心架构是明智的。我们不希望后来被那些会破坏我们的设计选择的技术细节所困扰。

什么是游戏引擎?

游戏引擎是推动游戏行业前进的动力,Unity 是这一点的最佳例证。自从其发布以来,游戏工作室的数量以指数级增长。Unity 通过为业余爱好者和专业人士 alike 提供可扩展的开发环境,使制作视频游戏的过程民主化。

但对于那些不熟悉游戏引擎的概念,甚至不知道为什么它们被称为引擎的人来说,我有一个简单的方法来描述它们。看看你汽车的引擎盖下,你看到了什么?电缆、过滤器、管道、电池和齿轮连接在一起,但协同工作以运行车辆。游戏引擎与汽车引擎的概念非常相似,但不同的是,它不是由金属和橡胶制成的,而是纯软件。如果你查看所谓的“引擎盖”,即任何现代游戏引擎的代码库,你将看到数百个系统、工具和组件相互连接并协同工作。

因此,如果你计划制作一款视频游戏,你必须做出的最关键决定是选择哪个引擎,因为这将运行你的游戏。这个单一的选择将影响你生产的各个方面。每个部门都需要调整和改变他们的管道和工作流程。许多游戏因为引擎技术选择不当而被取消或最终成为充满 bug 的灾难。

这也是 Unity 变得如此受欢迎的原因之一,正如其名字所暗示的,它是一个具有统一游戏行业核心意图的引擎。你可以找到 Unity 被用来构建从愤怒的小鸟克隆到史诗般的日本角色扮演游戏JRPGs)的游戏;换句话说,它是跨类型的。通过结合行业中的最佳实践并将它们整合到一个独特但直观的开发环境中,Unity 使其引擎成为行业的基石。

请注意,Unity 是一个闭源代码库。只有 Unity 的合作伙伴才能直接访问引擎的源代码。因此,当我们谈论 Unity 架构的内部运作时,会有一定程度的推测。这就是为什么我们将本章保持在一个非常高级的水平,并没有深入到具体规格。

Unity 的架构

现在是时候处理我们的主要主题了,Unity 及其核心引擎架构支柱。我们必须牢记的一点是,Unity 是一个闭源引擎;这意味着我们必须从其官方文档中推断出我们对它整体架构的心理模型。为了避免深入 Unity 设计中的灰色区域,这些区域难以验证,我们将专注于对我们来说最明显和最有用的支柱。以下是我们需要了解的两个主要核心引擎架构支柱:

  • 组件

  • 脚本 API

组件

Unity 是一个以组件驱动的引擎,我们正是通过组件的组合来构建我们的游戏。如果我们分析以下图表,我们可以看到存在一个高级层次结构,实体包含其他实体。这个结构的基本元素是组件;它们是游戏的基本构建块:

一种可视化这种架构的简单方法是考虑一个场景是一组 GameObject,而 GameObject 是一组可以实现和包含以下内容的组件:

  • 系统(摄像头和物理)

  • 数据(配置、动画和纹理)

  • 行为(游戏机制和脚本事件)

因此,通过这种方法,我们只需更改它所持有的组件,就可以快速将一个表现像摄像机的 GameObject 转换成一个动画角色。这意味着 GameObject 是由组件组成的,并且,根据我们附加到 GameObject 的组件类型,它将将其转换成特定类型的实体,如摄像机、动画角色或粒子。因此,这是一种非常直接和模块化的构建游戏的方法。

在下一节中,我们将回顾 Unity 提供的 API,它允许我们编写这些各种组件。

脚本 API

Unity 的原始设计者明白,如果他们想要制作一个可以被不同技能水平的开发者使用的引擎,他们需要设计一个易于使用但足够灵活的编程环境,以便制作任何类型的游戏。他们通过封装和暴露引擎的核心功能和库,通过一个受管理的脚本 API 实现了这一点。这意味着 Unity 开发者可以专注于编写代码,而无需担心内存管理的复杂性或引擎内部的工作原理。

这种方法甚至在 AAA 内部引擎中也很常见。引擎的核心组件通常使用低级编程语言,如 C++和汇编语言编写,因为需要对内存和处理使用进行精确控制。但负责实现游戏内系统,如 AI 行为或游戏机制的程序员,可以在引擎架构的更高层进行编码。因此,引擎程序员通常会向游戏程序员公开一个 API 或库,以便他们在安全和受控的环境中实现游戏内组件。

游戏程序员通常会通过一种简单的脚本语言,如 LUA,实现另一层抽象,这样设计师就可以在不了解如何编码的情况下编写脚本。一些引擎甚至通过实现一个可视化的脚本环境来进一步简化这种简化方法;一个很好的例子是 Unreal 的蓝图系统。

所有这些抽象层的目标是使构建游戏的过程对各种专业水平的开发者更加容易接近,同时保护引擎免受因代码实现不当而崩溃的影响。换句话说,我们希望避免设计师因为编写了一个在场景中一次性生成一千个敌人角色的脚本而导致引擎崩溃,从而引发内存不足异常。因此,我们希望确保我们提供给使用我们引擎的内容创作者的 API 或脚本库能够帮助他们避免引发可能影响开发环境整体稳定性的关键错误。

下面的图展示了典型 AAA 游戏开发团队的架构层次和责任链。随着我们向上移动链,技术细节变得更加抽象,对内容创作的关注级别更高:

图片

这样做的目的是控制对引擎有限资源的访问,同时向最终用户(通常是设计师和艺术家)暴露核心功能。因此,Unity 的脚本 API 具有类似的目的;其目标是向最终用户(在这种情况下,是开发者)暴露 Unity 的核心功能,同时保护引擎的内部运作。

因此,脚本 API 和组件系统的结合为 Unity 提供了一个非常简单但强大的编码模型。您可以通过以下代码示例看到实现 GameObject 组件引用并调用其public方法是多么容易:

using UnityEngine;

public class PlayerCharacter : MonoBehaviour
{
    public float m_Range = 10.0f;
    public float m_Damage = 12.0f;

    private Weapon m_Weapon;
    private Inventory m_Inventory;

    void Awake()
    {
        m_Weapon = GetComponent<Weapon>();
        m_Inventory = GetComponent<Inventory>();
    }

    void Start ()
    {
        m_Inventory.Loadout("default");
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.UpArrow))
        {
            m_Inventory.EquipNextWeapon();
        }

        if (Input.GetKeyDown(KeyCode.Return))
        {
            m_Weapon.Shoot(m_Range, m_Damage);
        }
    }
}

这种编程游戏的方法简单直接,但也很强大。在这本书中,我们将利用 Unity 的 API 和以组件驱动的架构的灵活性,同时应用经典和现代的软件设计模式,使我们的代码更加健壮。我们的最终目标是构建一套适应 Unity 独特编码模型的模式工具包,以便我们可以用健壮的架构开发游戏。

对于每个行业标准模式或最佳实践,都可能存在相应的反模式或缺点。作为程序员,不仅要记住实现模式的好处,还要记住如果错误地集成到整体架构中,其潜在的风险。

摘要

在本章中,我们开始探索游戏引擎的世界,以及 Unity 的两个核心工程支柱:

  • 组件系统

  • 脚本 API

引擎是非常复杂的软件组件,Unity 有数百个功能,我们在这本书中无法全部涵盖,但如果我们专注于掌握 Unity 的 API,我们将知道在需要时如何访问它们。

在接下来的章节中,我们将专注于架构,但更具体地说,是设计模式。我们将学习如何将经过验证的行业模式和最佳实践适应 Unity 独特的编码模型,同时避免过度工程化的陷阱。在下一章中,我们将回顾游戏编程中最关键的两种概念和模式:游戏循环和更新方法,它们可以被认为是视频游戏的脉搏和耳朵。

进一步阅读

第二部分:序列模式

在本节中,我们将回顾序列模式,这在游戏开发中是非常独特的。Unity 的 MonoBehaviour Update() 魔法方法是实现序列方法的完美示例。以下章节包含在本节中:

  • 第二章,游戏循环和更新方法

第二章:游戏循环和更新方法

在本章中,我们将探讨游戏循环和更新方法。两者都是游戏开发的核心模式,初学者经常将它们混淆,因为它们都可以与连续循环序列的概念相关联。但是,正如我们将要看到的,游戏循环和更新方法可能有关联,但它们具有非常不同的职责,并且在 Unity 架构的不同层上实现。

如果我们要理解游戏循环和更新方法背后的核心原则,那么我们需要考虑视频游戏在其最基本的形式下是什么。我遇到的最佳定义是,视频游戏是由交互式虚拟空间组成的模拟,这些空间充满了物体和动画实体,具有不同的行为。这些虚拟空间是逐帧绘制在屏幕上的,而持续运行的系统会根据用户的输入进行监听和响应。

但是,究竟是什么使得视频游戏能够持续运行模拟并即时响应用户的输入而没有任何延迟?答案是游戏循环的组合,它可以被描述为运行中的游戏的脉搏,但同时也是更新方法,它可以充当系统的耳朵。

但是作为一个 Unity 开发者,你永远不会需要手动编写游戏循环或更新方法,因为它们已经在引擎中本地实现了。所以,在接下来的章节中,我们只是将要探索这些模式背后的理论,这样我们就能在 Unity 工作中意识到它们的存在。

本章将涵盖以下主题:

  • 快速回顾游戏循环和更新方法模式背后的核心概念

技术要求

在本章中,我们将专注于理论而不是实践;对编程的基本理解就足够了。

是什么让游戏运行?

如前所述,视频游戏是模拟;子弹在空中嗡嗡作响,然后在《使命召唤》中击中敌人战斗人员,这是由于一系列系统相互作用,共同营造出一种 3D 圆柱形网格在弯曲矢量上穿越空间时受到重力和空气阻力的影响的错觉。

但是,我们需要回答的问题是,是什么驱动所有这些系统以完美的同步运行?与电子表格或浏览器不同,视频游戏不是事件驱动的;它是持续处理的,即使玩家没有按任何按钮。通过实现游戏循环模式,可以有一个以恒定速率自行循环的系统,同时以完美的同步调用子系统,但仍能动态地响应用户的输入。

因此,在本节中,我们将回顾两个核心概念:主循环的实现和计时的重要性,因为游戏循环的主要目的是模拟时间,而不仅仅是重复执行代码。

你可能会注意到游戏设计师经常谈论核心游戏循环。他们通常指的是我们所说的体验或奖励循环。这个主题超出了本书的范围,但我们可以这样说,游戏几乎在每一个层面上都是由循环组成的。

主要循环

即使在 20 世纪 80 年代初,程序员在没有像 Unity 这样的引擎的帮助下用纯汇编语言编写游戏时,游戏循环的概念就已经存在了。在那个时期,游戏循环的实现需要在时序上非常精确,因为它必须正确地与 CRT 电视电子枪的运动同步,否则屏幕上的图像会扭曲,游戏将变得无响应。

以下是一个为 Atari 2600 编写的游戏循环汇编代码示例。你可以看到主程序周期性地调用一系列子程序。每个子程序在模拟电视屏幕绘制序列的特定阶段运行。在每一步之间,你可以进行计算、捕获玩家的输入或绘制精灵:

Main: 
 jsr VSync ; Beginning of frame
 jsr VBlank ; Wait for electron gun to line up 
 jsr Draw ; Draw sprites, UI and background
 jsr OverScan ; Ending frame
 jmp Loop ; Loop again

现代游戏循环的实现并没有太大差异,它们可能更加复杂,但核心原则和顺序模式是相似的。每个游戏循环都必须在调用渲染管线(也称为绘制循环)之前收集玩家的输入数据,并在场景中的实体上计算新的变换。在不知道事物应该与玩家最新的输入相关联的位置之前,你不能绘制任何东西。

在本节中,我们回顾了游戏循环的主要职责之一是确保在每一周期中按正确顺序调用子程序。换句话说,它是在维护系统调用的连续序列。但在下一节中,我们将回顾游戏循环的另一个重要职责,即保持一定程度的时序一致性。

总要记住,游戏循环和更新方法相关联,但它们并不相同。它们都是顺序模式,但实现方式不同,且责任也不同。

这一切都关乎时机

就像喜剧一样,时机对于游戏编程至关重要;在屏幕上渲染数千个像素的同时,复杂的物理计算在毫秒级内完成。了解每一帧被调用的是什么,对于掌握优化至关重要。

在 Unity 这样的引擎中,核心游戏循环和渲染管线被抽象化,我们只能通过脚本 API 的魔法函数(如:FixedUpdate()Update()LateUpdate())来挂钩其顺序机制。这种方法允许引擎保护其内部时钟和已建立的系统调用序列,同时让我们能够在游戏循环的特定时刻安全地执行代码。

但这种权衡是我们失去了对特定系统更新确切时刻的精确控制。在大多数情况下,这不是问题,但对于大规模 AAA 制作来说,这种限制可能是一个决定性的因素。通常,复杂的 CPU 密集型游戏需要更细粒度的方法来管理精确计算的时机;当有一个滴答机制时,这变得至关重要。而不是依赖于尝试与 CPU 的内部时钟同步,游戏循环有一个类似于模拟手表的滴答机制。像时钟一样,游戏循环不会循环,而是滴答。游戏循环管理滴答之间的时间变化,这取决于可用的操作系统和硬件资源。这种方法允许我们在每一帧之间以更细粒度的控制方式安排特定游戏系统的处理时间。

在游戏行业中,术语滴答经常被互换使用,但请注意,它们并不一定是同义词。我们可以这样说,是基于生成和绘制新帧到屏幕上所需延迟的时间单位。而滴答是与游戏内部时钟相关的时间单位,它通过主游戏循环的执行来模拟;它类似于一个模拟时钟的秒针在时钟上移动时的滴答声。

在前面的章节中,我们考察了游戏循环模式的一个非常高级和简化的概述。当然,现代 AAA 游戏引擎中游戏循环的实际实现细节超出了本书的范围。对于那些想要深入研究主题并对此有更学术性理解的人,我建议阅读进一步阅读部分列出的书籍。

在下一节中,我们将探索 Unity 对更新方法的实现。

更新方法

如果我们都同意游戏循环模式的主要目标是通过实现一个计时器来抽象 CPU 的时钟周期,以便我们可以以一致的方式在每一帧上计时我们的代码执行,那么我们可以说更新方法模式通过提供一种封装我们的游戏实体并让它们在每一帧自行更新的方式来简化这一过程。

在本书的下一节中,我们将回顾更新方法和它在 Unity 引擎中的实现。

这些年来,我发现游戏编程主要是由精确的时间控制来操纵数据。因此,了解如何管理数据和时间是掌握游戏编程的关键。

概述

实现游戏循环的最大挑战之一是跟踪场景中包含的所有实体,以及如何在每一帧更新它们的状态。但更新方法提供了一种可扩展且直接的解决方案,通过让每个对象都暴露一个每帧被调用的Update()函数。

游戏循环并不了解每个对象Update()函数的内容,只知道每个拥有该函数的对象都应该在每一帧被调用。因此,我们基本上通过一个单一接口封装了每个游戏对象状态的更新过程。

挑战

正如我们可以在以下示例中看到的那样,一个基本的游戏循环实现看起来很简单,但正确实现可能会非常复杂:

while (true)
{
    Capture();    // Listen and process the player's input. 
    Update();    // Update the scene entity's positions and states.
    Render();   // Draw the frame.
}

一旦我们捕获了玩家的控制器输入,我们必须在屏幕上绘制游戏对象之前更新游戏对象的变换和状态。但要实现这一点,我们需要知道要更新哪些实体,以及如何请求它们这样做。如果这些实体没有共同的类型或接口,我们就需要逐个管理它们:

Update()
{
    sceneEntities = scene.getEntities();

    for each entity in sceneEntities
    {   
        switch (entity.type)
        {
            case Player:
            MovePlayer()
            break;

            case Camera:
            MoveCamera()
            break;

            // This switch case will get long. 
            // Let's find a better way to do this.
            ..........
    }
}

但我们的问题还没有结束;我们还需要维护一个动态的实体列表,以在它们的整个生命周期中持有每个对象。正如我们所看到的,这种类型的方案对于大型游戏来说扩展性不好。因此,最好的解决方案是让实体在每一帧自行更新。换句话说,让我们让它们封装自己的行为,而提醒它们自行更新的最简单方法是在每一帧调用一个标准的公共函数。

让我们在下一节中看看我们如何通过更新方法来解决所有这些问题。

解决方案

现在我们已经找到了一个提供标准接口给游戏场景实体并将它们封装起来的解决方案,我们仍然需要维护一个列表。游戏是动态的软件;实体在几秒钟内爆炸和生成,手动管理对象列表是容易出错的。

但是,如果我们为所有游戏对象有一个共同的类型,那么我们就可以很容易地动态维护一个实体列表,并在每一帧遍历它。如果我们有一个包含所有游戏对象的容器,比如场景,那么我们可以遍历它以找到特定类型的所有对象,并调用它们的Update()方法。

这基本上就是 Unity 中的MonoBehaviour;它为需要每帧更新自己的场景中的对象提供了一个共同的父类型。因此,任何是MonoBehaviour父类子类的组件都有一个名为Update()的魔法方法,它在每一帧被调用。因此,虽然 Unity 在幕后做了所有繁重的工作,但你可以通过在脚本的Update()方法中编写代码来专注于实现你想要在每一帧触发(更新)的行为。

在下一节中,我们将更深入地探讨 Unity 的更新方法实现。

Unity 的更新方法

作为 Unity 开发者,我们不需要实现自己的更新方法;它是引擎脚本 API 的固有部分。但 Unity 工程师扩展了核心概念,并公开了几种类型的Update()方法;每一种都允许我们在帧的不同时刻执行代码。

以下是在屏幕上渲染帧所需时间内执行步骤的示例:

图片

图表中的每一步都在不到 1/30 秒内完成,并且按照恒定的顺序进行。但 Unity 工程师有远见,知道只有一个 Update() 是不够的,因为特定的系统需要在帧的不同时刻进行处理。因此,他们决定公开三种主要的 Update() 方法,我们将逐一进行回顾。

注意,只有场景中活跃且继承自 MonoBehaviour 父类的对象才会调用它们的各种 Update() 方法:

  • Update(): 此方法以与游戏帧率相同的频率被调用,这可能是不可靠的,但至少是频繁的。在其中,你应该只实现需要与每个渲染帧相对应的代码。由于其更高的调用频率,这是一个实现输入监听器的好地方。

  • LateUpdate(): 此方法在 Update() 之后被调用。它用于需要在 Update() 调用完成后执行的代码。对于依赖于玩家控制的角色的移动的相机移动转换非常有用。

  • FixedUpdate(): 每当物理模拟被触发(更新)时,都会调用此方法。FixedUpdate() 方法的调用时机在每一帧之间提供了稳定的 delta 时间。这种方法对于物理计算和某些类型行为的模拟(如加速运动)是必要的。

    以下部分展示了 Update()FixedUpdate() 之间的间隔:

图片

如我们所见,FixedUpdate() 调用是一致的,而 Update() 随时间变化。

以下是在典型 MonoBehaviour 脚本内部各种更新方法的外观:

void FixedUpdate ()
{

}

void Update ()
{

}

void LateUpdate ()
{

}

最重要的是,我们始终需要意识到我们的代码将在何时执行。Unity 通过抽象引擎的内部触发(更新)机制,并通过 API 中的各种更新方法来简化这项任务。

摘要

在本章中,我们回顾了游戏循环模式及其核心原则。像许多现代引擎一样,Unity 抽象了其核心游戏循环的内部工作,而不是一些 API 钩子,这允许我们在每个帧控制代码的执行时间,而无需手动与 CPU 的内部时钟同步。

我们也简要地提到了包含更新方法模式的核心理念。作为 Unity 程序员,我们不需要手动实现这个模式,因为它已经是脚本 API 的本地功能,但我们仍然需要了解其目的。对 Unity 更新函数的时机和顺序有扎实的理解是至关重要的。即使我们不知道底层发生了什么,我们至少可以控制我们代码的执行顺序。

在下一章中,我们将深入探讨实际的设计模式,并将它们应用于解决现实生活中的游戏架构问题和挑战。我们的第一个主题将是原型模式。

进一步阅读

第三部分:创建型模式

在本节中,我们将通过为地牢探险游戏原型化一个基于网格的生成系统来学习创建型模式。生成概念是众所周知的:事物出现在游戏中——有时是粒子、角色,甚至是臭名昭著的宝箱。换句话说,作为游戏开发者,我们需要尽可能快地将事物显示在屏幕上,而不会降低帧率。有一些设计模式可能有助于完成这项任务。本节包含以下章节:

  • 第三章,原型

  • 第四章,工厂方法

  • 第五章,抽象工厂

  • 第六章,单例

第三章:原型

使用原型模式的目标是帮助建立一个基于原型的对象复制的一致方式。这个原型通常是一个典型对象,我们在应用程序的生命周期中需要多次创建它。为了避免初始化新对象可能带来的性能成本,我们可以使用原型模式来设置一个类似于复印机的系统。通过实现原型模式,我们可以在不降低应用程序整体性能的情况下,即时复制典型对象。换句话说,原型模式是我们编程工具箱中的一个实用工具。

本章将涵盖以下主题:

  • 我们将回顾原型模式的核心理念。

  • 我们将实现一个生成系统,以原型模式作为我们的基础。

技术要求

本章是一个实践章节;你需要对 Unity 和 C#有一个基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 接口

  • 组合

如果你对这些概念不熟悉,请在开始本章之前复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

观看以下视频,以查看代码的实际效果:

bit.ly/2WviTwe

原型模式概述

原型模式被归类为创建型模式,这意味着它的主要职责是优化初始化对象的过程。在 Unity 脚本 API 中,我们通常不使用构造函数;相反,我们将我们的类转换为组件并将它们附加到 GameObject 上。采用这种方法,引擎管理我们的对象初始化序列到内存中。

从理论上讲,对象的初始化开销超出了我们的控制,因为引擎为我们管理这一点。这个说法在某种程度上是正确的,但它没有考虑到场景生命周期中发生的事情。如果我们需要在场景的特定时刻动态加载预制体,引擎将无法防止在将整个实体加载到内存中时帧率突然下降。

预制体是一个由组装好的 GameObject 和组件组成的预制容器。例如,你可以为游戏中每种类型的角色创建一个预制体。预制体易于加载和复制到内存中。它们通常被称为游戏的构建块。

原型模式为这个技术难题提供了一个简单的解决方案;我们不是加载一个新的预制件,而是复制一个已经存在于内存中的。类似于复印机,我们可以从一个单一引用中制作出我们需要的任意数量的副本。这种方法适用于生成预制件和单个组件。

以下 UML 图是一个使用原型模式作为基础的生成系统设计的示例:

正如你所见,原型模式的核心元素是名为 ICopyable 的接口类。正如其名所示,任何实现了 ICopyable 的类都需要能够返回其自身的副本。在先前的图中,Enemy 类实现了 ICopyable 接口。这种关系表明,我们将能够请求 DroneTank 的实例,而无需每次都创建新的实例。

将设计模式与实际系统关联可以帮助你记住特定模式的定义。我个人总是将原型模式与复印机相比较。

优点和缺点

让我们回顾一下与原型模式实现相关的优点和潜在缺点的简短列表。

以下是一些好处:

  • 减少初始化开销:理论上,复制已经存在于内存中的对象比初始化一个新的对象要快。

  • 内存中实例的可重用性:在原型对象从一个状态转移到另一个状态的过程中,可以复制其排列组合。

  • 一致性:让对象自我复制具有结构上的优势;这样做更安全,并且为复制过程提供了一个标准接口。

以下是一些缺点:

  • 维护引用:如果在复制之前我们总是销毁原型对象,那么我们最终会消除使用此模式的所有好处。

  • 不支持和不支持循环引用:在某些情况下,对象的内部结构不支持克隆。在这些情况下,可能很难在实现原型模式的系统中使用这些对象。

在这本书中,我们将避免使用严格的计算机科学术语。对于任何编程概念,总有一个科学和实用的定义。我们将专注于实用的定义,同时仍然考虑模式的理论解释。

用例示例

现在你已经对原型模式有了基本的了解,让我们实际实现一个游戏中的系统,将模式作为我们架构的基础。生成系统是原型模式这类创建型模式的一个完美用例。在正确的时间生成敌人对于设计一个非常沉浸式的游戏体验至关重要。

我们需要避免的最关键的技术问题是敌人生成过程中的帧率下降。这就是为什么我们将使用原型模式;我们将复制特定敌人的现有实例,而不是每次需要生成它们时都创建新的实例。

在下一节中,我们将实现我们在本章开头审查的 UML 图。

代码示例

在本节中,我们将实现一个仅包含无人机和狙击手作为主要敌人类型的游戏的基本生成系统。在此阶段,让我们确保我们的生成系统能够向客户端返回特定敌人类型的副本。

当我们在这本书中使用术语 客户端 时,我们指的是使用模式功能的一个类。在我们的上下文中,它通常是一个 Client 类,它允许我们测试我们的代码示例。

在整本书中,我们将在我们的示例中经常使用接口。它们是面向对象编程中的强大工具。它们提供了一种简单的方式来声明实现合同。参考以下步骤:

  1. 作为第一步,让我们实现一个名为 ICopyable 的接口。我们将公开一个名为 Copy() 的函数:
public interface iCopyable
{
    iCopyable Copy();
}

注意,我们的接口名为 ICopyable;这是为了避免与 C# 的原生接口 ICloneable 混淆,后者用于声明一个类为 可克隆。在我们的示例中,我们不会使用这个 C# 接口。

  1. 现在我们有了我们的接口,让我们在名为 Enemy 的具体类中实现它:
using UnityEngine;

public class Enemy : MonoBehaviour, iCopyable
{
    public iCopyable Copy()
    {
        return Instantiate(this);
    }
}

我们的 Enemy 父类现在能够通过 Copy() 函数返回其自身的克隆实例。正如我们之前提到的,我们没有使用 C# 的原生 ICloneable 接口,因为我们正在通过使用 Unity 的 Instantiate() 函数利用 Unity 的 API。这个 API 函数更适合我们的上下文,因为它可以在克隆过程中持续原生 Unity GameObject 或组件的层次关系。换句话说,当使用 Instantiate() 克隆 GameObject 时,您也在复制(克隆)其子对象。这种方法在 Unity 中至关重要,因为 GameObjects 通常由多个对象和组件组成,以父子结构排列。

  1. 下一步涉及实现我们的两个主要敌人;让我们从 Drone 开始:
public class Drone: Enemy
{
    public void Fly()
    {
        // Implement flying functionality.
    }

    public void Fire()
    {
        // Implement laser fire functionality.
    }
}

如您所见,我们的 Drone 类现在是 Enemy 类的一个子类,并且因为在面向对象的环境中子对象继承其父对象的属性,Drone 类获得了访问 Copy() 函数的权限。这种安排意味着客户端可以通过调用 Copy() 来请求 Drone 的副本。

  1. 现在,让我们为我们的 Sniper 做同样的事情:
public class Sniper : Enemy
{
    public void Shoot()
    {
        // Implement shooting functionality.
    }
}
  1. 现在我们已经将所有具体的 Enemy 类型类写下来,让我们实现我们的 EnemySpawner
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public iCopyable m_Copy;

    public Enemy SpawnEnemy(Enemy prototype)
    {
        m_Copy = prototype.Copy();
        return (Enemy)m_Copy;
    }
}

我们的生成系统相当简单;它通过复制接收到的任何对应于Enemy类型的对象来生成敌人。就像一台复印机;给它正确的文档,它就会复制它。然而,有一个核心区别;我们的EnemySpawner不执行复制。它只是要求它接收到的对象复制自己,然后将复制品返回给客户端。

  1. 为了测试我们的敌人生成系统实现,让我们编写一个Client类:
using UnityEngine;

public class Client : MonoBehaviour
{
    public Drone m_Drone;
    public Sniper m_Sniper;
    public EnemySpawner m_Spawner;

    private Enemy m_Spawn;
    private int m_IncrementorDrone = 0;
    private int m_IncrementorSniper = 0;

    public void Update()
    {
        if (Input.GetKeyDown(KeyCode.D))
        {
            m_Spawn = m_Spawner.SpawnEnemy(m_Drone);

            m_Spawn.name = "Drone_Clone_" + ++m_IncrementorDrone;
            m_Spawn.transform.Translate(Vector3.forward * m_IncrementorDrone * 1.5f);
        }

        if (Input.GetKeyDown(KeyCode.S))
        {
            m_Spawn = m_Spawner.SpawnEnemy(m_Sniper);

            m_Spawn.name = "Sniper_Clone_" + ++m_IncrementorSniper;
            m_Spawn.transform.Translate(Vector3.forward * m_IncrementorSniper * 1.5f);
        }
    }
}

我们的Client类相当简单;根据玩家是否在键盘上按下SD,它将请求EnemySpawner返回一个DroneSniper实例,然后它将把它放在之前生成的实体旁边。

在本书中,我们假设读者具备基本的 Unity 技能,并且已经知道如何设置 GameObject 以及将组件附加到它们上。作为一个快速提醒,为了使此代码示例在 Unity 场景中编译并工作,你需要执行以下操作:

  1. 创建两个 GameObject,并将 Drone 或 Sniper 脚本附加到它们作为组件。

  2. 创建一个带有客户端(脚本)的 GameObject。

  3. 在客户端(脚本)组件的检查器中,将 Drone 和 Sniper GameObject 设置为相应字段中的引用。

以下截图显示了测试我们的代码示例的典型 Unity 场景设置:

图片

本书的相关源代码和 Unity 项目可在 GitHub 仓库github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018中找到。

我们在构建简单的生成系统时成功实现了原型模式。这段代码是开发更高级生成系统的坚实基础。需要记住的最重要的一课是始终考虑在创建对象之前复制它。这种方法是一种直接的优化策略。

摘要

我们这本书的实践部分以一个灵活但简单的模式开始。原型模式背后的整体概念很简单;我们不是初始化新对象,而是仅仅从内存中已有的实例克隆它们。为了在克隆过程中保持一致性,我们封装了对象自我克隆的方式,将这一责任从客户端移除。作为好处,我们可以获得在游戏中生成实体的性能和一致性。

在下一章中,我们将探讨原型模式的一个近亲,即工厂模式。

练习

每次你学习一个新的模式并将其适应到 Unity 中时,你应该验证它是否在使你的代码看起来结构化之外还有益处。与其他领域不同,游戏程序员不仅被他们的编写整洁代码的能力所评判,还在于代码的运行速度。你会发现很多设计模式为了结构的一致性而牺牲了性能。

作为一项练习,我建议你比较使用Instantiate()通过复制内存中现有对象和使用Resource.Load()加载相同对象的现有预制件来使用Instantiate()的性能。

为了完成这个任务,你可以尝试使用 Unity 的本地分析工具。

我建议阅读 Unity 的分析器文档;你可以在本章的“进一步阅读”部分查看链接。经常分析你的代码是一个好习惯,尤其是在尝试任何优化之前。这种方法将帮助你避免花费数小时优化那些甚至不经常执行的代码。

进一步阅读

第四章:工厂方法

工厂方法可能是最著名的设计模式,因为它为大多数软件架构提供了一个坚实的结构基础。这个模式主要有两种变体:

  • 工厂方法

  • 抽象工厂

这两种模式之间的主要区别在于,工厂方法围绕一个单一的工厂方法,而抽象工厂提供了一种封装和组合具有相似主题的工厂的方法。这些描述现在可能听起来很抽象,但我们将分别在单独的章节中实现这两种变体,以便我们更好地理解每种工厂模式的核心区别。

说到个人观点,我对工厂模式的一般问题在于程序员在实现它时往往不够规范,因此有时由于个人风格的原因,一致性会丢失。但正如我们将在整本书中看到的那样,最受欢迎的模式往往在专业程序员实现它们的方式上有显著的改变。我们将尝试采取一种始终与 Unity 的 API 和编码模型兼容的方法。

本章将涵盖以下主题:

  • 工厂方法的概述

  • 在使用工厂方法作为我们架构基础的同时实现非玩家角色NPC)生成系统

工厂模式和原型模式之间的核心区别在于,当您想要委托对象的创建过程时,工厂模式是有用的,而原型模式在创建新实例的成本过高时是一个最优解。

技术要求

本章是实践性的;你需要对 Unity 和 C#有基本的了解才能继续。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 枚举

  • 构成

如果你对这些概念不熟悉,请在开始本章之前复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际应用:

bit.ly/2WvN2vp

工厂方法的概述

工厂模式是那些其名称很好地说明了其核心目的的模式之一。有一个稳健的现实世界相关性可以帮助我们可视化其意图——想象一下你在经销商那里订购一辆新车。在这个过程中,经销商会告诉你新车的制造过程吗?答案很可能是不会;通常,经销商会将你的订单发送到工厂,然后他们把请求的最终产品运回给你。

换句话说,作为产品的消费者,你应该专注于订购和接收,而不是制造和分销。这就是工厂模式的主要目标;它通过提供一个对请求对象制造过程的内部工作抽象的接口,简化了特定类型对象的订购过程。

正如我们在引言中提到的,工厂有两种主要的变化形式,但在这个章节中,我们将只回顾这种模式最简单的形式,即工厂方法。

让我们先回顾一个使用工厂方法的用例的 UML 图:

图片

如我们从这张图中可以看出,NPCFactory 类的 GetNPC() 方法负责获取特定类型的 NPC(乞丐店主农民)。因此,如果客户端请求特定类型的 NPC,它需要请求NPCFactory来生产它。

工厂模式的核心目的是抽象创建并局部化特定类型对象的创建过程。

好处和缺点

工厂方法模式享有极好的声誉,并且通常是可靠代码库的基石。让我们看看工厂方法的一些好处和缺点。

这些是好处:

  • 松散耦合:因为客户端不需要知道特定类型对象的初始化过程的细节,这减少了类之间的耦合和依赖。

  • 创建过程的封装:由于工厂方法负责创建特定类型的对象,你可以将复杂的初始化过程局部化在一个类中。

这些是缺点:

  • 额外的代码复杂性:由于你正在添加抽象和额外的类,代码变得难以阅读。

  • 开放性解释:程序员经常混淆工厂方法及其近亲抽象工厂。这个问题可能会导致不一致的实现,这可能会在同一个代码库上工作的程序员之间引起不确定性和混淆。

有时很难将反复出现的缺点与特定的模式隔离,因为这通常是一个关于上下文的问题。但关于设计模式的一个普遍真理是,如果你误用它们,它们可能会成为你架构中的退步部分。

用例示例

现在我们对工厂方法有了基本的了解,让我们用它来构建一个游戏系统。为了与之前的章节保持一致,我们将实现另一个生成系统,但这次是为 NPC。因为工厂方法是一种创建型模式,它非常适合生成系统。正如我们将在下面的代码示例中看到的,工厂方法是在需要集中初始化各种实体初始化管道时使用的完美模式。

在一个专业的游戏项目中,你可能会不得不为不同实体组构建独立的出生系统。例如,在一个开放世界游戏中,你可能会有一个特定的方法来生成平民和警察,因为每种主要的 AI 角色可能有不同的要求和加载过程。

代码示例

如我们之前提到的,工厂方法是实现工厂模式最直接的方法。正如其名所示,它主要关注通过工厂方法提供创建特定对象类型的标准接口。因此,如果我们有一个Client类需要初始化特定类型的对象(也称为产品),但我们不知道确切的类或调用过程,我们只需引用工厂来生产所需的产品并将其返回给我们。让我们按照以下步骤开始我们的示例:

  1. 让我们使用工厂方法作为基础,为 NPC 构建一个简单的出生系统。但在我们这样做之前,我们需要声明我们的通用 NPC 类型。在代码中,最好的方法是为我们所有的 NPC 角色提供一个标准接口。出于简单性的考虑,我们所有的 NPC 实体都将具有一个共同的功能;它们可以说话脚本对话:
public interface INPC
{
   void Speak();
}
  1. 现在我们有一个名为INPC的标准 NPC 接口,我们需要为可能想要生成的每种 NPC 类型创建具体的类。我们将限制自己到在经典 RPG 游戏中的农场村庄中可能找到的典型角色:
  • 首先是我们的“乞丐”,他们乞求珍贵的硬币:
using UnityEngine;

public class Beggar : INPC
{
    public void Speak()
    {
        Debug.Log("Do you have some change to spare?");
    }
}
  • 然后是我们的“店主”,他们总是准备好卖给我们一些商品:
using UnityEngine;

public class Shopowner : INPC
{
    public void Speak()
    {
        Debug.Log("Do you wish to purchase something?");
    }
}
  • 最后,我们有我们的“农夫”,他们有智慧的话语:
using UnityEngine;

public class Farmer : INPC
{
    public void Speak()
    {
        Debug.Log("You reap what you sow!");
    }
}
  1. 因此,现在我们已经为我们的主要 NPC 类型创建了具体的类,我们需要一种方法在需要时引用它们。让我们编写一个公共的enum,它将易于访问。我们将公开一个可用的 NPC 类型列表:
public enum NPCType
{
    Farmer,
    Beggar,
    Shopowner
}
  1. 下一步是实现我们的NPCFactory类,它有一个公共的工厂方法,将创建请求的 NPC 实例(产品):
using UnityEngine;

public class NPCFactory : MonoBehaviour
{
    public INPC GetNPC(NPCType type)
    {
        switch (type)
        {
            case NPCType.Beggar:
                INPC beggar = new Beggar();
                return beggar;
            case NPCType.Farmer:
                INPC farmer = new Farmer();
                return farmer;
            case NPCType.Shopowner:
                INPC shopowner = new Shopowner();
                return shopowner;
        }
        return null;
    }
}

如我们所见,我们的工厂方法的具体实现是一个名为GetNPC()的函数,它由一个返回指定NPCTypeINPC实例的 switch case 组成。

  1. 但这种设计的优势在我们的客户端中很明显,在这个例子中,将是我们的NPCSpawner类:
using UnityEngine;

public class NPCSpawner : MonoBehaviour
{
    public NPCFactory m_Factory;

    private INPC m_Farmer;
    private INPC m_Beggar;
    private INPC m_Shopowner;

    public void SpawnVillagers()
    {
        /** 
        We don't want to specify the class to instiate for each type 
        of villager.
        Instead, we ask the factory to "manufacture" it for us.
        **/

        m_Beggar = m_Factory.GetNPC(NPCType.Beggar);
        m_Farmer = m_Factory.GetNPC(NPCType.Farmer);
        m_Shopowner = m_Factory.GetNPC(NPCType.Shopowner);

        m_Beggar.Speak();
        m_Farmer.Speak();
        m_Shopowner.Speak();
    }
}
  1. 我们可以使用以下测试类来测试工厂方法实现和NPCSpawner
using UnityEngine;

public class Client : MonoBehaviour
{
    public NPCSpawner m_SpawnerNPC;

    public void Update()
    {
        if (Input.GetKeyDown(KeyCode.S))
        {
            m_SpawnerNPC.SpawnVillagers();
        }
    }
 }

现在,我们能够生成特定 NPC 的实例,而无需知道其位置或具体类的确切名称。当只处理三种基本类型时,这可能看起来并不令人印象深刻,但想象一下,如果每种 NPC 类型都有不同的初始化过程和多个依赖项。

例如,想象一个场景,其中“乞丐”NPC 不是一个角色,而是一个可以附加到我们场景中任何平民角色上的行为组件,而“农夫”NPC 类型是一个自包含的预制件。使用工厂方法,我们不必每次想要生成特定的 NPC 时都记住所有这些规范;相反,我们让工厂方法为我们做脏活,并决定创建这些特定实体的最佳方式。

在实现特定对象家族的通用父类时,在抽象和接口之间进行选择可能会让人感到困惑。在这个例子中,我决定选择接口,因为我不想共享实现——我想声明一个类型组。

摘要

在本章中,我们介绍了工厂模式的核心原则,并实现了其第一种变体,即工厂方法。使用这种模式,我们可以本地化对象的类型创建过程。这可能很好,但如果我们想要复杂的产品,这些产品结合了各种类型的对象,每种对象都有其特定的创建方法呢?我们是否需要分别了解和调用每个工厂,并手动组装它们?

在下一章中,我们将通过实现工厂模式的更高级版本——抽象工厂来解决这个问题的。

当你分析设计模式的有用性时,始终记住它们是为团队合作而设计的。工厂是一个完美的例子。作为一个独立开发者,你可能会发现大多数设计模式都是多余的,但想象一下你在一个大型的代码库上工作,有几十个程序员。在这种情况下,抽象层和通用接口可以帮助你在团队和代码库增长时保持理智。

实践

在上一章中,我们学习了如何使用原型模式作为基础来设计生成系统。因此,我们的系统基本上就像一台复印机;它复制现有的实例。这种机制减少了初始化开销,但这只有在内存中已经有引用可以复制的情况下才有益。

但现在,我们的工具箱中已经有了工厂方法;我们可以将创建特定类型新对象的过程本地化。有趣的是尝试将两者结合起来。工厂方法能否检查该类型对象在内存中是否已经存在一个实例?

结合模式是一个很好的练习,这将为你提供更广泛的方法来处理复杂的实现。

进一步阅读

第五章:抽象工厂

在上一章中,我们探讨了工厂方法(Factory Method),这是工厂模式的一种直接、简单直接的变体。现在,我们将实现工厂模式的一个更高级版本:被良好命名的抽象工厂(Abstract Factory)。这两种工厂模式的主要目标都是封装对象的创建过程。在本章中,我们将专注于区分工厂方法和抽象工厂之间的主要区别,以便我们更好地理解在什么情况下我们可能会选择其中一个而不是另一个。

本章将涵盖以下主题:

  • 抽象工厂的基本原理

  • 使用抽象工厂模式设计 NPC 生成器

解释工厂方法(Factory Method)和抽象工厂(Abstract Factory)之间的核心区别有时会被用作技术面试中的陷阱问题。因此,对这类问题有一个清晰的答案可以给面试官留下深刻印象。

技术要求

本章非常注重实践,因此你需要对 Unity 和 C#有基本的了解。

我们将使用以下 Unity 引擎和 C#语言概念:

  • 枚举

如果你对这些概念不熟悉,请在开始本章之前复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频,以查看代码的实际应用:

bit.ly/2HKybdy

抽象工厂概述

抽象工厂通常在学术文档中被过度复杂化地解释,但如果你将其简化到基本形式,其设计和意图相当简单。抽象工厂的主要目的是将产品的制造过程(对象)组织成相关的组。这种方法使我们能够管理生产特定家族产品的工厂(对象)。换句话说,我们能够为特定类别的产品(对象)的创建过程添加抽象层,以及特定的单个类型。

在以下图中,我们可以看到抽象工厂的基本结构以视觉方式描述:

注意以下该模式的成员:

  • FactoryProducer 负责返回特定类别(家族)的产品(人类或动物)的单独工厂。

  • HumanFactoryAnimalFactory 负责创建人类或动物产品

当你处理创建产品目录时,每个组都有其独特的制造规范,使用抽象工厂的好处是显而易见的。

当描述工厂模式时,我们经常使用现实世界的术语,如产品制造生产者。找到现实生活概念和计算机科学术语之间的相关性总是一个明智的方法,因为它有助于识别和记住它们的核心目的。

好处和缺点

抽象工厂的好处和缺点与工厂方法非常相似,所以在这个章节中不需要重复。但有一个显著的好处,这是两种模式的核心区别,我们需要解决。虽然工厂方法侧重于提供一个方法,允许我们请求创建特定类型的对象,但抽象工厂则更进一步,它为我们提供了一种管理特定组对象创建的方式。

这种区别一开始可能看起来并不重要,但如果考虑现实世界的类比,比如制造汽车的过程,我们可以看到为什么抽象工厂是有优势的。典型的汽车是由单独制造的组件组装而成,但发动机和轮胎的制造过程完全不同,因此需要单独的工厂来创建这些最终产品的关键部件。这就是为什么抽象工厂在软件开发中是有益的,因为它为我们提供了一种类似的方法来构建和组织我们生产最终复杂产品的方式,该产品具有多个组件和不同的对象创建过程。

用例示例

我们将扩展第四章中的用例“工厂方法”,通过添加一个新的可生成 NPC 的类型,称为“动物”。因此,在我们的例子中,人类和动物被认为是不可玩的角色,但它们有独立的制造过程,所以它们将需要单独的工厂。这种需求可以通过抽象工厂轻松实现。

代码示例

我们的代码示例几乎与我们在第四章中完成的那个相同,工厂方法。但我们将通过包括特定的 NPC 系列来增加系统的深度;在我们的情况下,人类和动物。

  1. 抽象工厂的一个关键元素是每个产品系列都有一个相关的工厂:
using UnityEngine;

public class FactoryProducer : MonoBehaviour
{
    public static AbstractFactory GetFactory(FactoryType factoryType)
    {
        switch (factoryType)
        {
            case FactoryType.Human:
                AbstractFactory humanFactory = new HumanFactory();
                return humanFactory;
            case FactoryType.Animal:
                AbstractFactory animalFactory = new AnimalFactory();
                return animalFactory;
        }
            return null;
    }
}

注意到这个类是按照工厂方法模式实现的,因为我们使用简单的switch case 来根据请求的类型返回正确的Factory给客户端。

  1. 现在,我们需要一个抽象类来保持每个特定产品Factory实现的连贯性:
public abstract class AbstractFactory
{
    public abstract IHuman GetHuman(HumanType humanType);
    public abstract IAnimal GetAnimal(AnimalType animalType);
}
  1. 接下来是我们的第一个具体产品工厂,HumanFactory
public class HumanFactory : AbstractFactory
{
    public override IHuman GetHuman(HumanType humanType)
    {
        switch (humanType)
        {
            case HumanType.Beggar:
                IHuman beggar = new Beggar();
                return beggar;
            case HumanType.Farmer:
                IHuman farmer = new Farmer();
                return farmer;
            case HumanType.Shopowner:
                IHuman shopowner = new Shopowner();
                return shopowner;
        }
        return null;
    }

    public override IAnimal GetAnimal(AnimalType animalType)
    {
        return null;
    }
}
  1. 现在,AnimalFactory,它将生产猫和狗:
public class AnimalFactory : AbstractFactory
{
    public override IAnimal GetAnimal(AnimalType animalType)
    {
        switch (animalType)
        {
            case AnimalType.Cat:
                IAnimal cat = new Cat();
                return cat;
            case AnimalType.Dog:
                IAnimal dog = new Dog();
                return dog;
        }
        return null;
    }

    public override IHuman GetHuman(HumanType humanType)
    {
        return null;
    }
}

注意到这两个类都实现了对方的GetAnimal()GetHuman()函数,但根据上下文返回null。这种方法是在客户端在请求特定类型的 NPC 时引用了错误的工厂时使用的;而不是抛出异常,它将收到一个null

  1. 我们将不再在switch类型的条件块中使用字符串,我们将为每个支持的产品类型实现枚举,包括相关的工厂,如下所示。这种方法将避免错误并保持一致性:
  • FactoryType:
public enum FactoryType
{
    Human,
    Animal
}
  • HumanType:
public enum HumanType
{
    Farmer,
    Beggar,
    Shopowner
}
  • AnimalType:
public enum AnimalType
{
    Dog,
    Cat
}
  1. 我们的食物不会说话,但人类会说话,所以它们不能共享一个标准接口。在这种情况下,我们将为每种类型实现一个,如下所示:
  • IHuman:
public interface IHuman
{
    void Speak();
}
  • IAnimal:
public interface IAnimal
{
    void Voice();
}
  1. 现在,我们需要编写所有人类和动物 NPC 的每个具体类,如下所示:
  • Beggar:
using UnityEngine;

public class Beggar : IHuman
{
    public void Speak()
    {
        Debug.Log("Beggar: Do you have some change to spare?");
    }
}
  • Farmer:
using UnityEngine;

public class Farmer : IHuman
{
    public void Speak()
    {
            Debug.Log("Farmer: You reap what you sow!");
    }
}
  • Shopowner:
using UnityEngine;

public class Shopowner : IHuman
{
    public void Speak()
    {
        Debug.Log("Shopowner: Do you wish to purchase something?");
    }
}
  • Dog:
using UnityEngine;

public class Dog : IAnimal
{
    public void Voice()
    {
        Debug.Log("Dog: Woof!");
    }
}
  • Cat:
public class Cat : IAnimal
{
    public void Voice()
    {
        Debug.Log("Cat: Meow!");
    }
}
  1. 最后,我们可以扩展我们的NPCSpawner类以支持生成动物和人类 NPC:
public class NPCSpawner : MonoBehaviour
{
    private IAnimal m_Cat;
    private IAnimal m_Dog;

    private IHuman m_Farmer;
    private IHuman m_Beggar;
    private IHuman m_Shopowner;

    private AbstractFactory factory;

    public void SpawnAnimals()
    {
        factory = FactoryProducer.GetFactory(FactoryType.Animal);

        m_Cat = factory.GetAnimal(AnimalType.Cat);
        m_Dog = factory.GetAnimal(AnimalType.Dog);

        m_Cat.Voice();
        m_Dog.Voice();
    }

    public void SpawnHumans()
    {
        factory = FactoryProducer.GetFactory(FactoryType.Human);

        m_Beggar = factory.GetHuman(HumanType.Beggar);
        m_Farmer = factory.GetHuman(HumanType.Farmer);
        m_Shopowner = factory.GetHuman(HumanType.Shopowner);

        m_Beggar.Speak();
        m_Farmer.Speak();
        m_Shopowner.Speak();
    }
}
  1. 作为我们概念的证明,我们的Client类可以从我们的Spawner请求AnimalHuman NPC,而无需了解最终产品的创建过程:
public class Client : MonoBehaviour
{
    public NPCSpawner m_SpawnerNPC;

    public void Update()
    {
        if (Input.GetKeyDown(KeyCode.U))
        {
            m_SpawnerNPC.SpawnHumans();
        }

        if (Input.GetKeyDown(KeyCode.A))
        {
            m_SpawnerNPC.SpawnAnimals();
        }
    }
}

如你所见,抽象工厂比其表亲工厂方法提供了更多的灵活性。我们现在可以管理产品系列,并为制造过程添加更多抽象层。

摘要

在本章中,我们学习了抽象工厂,它是工厂方法的近亲。正如其名称所暗示的,抽象工厂允许我们在特定类型产品的制造过程中添加抽象层。当处理多个产品系列时,这种模式非常有用。与工厂方法相比,抽象工厂的主要缺点是它更冗长且复杂。

在下一章中,我们将探讨可能是所有设计模式中最著名的一个:单例。

在学习工厂模式的过程中,你可能会注意到我们使用了与传统制造过程相关的几个术语。制造业和软件产业密切相关。与工厂管理相关的最佳实践启发了 DevOps 和看板背后的几个核心思想,这些思想现在是稳健软件开发流程的基石。

实践练习

在本书的这一部分,我们已经回顾了工厂模式中最流行的两种形式:抽象工厂和工厂方法。然而,作为一个练习,我建议通过实现第三种形式来扩展你对工厂的了解,例如,静态工厂方法。

你可以在 Joshua Bloch 的经典著作《Effective Java》中了解静态工厂方法模式。更多信息可以在“进一步阅读”部分找到。

进一步阅读

第六章:Singleton

Singleton 是行业中最臭名昭著的模式,但讽刺的是,它在 Unity 开发者中非常受欢迎。因此,它已经变成了程序员的胶带,被过度用作快速修复而不是稳健架构的基石。实现 Singleton 有许多方法,从简单但不安全到复杂但稳健;我们将选择后者,因为如果我们需要实现不受欢迎的模式,让我们以一种不会对我们产生反作用的方式去做。

本章将涵盖以下主题:

  • Singleton 模式的基础

  • 在 Unity 中实现完美的 Singleton

程序员喜欢争论,有时甚至到了瘫痪的地步,因此永远不要对设计模式过于虔诚。始终记住,没有完美的解决方案;你做出的每一个设计决策都会有权衡。

技术要求

这是一个实践性章节;你需要对 Unity 和 C# 有基本的了解。

我们将使用以下特定的 Unity 引擎和 C# 语言概念:

  • 泛型

如果不熟悉这个概念,请在开始本章之前查看它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际应用:

bit.ly/2YwBVEv

Singleton 模式的概述

如其名所示,Singleton 模式的主要目标是保证唯一性。这种方法意味着如果一个类正确实现了这个模式,一旦初始化,它将在运行时内存中只有一个自身的实例。这种机制在需要从单一入口点全局访问系统时非常有用。

如下图中所示,Singleton 的设计相当简单;与原型模式不同,作为 Singleton 实现的类不会复制自身,而是仅将其当前实例返回给请求它的客户端:

图片

如果正确实现,Singleton 实例甚至可以销毁任何其他自身的实例,以防有人试图复制它。换句话说,只能有一个。但我们将在本章中进一步看到,在 Unity 中实现一个稳固的 Singleton 并不像看起来那么简单。

优点和缺点

Singleton 是一个非常具有争议的模式;许多人不喜欢它,因为 Unity 开发者经常误用它。如果对这种模式的蔑视是合理的,我会说是的,但只到一定程度。而不是列出这个模式的所有潜在优点和缺点,我将只提出一个优点和缺点;两者都是我认为支持或反对使用这个模式的最有力的论据。

这是优点:

  • 唯一的入口点:Singleton 提供了一个唯一的但全局的访问点,用于访问其自身的实例。这种机制使得访问由 Singleton 实例暴露的依赖项变得更加容易。

这是缺点:

  • 依赖项的混淆:Singleton 通常被用作“胶带”来简化对复杂互锁依赖项的访问。这是一个简单的解决方案,可以防止错误的架构选择被清除。

因此,考虑到这两个论点,当我们决定使用 Singleton 时,我们必须问一个简单的问题;这是因为它需要并且与我们的整体架构相匹配,还是我们使用它是因为它是一个解决复杂问题的快速解决方案?从我们的答案中,我们可以确定我们在设计选择上是聪明还是懒惰。

当你在做设计选择时,始终牢记你的架构是否可维护、可扩展和可测试非常重要。如果你不能单独测试你的模块,那么这可能是一个很好的迹象,表明你的设计已经使你的代码库耦合并依赖于全局依赖项。

用例示例

我们必须考虑,大多数游戏都是由一系列关卡组成的,但每个关卡在其生命周期内都包含一系列事件,如下所示:

  • 加载上一个保存

  • 触发引导序列

  • 环境和角色的生成

  • 管理运行时游戏状态

  • 触发结束场景序列

  • 保存当前玩家状态

  • 触发下一级

为了能够管理这一点,我们需要一个在整个场景生命周期中保持活跃的游戏管理器。作为一个类比,如果我们看看经典的纸笔版《龙与地下城》,通常有一个游戏大师来调节和监督游戏的流程,以便玩家能够有一个一致但结构化的体验。

因此,对于我们的用例,我们需要类似的东西,但当然,不如人类游戏大师那样复杂。Singleton 是一个完美的模式来实现 GM,因为它为我们提供了一种将类编写为单一但全局实体的方式,该实体将在我们游戏的整个运行时中可访问。

代码示例

在本节中,我们将探讨 Unity 中 Singleton 模式实现的两种版本。第一个例子是不安全的但直接的。第二个例子是高级的但更健壮的,如本章开头所述。

简单方法

让我们先回顾一下在 Unity 中实现 Singleton 的简单方法。我们必须记住,当我们使用MonoBehaviours时,我们没有访问构造函数,因此我们需要在Awake()魔法函数中控制任何成员变量的初始化,如下所示:

using UnityEngine;

public class GameManager: MonoBehaviour 
{
    public static GameManager instance;

    void Awake()
    {
        instance = this;
    }

    public void InitializeScene()
    {
        // Load persistent game state variables from the save system.
        // Load game systems and dependencies.
    }
}

如我们所见,我们只有一个成员,它是静态且公开的,这将使我们的客户端更容易引用它。在我们的Awake()方法中,我们将当前的this实例传递给公开的静态实例变量。这种方法意味着我们的客户端将有一个恒定且持久的访问点来访问我们的GameManager,如下面的代码片段所示:

using UnityEngine;

public class Client: MonoBehaviour
{
    void Start()
    {
        GameManager.instance.InitializeScene();
    }
}

它看起来很简单。我们需要引用GameManager类的静态实例成员,并且我们可以在任何点调用其公共函数。但有一个巨大的问题:这不是一个单例,因为没有机制可以防止在内存中有两个该对象的实例。

我们只是实现了一个全局管理器实例的接口,但我们没有保护它免受内存中重复或保持其完整性的影响。让我们看看在下一个示例中我们是否能做得更好:

using UnityEngine;

public class GameManager: MonoBehaviour 
{
    public static GameManager _instance;

    void Awake()
    {
        if (_instance == null)
        {
            // Assigning only if there's no other instances in memory.
            _instance = this; 
        }
        else if (_instance != null)
        {
            // Destroying itself if detects duplication.
            Destroy(gameObject) 
        }
    }
}

现在,这已经变得更好了。我们至少在分配_instance静态成员之前检查null引用,并通过在它们awake时销毁它们来避免GameManager的潜在重复实例。

这种方法看起来是有效的,但如果决定有多个类实现为单例,则没有任何机制可以保证一致性。你可能会有一位程序员以一种方式编写单例,而另一位则以完全不同的方式编写。从长远来看,这抵消了设计模式的基本好处之一;架构的一致性。

在下一节中,我们将探讨一个高级单例实现的潜在候选者,它可以成为我们代码库的支柱,并为我们提供完全的可重用性。

高级方法

以下类是一个完整的单例实现的示例,但这里有很多东西需要解释,因此我们将尝试关注以下基本元素:

using UnityEngine;

// <T> can be any type.
public class Singleton<T> : MonoBehaviour where T : Component
{
    // The instance is accessible only by the getter.
    private static T m_Instance; 
    public static bool m_isQuitting;

    public static T Instance
    {
        get
        {
            if (m_Instance == null)
            {
                // Making sure that there's not other instances 
                // of the same type in memory. 
                m_Instance = FindObjectOfType<T>();

                if (m_Instance == null)
                {
                    // Making sure that there's not other instances 
                    // of the same type in memory.
                    GameObject obj = new GameObject();
                    obj.name = typeof(T).Name;
                    m_Instance = obj.AddComponent<T>();
                }
            }
            return m_Instance;
        }
    }

    // Virtual Awake() that can be overridden in a derived class.
    public virtual void Awake()
    {
        if (m_Instance == null)
        {
            // If null, this instance is now the Singleton instance 
            // of the assigned type. 
            m_Instance = this as T;

            // Making sure that my Singleton instance 
            // will persist in memory across every scene.
            DontDestroyOnLoad(this.gameObject);
        }
        else
        {
            // Destroy current instance because it must be a duplicate.
            Destroy(gameObject);
        }
    }
}

在这个例子中,我们介绍了泛型,这是一个令人信服的 C#特性,允许我们在运行时延迟类的类型。当我们说一个类是泛型时,这意味着它没有定义的对象类型。这种方法的优势在于,当我们初始化它时,我们可以将其分配为特定的类型。换句话说,它可以成为我们想要的任何东西,这可以解决我们与单例相关的核心问题,即我们的单例类之间实现的一致性。

让我们将我们的通用单例类应用到几个管理器上,看看我们如何保持编写单例类的方式的一致性,如下所示:

using UnityEngine;

// Inheriting Singleton and specifying the type.
public class GameManager : Singleton<GameManager>
{
    public void InitializeGame()
    {
        Debug.Log("Initializing the game.");
    }
}

如我们所见,通过继承单例父类,我们只需一行代码(Singleton<GameManager>)就将GameManager变成了单例。这种机制之所以可行,是因为我们的父类拥有单例的核心组件。

接下来是一个将Manager类转换为单例模式的示例,只需一行代码即可实现:

using UnityEngine;

// Inheriting the Singleton and specifying it's type.
public class InventoryManager : Singleton<InventoryManager> 
{
    public void AddItem(int itemID)
    {
        Debug.Log("Adding item to the inventory.");
    }

    public void RemoveItem(int itemID)
    {
        Debug.Log("Removing item to the inventory.");
    }
}

我们可以用以下Client类来测试我们新的单例:

using UnityEngine;

public class Client : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.I))
        {
            GameManager.Instance.InitializeGame();
        }

        if (Input.GetKeyDown(KeyCode.A))
        {
            InventoryManager.Instance.AddItem(001);
        }

        if (Input.GetKeyDown(KeyCode.R))
        {
            InventoryManager.Instance.RemoveItem(023);
        }
    }
}

现在我们已经找到了实现单例模式的结构化和可重用方法,我们可以在不过度使用的情况下安全地将它集成到我们的代码库中。

摘要

在本章中,我们解决了最具有争议性的设计模式之一。但我们找到了一种以一致和可重用的方法来实现它的方法。即使关于单例有用性的争论持续存在,我们也可以看到它对 Unity 开发者是有益的。

我们已经完成了创建型模式章节,现在我们的工具箱中有三个核心模式,每个模式都有其特定的功能:

  • 原型模式为我们提供了一种通过复制引用来创建对象的方法

  • 抽象模式强制执行对象的创建过程的本地化

  • 单例提供了一种实现机制,确保在内存中只有一个对象的唯一实例

在下一章中,我们将从创建型模式过渡到行为型模式。我们列表中的第一个是策略模式,这是一个经典的模式,它关注于在运行时动态选择算法。

练习

单例模式的主要问题在于其实例是全局可访问且持久的,因此如果任何与单例对象相关的依赖组件,它就不能作为一个独立的单元进行测试。但在现实世界中,代码库从不完美,程序员经常使用单例。

因此,即使在与高度依赖全局单例实例的架构打交道时,你也必须找到一种方法来维护适当的单元测试最佳实践。所以,作为一个练习,我建议你阅读有关测试驱动开发TDD)实践的内容,特别是像存根模拟这样的核心概念。

TDD 超出了本书的范围,所以请参考进一步阅读部分以获取更多关于该主题的信息。

进一步阅读

第四部分:行为模式

在本节中,我们将重点介绍如何使用专注于管理状态和行为的模式来最佳地实现 AI 和 UI 系统及组件。有了这些知识,我们将搭建起开发各种类型游戏的基本构建模块,包括实时策略RTS)游戏。

本节包含以下章节:

  • 第七章,策略

  • 第八章,命令

  • 第九章,观察者

  • 第十章,状态

  • 第十一章,访问者

第七章:策略

策略是那些其名称并不明确表示其意图的模式之一。这种不确定性可能会使其难以理解和记忆其目的。但策略模式相当简单:它提供了一种在运行时动态选择算法并将它们分配给对象的方法。我们可以想象策略模式就像一个大师棋手,分析棋盘并根据上下文选择最佳策略。

本章将涵盖以下主题:

  • 策略模式的基础

  • 使用策略模式实现一系列导弹的寻的行为的实现

技术要求

本章是一个实践性章节,因此你需要对 Unity 和 C#有一个基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 组合优于继承

如果不熟悉这个概念,请在开始本章之前复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际运行情况:

bit.ly/2UlVjVG

策略模式概述

策略模式的根本目标是将选择使用哪种算法的决定推迟到运行时。这种方法使得实现逻辑和行为的代码段更加灵活和可重用。这个想法可能听起来非常复杂,但它是一个简单的机制,这是由于面向对象编程中的组合概念所实现的。因此,我们不是通过在可以被其他类继承的父类中实现它们来共享可重用的算法,而是将每个算法封装成独立的组件,在运行时将这些组件附加到对象上。

如果这听起来很熟悉,并且与 Unity 的组件系统工作方式相似,那是因为引擎采用了面向对象编程中的组合优于继承方法。因此,这使得策略模式与 Unity 的架构相协调。

让我们回顾一下这个模式的简单图示:

正如我们所看到的,这个策略模式的例子实现了一个接口,允许我们将各种寻的行為分配给任何导弹类型的对象。但像大多数模式一样,仅通过查看 UML 图很难理解其设计。因此,我们只能通过在代码中实现它,才能真正理解这个模式背后的机制,我们将在下一节中看到。

在本章中,我们经常使用算法、逻辑和行为这些术语作为同义词,因为在策略模式的上下文中,它们可以作为独立的组件进行管理。

优点和缺点

策略模式享有良好的声誉,但像大多数复杂的模式一样,其复杂性可能导致一些缺点。

这些是好处:

  • 替代子类化:因为它侧重于组合而非继承,策略提供了一种避免在父类型每个子类中硬编码行为的方法

  • 减少条件语句管理:通过将每个行为实现为可以由策略模式管理的单独组件,当处理复杂上下文行为选择时,消除了需要长条件语句的需求

这里是缺点:

  • 客户端必须了解各种策略:策略模式没有从客户端抽象出来,因此客户端必须了解不同的策略以及如何调用它们

  • 增加代码复杂性:这种复杂模式的常见缺点是它确实增加了要管理的类和对象的数量

许多程序员犹豫使用复杂模式的主要原因是他们担心团队中的初级成员可能会迷失在它给代码库增加的复杂性中。因此,重要的是要绘制和记录代码的复杂部分,并列出你正在使用的实现系统的模式。

用例示例

对于这个用例,让我们假设我们正在开发一个军事模拟游戏,并且我们被分配实现导弹寻家系统的以下行为:

  • Heat:导弹通过其热特征来寻找目标

  • Sonar:导弹使用声波传播来寻找目标

  • GPS:导弹使用 GPS 坐标来定位目标

设计文档还强调,将有三种类型的导弹使用寻家系统。但到目前为止,尚未决定哪种导弹将使用哪种寻家系统:

  • Tomahawk:通常从航母发射

  • SideWinder:它们是为喷气式战斗机设计的

  • Torpado:它们被设计用来摧毁水下目标

因此,我们现在必须做出技术选择:

  • 我们是否在每个导弹类型的类中硬编码每种寻求行为?

  • 我们是否编写一个包含所有导弹寻求行为的单一寻家系统类?

  • 我们是否相反地编写每个导弹寻求行为作为一个单独的组件,我们可以将其动态地附加到任何导弹上?

第三个选项是最好的,因为它消除了任何重复的代码,并以组合的形式提供了灵活性。在下一节中,我们将实现这个用例,并看看策略模式如何为我们提供很多可扩展性。

好的代码是灵活的,永远不会僵化。僵化可能看起来更稳定,但它使更改变得困难和昂贵。

代码示例

我们将使这个例子非常简单,这样我们就可以专注于理解策略模式,而不是迷失在冗长的描述中。让我们遵循以下步骤:

  1. 让我们首先实现使这个模式工作的重要元素,即用于访问寻求行为的接口:
public interface ISeekBehaviour
{
    void Seek();
}

现在我们为所有的寻求行为提供了一个标准接口,让我们在单个具体类中实现它们。

  1. 我们第一个是 SeekWithGPS 行为,如下所示:
using UnityEngine;

public class SeekWithGPS : ISeekBehaviour
{
    public void Seek()
    {
        Debug.Log("Seeking target with GPS coordinates.");
    }
}
  1. 我们有 SeekWithHeat 行为,如下所示:
using UnityEngine;

public class SeekWithHeat : ISeekBehaviour
{
    public void Seek()
    {
        Debug.Log("Seeking target with heat signature.");
    }
}
  1. 最后,我们有我们的 SeekWithSonar 行为,如下所示:
using UnityEngine;

public class SeekWithSonar : ISeekBehaviour
{
    public void Seek()
    {
        Debug.Log("Seeking with sonar.");
    }
}

因此,现在我们已经将每种寻求行为封装到单独的类中,下一步就是找到一种方法将它们动态地分配给导弹。

  1. 让我们编写一个抽象类,它将每种导弹与一个共同的父类分组,并允许我们为它们提供一个共享的接口,如下所示:
abstract public class Missile
{
    protected ISeekBehaviour seekBehavior;

    public void ApplySeek()
    {
        seekBehavior.Seek();
    }

    public void SetSeekBehavior(ISeekBehaviour seekType)
    {
        this.seekBehavior = seekType;
    }
}

有两个关键点需要注意:ApplySeek()SetSeekBehaviour() 函数将应用指定的行为到任何从 Missile 类派生的导弹类型。我们为自己提供了一个访问所有导弹类型的单一入口点,以及动态应用寻求行为的方法。让我们看看这在我们的具体导弹类中是如何表现的。

  1. 我们从我们的 Torpedo 开始。默认情况下,让我们给它 SeekWithSonar 行为,如下所示:
public class Torpedo : Missile
{
    void Awake()
    {
        this.seekBehavior = new SeekWithSonar();
    }
}
  1. 接下来是我们的 SideWinder。我们应该给它 SeekWithHeat 行为,如下所示:
public class SideWinder : Missile
{
    void Awake()
    {
        this.seekBehavior = new SeekWithHeat();
    }
}
  1. 我们最后的导弹类型将是 Tomahawk。让我们给它 SeekWithGPS 行为,因为它是一种远程导弹,如下所示:
public class Tomahawk : Missile
{
 void Awake()
 {
 this.seekBehavior = new SeekWithGPS();
 }
}

我们可以注意到,每个具体的导弹类在 Awake() 方法中将寻求行为的实例分配给 this.seekBehaviour,这是因为我们想要确保每种导弹类型在初始化时都与一个默认的寻求行为相关联。

  1. 我们将在我们的 Client 类示例中看到,我们可以在任何时候重新分配一个新的行为给导弹,如下所示:
using UnityEngine;

public class Client : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.D))
        {
            // Applying default seeking behaviour to missiles.
            Missile sideWinder = ScriptableObject.CreateInstance<SideWinder>();
            sideWinder.ApplySeek();

            Missile tomahawk = ScriptableObject.CreateInstance<Tomahawk>();
            tomahawk.ApplySeek();

            Missile torpedo = ScriptableObject.CreateInstance<Torpedo>();
            torpedo.ApplySeek();

            // Applying custom seeking behaviour to a SideWinder.
            Missile sideWinderWithSonar = ScriptableObject.CreateInstance<SideWinder>();
            ISeekBehaviour sonar = new SeekWithSonar();
            sideWinderWithSonar.SetSeekBehavior(sonar);
            sideWinderWithSonar.ApplySeek();
        }
    }
}

如我们所见,我们现在能够动态地将寻求行为附加到任何导弹上。这种机制是有益的,因为它意味着我们可以在飞行途中切换导弹的寻求行为;这是一个在游戏中非常酷的功能。

摘要

我们刚刚学习了如何通过构建一系列可以在运行时附加到任何导弹上的目标寻求行为来实现策略模式。从这个模式中,我们学到的很重要的一点是将行为隔离到可以动态分配给对象的单独类中的重要性。这种方法已经成为良好架构的支柱,并且在游戏程序员中非常受欢迎。

在下一章中,我们将探讨命令模式,这是一种经常用于管理事件触发的行为模式。

实践

在前面的代码示例中,我们只实现了一个简单的导弹目标搜索行为的原型,以保持章节长度合理并专注于学习策略模式背后的核心概念。然而,完成这些目标行为的实现并构建一个可以动态切换导弹制导系统从热、声纳或 GPS 搜索行为的发射系统演示将是一个很好的练习。

进一步阅读

第八章:命令

我必须承认,命令模式可能一开始很难理解。我知道我花了很长时间才掌握它。即使它的名字表明了其核心目的,其实际应用一开始并不明显。但一旦你开始尝试并理解其复杂性,它就可以成为设计特定类型系统(如用户界面)时应用的一个坚固的模式。它的主要目的是提供一个封装所需数据的方法,以便在特定时刻执行操作或触发事件。

本章将涵盖以下主题:

  • 命令模式背后的基本原理

  • 实现一个我们可以用其控制多个设备的通用控制器

技术要求

下一章是实践性的,因此你需要对 Unity 和 C#有基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 构造函数

如果你对这个概念不熟悉,请在继续之前先复习一下。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频,以查看代码的实际运行情况:

bit.ly/2Our6OF

命令模式的概述

命令模式是一种解决方案,它使得在对象上集中调用特定命令的过程成为可能。在思考命令模式时,我常常会联想到一个通用控制器。在互联网和智能手机出现之前,大多数客厅里都有多个设备,每个设备都有其特定的功能。你有一个音响来播放音乐,一个电视来观看节目,一个 VHS 来播放磁带,等等,但每个系统都有一个特定的遥控器与之相关联,因此由于需要管理多种控制器,这常常会导致混乱。

由于这个原因,发明了可编程的通用控制器,它解决了这个问题,并允许你从单个遥控器控制多个设备。这种方法之所以有效,是因为通用控制器有一组标准的按钮,你可以将其与命令和设备相关联。

从某种意义上说,命令模式与通用控制器的概念非常相似,因为它允许你将特定的命令与可以处理请求的对象链接和调用。

让我们回顾以下图示,这是命令模式典型实现的示例:

通过查看图表来学习命令模式并不是正确的方法,但它确实帮助我们隔离了参与此模式的根本类:

  • 一个Invoker是一个知道如何执行命令并且可以记录已执行命令的对象。

  • Receiver是一种可以接收命令并执行它们的对象类型。

  • CommandBase通常是具体命令类的接口或抽象类。它是这种模式的主要抽象层。

这些核心类封装了在特定时刻执行命令所需的所有信息。这将在我们实现用例时变得更加明显。

优点和缺点

与策略和状态等模式类似,程序员似乎避免使用命令模式的主要原因是其冗长性

这些是命令模式的主要优点:

  • 顺序和时间: 命令模式为你提供了在特定顺序或时间范围内执行命令的灵活性

  • 撤销/重做: 命令模式通常用于实现簿记功能,允许以特定顺序回滚命令

  • 可扩展性: 行为模式的一个典型优点是它们给你提供了添加行为的能力,而无需对主要类进行大量更改

这些是命令模式的缺点:

  • 冗长性: 这种模式的一个常见缺点是它会使你的代码非常冗长,并且向你的代码库中添加更多类

用例示例

作为用例,我们将实际实现一个通用控制器,它将允许我们控制多个设备,包括电视和收音机。我们将使用通用控制器概念作为示例的主要原因是因为这将更容易让我们学习命令模式的复杂性,我们将通过实现一个直接与在特定对象上管理命令调用相关的系统来完成这一点。

代码示例

通过特定的用例实现命令模式是掌握它的最佳方式。我们将编写的示例非常适合命令模式,因为它全部关于向特定接收器发送命令:

  1. 对于我们的第一个类,我们需要以抽象类的形式声明RemoteControlDevice类型:
abstract class RemoteControlDevice
{
    public abstract void TurnOn();
    public abstract void TurnOff();
}
  1. 接下来是Command类,这是我们这种模式的核心类型:
abstract class Command
{
    protected RemoteControlDevice m_Receiver;

    public Command(RemoteControlDevice receiver)
    {
        m_Receiver = receiver;
    }

    public abstract void Execute();
}

正如我们所见,它的主要职责是分配一个Receiver对象并执行Execute()命令。

  1. 现在,让我们实现具体的命令类,每个类都有其独特的职责。首先,让我们实现TurnOnCommand,它用于打开我们的设备(接收器):
class TurnOnCommand : Command
{
    public TurnOnCommand(RemoteControlDevice receiver) : base(receiver)
    {
    }

    public override void Execute()
    {
        m_Receiver.TurnOn();
    }
}
  1. 接下来是TurnOffCommand,它当然会关闭我们的设备(接收器):
class TurnOffCommand : Command
{
    public TurnOffCommand(RemoteControlDevice receiver) : base(receiver)
    {
    }

    public override void Execute()
    {
        m_Receiver.TurnOff();
    }
}
  1. 还有我们的KillSwitchCommand,它是独特的:
class KillSwitchCommand : Command
{
    private RemoteControlDevice[] m_Devices;
    private static RemoteControlDevice receiver;

    public KillSwitchCommand(RemoteControlDevice[] devices) : base(receiver)
    {
        m_Devices = devices;
    }

    public override void Execute()
    {
        foreach (RemoteControlDevice device in m_Devices)
        {
            device.TurnOff();
        }
    }
}

正如我们所见,KillSwitchCommand不仅仅是在Receiver对象上调用Execute()函数,而是遍历一系列设备并在每个设备上调用TurnOff()函数。这意味着我们正在批量执行特定命令。

  1. 现在,我们需要实现接收特定命令以执行特定命令的设备。我们的第一个接收器是Television
using UnityEngine;

class TelevisionReceiver : RemoteControlDevice
{
    public override void TurnOn()
    {
        Debug.Log("TV turned on.");
    }

    public override void TurnOff()
    {
        Debug.Log("TV turned off.");
    }
}
  1. 我们下一个接收器是Radio
using UnityEngine;

class RadioReceiver : RemoteControlDevice
{
    public override void TurnOn()
    {
        Debug.Log("Radio is turned on.");
    }

    public override void TurnOff()
    {
        Debug.Log("Radio is turned off.");
    }
}

如我们所见,两个接收者都实现了TurnOn()TurnOff()函数。因此,它们封装了各自独特行为的细节。

  1. 此外,让我们实现命令模式(Command pattern)的一个关键角色——Invoker
class Invoker
{
    private Command m_Command;

    public void SetCommand(Command command)
    {
        m_Command = command;
    }

    public void ExecuteCommand()
    {
        m_Command.Execute();
    }
}

这个Invoker的例子很简单,但可以很容易地扩展以记录通过它执行的命令。

  1. 最后,我们有我们的Client类:
using UnityEngine;

public class Client : MonoBehaviour
{
    private RemoteControlDevice m_RadioReceiver;
    private RemoteControlDevice m_TelevisionReceiver;
    private RemoteControlDevice[] m_Devices = new RemoteControlDevice[2];

    void Start()
    {
        m_RadioReceiver = new RadioReceiver();
        m_TelevisionReceiver = new TelevisionReceiver();

        m_Devices[0] = m_RadioReceiver;
        m_Devices[1] = m_TelevisionReceiver;
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.O))
        {
            Command commandTV = new TurnOnCommand(m_Devices[0]);
            Command commandRadio = new TurnOnCommand(m_Devices[1]);

            Invoker invoker = new Invoker();

            invoker.SetCommand(commandTV);
            invoker.ExecuteCommand();

            invoker.SetCommand(commandRadio);
            invoker.ExecuteCommand();
        }

        if (Input.GetKeyDown(KeyCode.K))
        {
            Command commandKill = new KillSwitchCommand(m_Devices);
            Invoker invoker = new Invoker();
            invoker.SetCommand(commandKill);
            invoker.ExecuteCommand();
        }
    }
}

你会注意到在调用命令时有一个特定的调用顺序:

  1. 初始化一个新的命令

  2. 将其传递给Invoker

  3. Invoker执行指定的命令

采用这种方法,我们维护了调用命令和接收命令之间的一个一致通信渠道。

摘要

在本章中,我们回顾了命令模式(Command pattern),这是一个独特的模式,一开始往往会让很多程序员感到困惑,因为它的核心用途并不总是显而易见。但一旦正确应用,它确实在实现依赖于在多个组件上按特定顺序执行命令的系统时提供了很多可扩展性。

接下来是观察者模式(Observer pattern),这个模式比命令模式(Command)更容易理解,并且是 C#事件系统的核心。

练习题

命令模式通常用于实现大多数文本编辑器中常见的经典撤销/重做功能。在我们的代码示例中,我们实现了支持此功能的基础。因此,作为一个练习题,我建议你集成自己的撤销/重做功能。你可以在InvokerKillSwitchCommand类中找到关于实现此功能的最佳方法的线索。

进一步阅读

第九章:观察者

在本章中,我们将学习观察者模式,但我们将采取与之前章节不同的方法,简单的原因是观察者模式已经在 Unity 引擎中以 C# 事件系统的形式原生实现。但为了全面起见,我们将快速回顾观察者模式的经典形式,然后将其与 C# 事件系统进行比较。

本章将涵盖以下主题:

  • 观察者模式的基本原理

  • 检查它如何在 C# 事件系统中原生实现

技术要求

这是一个实践性章节,因此您需要具备对 Unity 和 C# 的基本理解。

我们将使用以下 Unity 特定引擎和 C# 语言概念:

  • 事件

  • 代理

  • 协程

在开始本章之前,您不需要了解它们,但花时间回顾一些关于该主题的在线文档会有所帮助。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频,看看代码的实际运行情况:

bit.ly/2Fy4HvP

观察者模式的预览

如其名所示,观察者模式的目的就是观察。更准确地说,观察者的核心目的是观察其他对象及其内部状态的具体变化。在观察者模式之前,从“外面看”观察一个对象的唯一方法是通过不断调用或“ping”其公共成员,希望捕捉到其值的变化。

观察者模式旨在通过定义一个系统来解决这个问题,在该系统中,对象(主题)维护一个其他对象(观察者)的列表。当主题需要广播其方面的变化时,它们会调用观察者。

我们可以通过以下现实世界的例子来可视化这个系统的原理:一位华尔街经纪人管理着一组与客户列表(观察者)关联的股票目录(主题)。当特定市场事件发生时,经纪人会联系所有客户,告知他们其股票的价值已发生变化。

让我们回顾一个典型实现观察者模式的 UML 图,看看它在代码中实现时可能的工作方式:

图片

如您所见,主题和观察者都有自己的接口,但最重要的分析接口是 ISubject,它包括以下公共函数:

  • Attach(): 这个函数允许将观察者对象添加到要通知的观察者列表中。

  • Detach(): 这个函数会从观察者列表中移除一个观察者。

  • Notify(): 这将通知所有已附加到主题观察者列表中的观察者。

尽管这不是一个非常复杂的设计来实现,但每次你需要观察其他对象时,编写它都可能变得非常繁琐。现代语言,如 C#,已经以事件系统的形式原生实现了观察者模式,因此程序员不需要手动编写它。

与通常只根据用户交互改变当前运行状态的电子表格应用程序不同,我们必须注意,游戏不是事件驱动的;相反,是主游戏循环推动游戏向前发展。

C#事件系统

作为一名 Unity 开发者,你可能永远不需要手动实现完整的观察者模式,因为 C#已经以事件系统的形式提供了原生的观察者实现。但在我们开始编写代码之前,让我们回顾一下 C#事件系统的核心组件:

  • 事件:当一个对象(发布者)引发事件时,它会发送一个信号,其他对象(订阅者)可以捕获这个信号。这个概念可能听起来非常熟悉,就像抛出和处理异常一样,当异常被抛出时,它会沿着调用栈向上传递,直到被处理。但在事件系统的案例中,实际上并没有调用链,因为一旦一个对象广播了一个事件,只有订阅了它的对象才会被通知,并且可以选择被它触发或只是忽略它。所以,我们基本上可以想象它就像一个突然爆发的无线电信号,只有那些有天线的人才能听到。

  • 代表(Delegates):当你理解它们背后的底层机制时,代表的概念很简单。代表的高级定义是它们持有函数的引用。当你想要从一个调用中触发多个函数时,它们非常有用——换句话说,当你想要多播(multicast)时。但这是对代表在幕后实际做什么的一个非常抽象的定义。它们基本上是函数指针,这意味着它们持有其他函数的内存地址。因此,我们可以将它们想象成一个包含函数位置的地址簿。这就是为什么代表可以持有多个函数并一次性调用它们。

好处和缺点

观察者模式是那些已经嵌入到现代语言和代码库中的模式之一。很难不使用这个模式,因为它的缺点是有限的。

以下是一些好处:

  • 松耦合:观察者的主要优势是它将观察对象与观察者解耦。它们不需要相互了解;它们只需要广播或监听。

  • 向任何人发送数据:你可以轻松地将数据从一个对象发送到另一个对象。

  • 随时停止监听:主体和听众之间没有明确的合同,因此如果需要,他们可以随时停止广播。

以下是一个缺点:

  • 嘈杂的代码:观察者模式带来了事件驱动范式,但如果过度使用,它可能会变得嘈杂且难以管理。

用例示例

以事件系统形式的观察者模式通常用于管理用户输入,但让我们看看我们是否可以用事件做其他事情,比如一个自动系统,它将广播其状态变化给其他系统。

假设我们正在构建一个带有倒计时计时器的经典益智游戏。在大多数有计时器的游戏或运动中,我们将给玩家一个独特的标志和反馈,以提醒他们剩余多少时间。

我们三个主要的计时器反馈事件如下:

  • 时钟开始

  • 半场

  • 时间到

对于每个事件,让我们触发一些独特的事情,如下所示:

  • 减弱灯光

  • 触发蜂鸣器

  • 在屏幕上显示一条消息

但这里的挑战是:我们如何通知管理照明、声音和 UI 的各个系统或组件计时器的状态?当我们遇到这类问题时,观察者模式就变得非常有用,以事件系统的形式:我们将能够让所有这些个别系统在计时器广播特定时间事件时进行监听。

代码示例

我们将通过实现观察者模式最重要的组件:主题来开始这个代码示例。如果没有东西可以观察,观察者模式就没有用途。请参考以下步骤:

  1. 在我们的代码示例中,Timer类将是我们主题:
using UnityEngine;
using System.Collections;

public class Timer : MonoBehaviour
{
    private float m_Duration = 10.0f;
    private float m_HalfTime;

    public delegate void TimerStarted();
    public static event TimerStarted OnTimerStarted;

    public delegate void HalfTime();
    public static event HalfTime OnHalfTime;

    public delegate void TimerEnded();
    public static event TimerEnded OnTimerEnded;

    private IEnumerator m_Coroutine;

    IEnumerator Start()
    {
        m_HalfTime = m_Duration / 2;

        if (OnTimerStarted != null)
        {
            OnTimerStarted();
        }

        yield return StartCoroutine(WaitAndPrint(1.0F));

        if (OnTimerEnded != null)
        {
            OnTimerEnded();
        }
    }

    private IEnumerator WaitAndPrint(float waitTime)
    {
        while (Time.time < m_Duration)
        {
            yield return new WaitForSeconds(waitTime);

            Debug.Log("Seconds: " + Mathf.Round(Time.time));

            if (Mathf.Round(Time.time) == Mathf.Round(m_HalfTime))
            {
                if (OnHalfTime != null)
                {
                    OnHalfTime();
                }
            }
        }
    }
}

如您所见,代码并不多;使用 C#事件系统实现一个主题相当简单。最重要的是委托事件类型之间的关系。一个事件是对象发送的消息,但在通信过程中,它并不知道哪些对象会接收其消息,因此需要一个类似指针的机制,可以在发送者和接收者之间充当中间人,这就是委托所需的地方。只需想象一下,委托就是将事件消息指向正确的观察者

还有另一个重要的细节需要注意。请注意,每次我们调用像OnTimerEnded()这样的事件时,它都会在其相关的事件类型引用上检查 null,然后再引发事件:

....        
if (OnTimerEnded != null)
{
    OnTimerEnded();
}

我们这样做是因为如果没有人在听,就无法广播事件。我们需要至少一个处理事件接收的观察者。这就是事件系统实现和管理其引用的方式。

  1. 现在我们已经准备好了主题,是时候实现那些将注册自己以接收来自我们的Timer事件消息的系统了。换句话说,我们将实现我们的观察者。第一个是Buzzer,它将通过发出蜂鸣声来通知玩家计时器已经开始或结束:
using UnityEngine;

public class Buzzer : MonoBehaviour
{
    void OnEnable()
    {
        Timer.OnTimerStarted += PlayStartBuzzer;
        Timer.OnTimerEnded += PlayEndBuzzer;
    }

    void OnDisable()
    {
        Timer.OnTimerStarted -= PlayStartBuzzer;
        Timer.OnTimerEnded -= PlayEndBuzzer;
    }

    void PlayStartBuzzer()
    {
        Debug.Log("[BUZZER] : Play start buzzer!");
    }

    void PlayEndBuzzer()
    {
        Debug.Log("[BUZZER] : Play end buzzer!");
    }
}
  1. 我们列表中的下一个是WarningLight,当计时器达到半场时将闪烁:
using UnityEngine;

public class WarningLight : MonoBehaviour
{
    void OnEnable()
    {
        Timer.OnHalfTime += BlinkLight;
    }

    void OnDisable()
    {
        Timer.OnHalfTime -= BlinkLight;
    }

    void BlinkLight()
    {
        Debug.Log("[WARNING LIGHT] : It's half-time, blinking the warning light!");
    }
}
  1. 作为我们的最终观察者,我们将实现Notifier,它负责在时间到游戏结束时弹出消息:
using UnityEngine;

public class Notifier : MonoBehaviour
{
    void OnEnable()
    {
        Timer.OnTimerEnded += ShowGameOverPopUp;
    }

    void OnDisable()
    {
        Timer.OnTimerEnded -= ShowGameOverPopUp;
    }

    void ShowGameOverPopUp()
    {
        Debug.Log("[NOTIFIER] : Show game over pop up!");
    }
}

我们应该注意到我们所有的观察者有一个共同点:它们都通过指向一个特定的本地函数来注册自己以接收来自Timer的事件。这种实现方式意味着当Timer广播一个事件时,所有观察它的对象将自动调用它们的一个本地方法。因此,远程事件可以触发对象的本地函数调用:

// Adding the object as a observer of the OnTimerEnded event once it //get's enabled.
void OnEnable() 
{
    Timer.OnTimerEnded += ShowGameOverPopUp;
}

// In case the object is disabled, removing it as an observer of //OnTimerEnded.
void OnDisable()
{
    Timer.OnTimerEnded -= ShowGameOverPopUp;
}

另一个需要记住的点是一个事件不能指向null引用,因此确保一个对象在禁用时作为观察者移除是一个好的实践。

通过 C#事件系统表达的观察者模式提供了一种简单但强大的方法来实现对象之间的观察者-主题关系,无需显式耦合,并且只需要几行代码。

摘要

在本章中,我们学习了如何通过构建一个计时器来实现观察者模式,该计时器可以通过组件监听特定的定时事件来触发场景中的行为。从这个模式中,我们得到的一个重要启示是,观察者模式在 Unity 中以 C#事件系统的形式原生化实现。

在下一章中,我们将探讨状态模式。这是游戏编程中另一个有用的模式,它与观察者模式有些相关。

练习

正如我们在本章中学到的,观察者模式是 C#事件系统的灵感来源。但当然,它并不是这个模式的精确实现。所以,作为一个练习,我会鼓励你重新编写我们刚刚实现的计时器系统,但不要使用 C#事件系统;相反,遵循观察者模式的设计。

您可以使用本章开头所示的 UML 图作为起点。

在不寻常的方式中实现设计模式是很常见的。通常,设计模式会激发程序员以某种方式结构化他们的代码,但在生产代码库中,你很少会看到特定模式的准确和“按部就班”的实现。

进一步阅读

设计模式》,作者:Erich Gamma, Richard Helm, Ralph Johnson, 和 John Vlissides(www.pearsoned.co.uk/bookshop/detail.asp?WT.oss=design%20patterns%20elements&WT.oss_r=1&item=171742)

第十章:状态

在视频游戏中,对象会根据可能由玩家或游戏机制触发的事件不断从一个状态转换到另一个状态。

因此,游戏程序员的主要职责之一是实现一系列有限状态和行为,这些状态和行为从非玩家角色(NPC)到武器不等。这些任务必须以可维护和可配置的方式进行,以便设计团队能够单独调整每个状态行为,直到游戏感觉平衡。

状态模式正是为了通过提供一种简单的方式来封装行为到代表对象特定状态的独立类中,从而精确地设计来达成这一目标。

本章将涵盖以下主题:

  • 状态模式的基本原理

  • 为涉及太空船的游戏实现有限状态集合

技术要求

下一章将涉及实践操作,因此你需要对 Unity 和 C# 有一个基本的了解。

我们将使用以下 Unity 特定引擎和 C# 语言概念:

  • 接口

如果你对这个概念不熟悉,请在继续本章之前进行复习。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

观看以下视频以查看代码的实际操作:

bit.ly/2UfzpTD

状态模式的基本原理

在某种程度上,状态模式与策略模式非常相似,因为它允许我们在运行时将行为应用到特定的对象上。核心区别在于,当我们需要管理对象的内部状态时使用状态模式,而策略模式则侧重于根据运行时上下文选择正确的算法来执行。

在太空船项目类结构中,以下图表显示了状态类(NormalShipStateAlertShipStateDisabledShipState)具有标准接口,允许 Ship 类调用特定状态的行为:

图片

正如我们将在代码示例中看到的,状态模式背后的概念与其实际实现一样简单,因为它为我们提供了一种封装行为并应用它们的方法,而不必依赖于长条件语句。

优点和缺点

确定状态模式的共同缺点可能具有挑战性,因为状态管理是游戏开发的基础,因此我们可以认为这个模式是基本的,不能被忽视。

使用状态模式的以下是一些优点:

  • 封装的行为:状态模式允许我们将一个实体的行为实现为一个自包含的组件集合,当对象状态改变时,这些组件可以动态地附加到对象上。

  • 条件块减少:使用状态模式可以减少对大量if-else条件或切换情况的需求,因为行为可以根据对象的内部或全局状态变化动态分配。

只有一个缺点:

  • 代码复杂性:实现模式有时会导致代码库冗长,并且由于封装和高度定义的结构,类数量增加

使用案例示例

假设我们正在制作一个游戏,玩家控制一艘飞船。我们的首席设计师仍在头脑风暴关于飞船在游戏中具体能做什么的想法。但他们要求我们实现至少三个核心状态,我们的飞船可以根据与敌舰太空战的战果处于这些状态之一:

  • 正常:玩家的飞船正常运行

  • 警报:一艘敌舰正在接近并准备攻击

  • 禁用:玩家的飞船在战斗中被击败,目前无法移动或反击

在每个状态下,都有特定的一组行为和行动供船员执行:

  • 正常:船员回到他们的默认位置并执行分配的任务

  • 警报:船员跑向他们的指定战斗位置

  • 禁用:船员跑向逃生舱并放弃船只

关于这个列表最重要的一点是它非常通用,这意味着我们可以编写这些状态和行为,以便将它们附加到我们游戏中任何类型的飞船上,包括敌舰。正如我们将在下面的代码示例中看到的,状态模式允许我们将行为与实体解耦,这也是为什么实体可以轻松地在状态之间切换的原因。

代码示例

如使用案例示例中所述,我们将为我们的飞船实现一系列有限的状态:

  1. 让我们先实现一个接口,用于定义我们的状态:
public interface IShipState
{
    void Execute(Ship ship);
}

如您所见,Execute函数接收一个Ship类型的实体。这个声明意味着我们将能够将我们的状态附加到任何飞船上并执行,这使得我们的代码非常模块化和可扩展。

  1. 现在我们将定义每个状态并添加一些上下文代码到Execute()方法中:
public class NormalShipState : IShipState
{
    public void Execute(Ship ship)
    {
        ship.LogStatus("NORMAL: ship operating as normal.");
    }
}

正常状态是我们的默认状态,它执行正常运行的飞船的行为。

  1. 接下来是警报状态。在这种情况下,飞船的船员和系统都会发出警报:
public class AlertShipState : IShipState
{
    public void Execute(Ship ship)
    {
        ship.LogStatus("ALERT: all hands on deck.");
    }
}
  1. 最后,是禁用状态。这意味着飞船无法移动,船员正在逃离:
public class DisabledShipState : IShipState
{
    public void Execute(Ship ship)
    {
        ship.LogStatus("DISABLED: crew jumping ship.");
    }
}

对于我们的代码示例,我们通过仅实现一些控制台输出以指示当前状态来简化事情,但在实际项目中,我们可以轻松触发每个状态变化的声音提示、粒子效果和动画。

  1. 现在我们已经收集了一组可以附加到飞船上的状态。下一步,让我们编写Ship类的具体实现:
using UnityEngine;

public class Ship : MonoBehaviour
{
    private IShipState m_CurrentState;

    void Awake ()
    {
        m_CurrentState = new NormalShipState();
        m_CurrentState.Execute(this);
    }

    public void Normalize()
    {
        m_CurrentState = new NormalShipState();
        m_CurrentState.Execute(this);
    }

    public void TriggerRedAlert()
    {
        m_CurrentState = new AlertShipState();
        m_CurrentState.Execute(this);
    }

    public void DisableShip()
    {
        m_CurrentState = new DisabledShipState();
        m_CurrentState.Execute(this);
    }

    public void LogStatus(string status)
    {
        Debug.Log(status);
    }
}

让我们回顾一下我们使用此模式所取得的成果:

  • 我们消除了管理飞船状态行为之间转换所需的 switch cases 或if-elses

  • 我们将飞船的行为解耦成可以动态附加到任何类型飞船的自包含组件。

这些小的好处给了我们相当大的灵活性,现在我们可以将行为作为单独的组件来编写。这意味着我们可以让一位同事专注于Alert状态,而另一位重构Disabled状态,而不会相互干扰。

  1. 我们代码示例的最后部分是我们的Client类,我们将使用它通过用户的输入来触发每个状态以测试它们:
using UnityEngine;

public class Client : MonoBehaviour
{
    public Ship ship;

    void Update()
    {
        if (Input.GetKeyDown("n"))
        {
            ship.Normalize();
        }

        if (Input.GetKeyDown("a"))
        {
            ship.TriggerRedAlert();
        }

        if (Input.GetKeyDown("d"))
        {
            ship.DisableShip();
        }
    }
}

在这个例子中,我们手动触发飞船的有限状态,但我们同样可以轻松地使用事件或健康系统来触发它们。换句话说,通过使用状态模式,我们获得了将多个状态行为附加到任何实体并动态通过任何机制触发的灵活性,而不必编写长而复杂的条件语句。

摘要

在这一章中,我们回顾了一个游戏开发的基础模式。我们现在有能力将状态行为封装成可以动态分配给对象的单独组件。我们减少了我们对长条件语句的依赖,并有一个与行为和状态管理相关的代码结构的一致方法。

在下一章中,我们将回顾访问者模式,这是一种独特的模式,它赋予我们解耦算法与对象结构的能力。

练习

在视频游戏中,为了使实体在行为上感觉不那么机械,一种常见的技巧是缓和状态之间的过渡。例如:当巡逻的敌人角色检测到玩家的角色时,它们不会立即从被动状态转变为攻击状态,而是在状态之间有一个简短的动画序列,显示敌人进入警戒姿态然后发起攻击。

作为练习,我建议尝试将过渡状态整合到宇宙飞船的每个有限状态之间,并找到一个使它们之间过渡无缝融合的解决方案。

进一步阅读

设计模式:可复用面向对象软件元素》,作者 Erich Gamma、John Vlissides、Ralph Johnson 和 Richard Helm

(www.informit.com/store/design-patterns-elements-of-reusable-object-oriented-9780201633610)

第十一章:访问者

我必须承认,我发现访问者模式很令人困惑,也很奇怪。我在理解这个模式背后的概念上挣扎了一段时间,主要是因为我很少使用它,而且我主要从学术来源阅读关于它的内容。但是,当我开始将对象不仅视为存储在堆中的数据块,而且视为可以被另一个对象访问和操作的结构时,我开始欣赏这个模式。这意味着可以在不修改对象结构的情况下对对象结构的元素执行特定操作。这种方法在需要实现需要遍历层次结构并对单个节点执行特定操作的系统时非常有用。

本章将涵盖以下主题:

  • 访问者模式背后的基本原理

  • 实现一个单臂工厂机器人的模拟

技术要求

本章是实践性的。你需要对 Unity 和 C#有基本的了解。

我们将使用以下 Unity 引擎和 C#语言概念:

  • 接口

如果你对这个概念不熟悉,请在开始本章之前复习它。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际操作:

bit.ly/2OsR6d6

访问者模式概述

一旦你掌握了它,访问者模式的主要目的就很简单;一个可访问对象允许访问者对象对其结构中的特定元素进行操作。这允许被访问的对象从访问者那里接收新的功能。

这种描述一开始可能看起来非常抽象,但如果你想象一个对象是一个数据结构,而不是一个封闭的数据和逻辑容器,那么它就更容易可视化。有了这个想法,你可以看到在操作对象的方式上有更广泛的可能性。

在以下图中,我们可以可视化这些原则:

图片

这个模式中有两个关键参与者:

  • 访问者是具体访问者的接口

  • 可访问是接受访问者的对象的接口

优点和缺点

访问者模式不像单例模式或依赖注入模式那样流行,因此围绕其优点和缺点的争议较少,如下列所示:

以下是一些优点:

  • 数据与逻辑分离:访问者模式提供了一种将对象的数据结构与其行为解耦的方法。这种方法通过仅添加更多访问者来扩展对象功能变得更容易。

  • 双重分派:访问者模式提供了在运行时根据给定参数的类型选择使用哪个方法的能力,从而使代码更加动态。

以下是一些缺点:

  • 代码复杂性:访问者模式最明显的缺点是它使代码更加晦涩。一个不熟悉访问者模式复杂性的程序员可能会很容易迷失方向。

  • 不灵活性:访问者模式不是一个容易使用的模式,并且在其实现中需要一致性。一旦集成到代码库中,它也可能很难移除,因此这可能是一个长期承诺。

用例示例

我们将使我们的用例简单,这样我们在尝试掌握访问者模式的复杂性时不会迷失在抽象层中。想象一下,我们正在做一个项目,需要设计一个机械臂的交互式机器人模拟。

机器人非常模块化,主要由各种组件组成。因此,我们希望我们的代码能够反映这一点,使我们能够动态地将单个组件附加到我们的骨架机器人对象上。为了实现这一点,我们将使用访问者模式,因为它为我们提供了一种在不直接修改对象结构的情况下动态向对象结构添加元素的方法。

代码示例

现在,是时候实现我们的单臂机器人了,通过附加它操作所需的所有组件,而不修改其基本结构:

  1. 让我们先实现访问者接口,在其中我们声明我们将要操作的那些机器人部件:
public interface IRobotPartVisitor
{
    void Visit(Robot robot);
    void Visit(Battery battery);
    void Visit(MechanicalArm mechanicalArm);
    void Visit(ThermalImager thermalImager);
}
  1. 为了帮助我们理解这个模式,让我们实现两个具体的访问者模式,如下所示;第一个访问所有我们的机器人部件并将它们打开,而另一个则关闭它们:
  • RobotPartActivateVisitor
using UnityEngine;

public class RobotPartActivateVisitor : IRobotPartVisitor
{
    public void Visit(Robot robot)
    {
        Debug.Log("Robot waking up.");
    }

    public void Visit(Battery battery)
    {
        Debug.Log("Battery is charged up.");
    }

    public void Visit(MechanicalArm mechanicalArm)
    {
        Debug.Log("The mechanical arm is actiaved.");
    }

    public void Visit(ThermalImager thermalImager)
    {
        Debug.Log("The thermal imager is turned on.");
    }
}
  • RobotPartShutdownVisitor
using UnityEngine;

public class RobotPartShutdownVisitor : IRobotPartVisitor
{
    public void Visit(Robot robot)
    {
        Debug.Log("Robot is going back to sleep.");
    }

    public void Visit(Battery battery)
    {
        Debug.Log("Battery is charging down.");
    }

    public void Visit(MechanicalArm mechanicalArm)
    {
        Debug.Log("The mechanical arm is folding back to it's default position.");
    }

    public void Visit(ThermalImager thermalImager)
    {
        Debug.Log("The thermal imager is turned off.");
    }
}

如您所见,到目前为止这相当直接;我们为每个机器人部件都有一个Visit()函数。这种方法允许我们单独操作它们。

  1. 现在我们已经准备好了访问者,是时候实现我们的可访问者了。让我们先编写我们的Visitable接口:
public interface IRobotPart
{
    void Accept(IRobotPartVisitor robotPartVisitor);
}
  1. 现在让我们实现我们的具体可访问者:
  • Battery
public class Battery : IRobotPart
{
    public void Accept(IRobotPartVisitor robotPartVisitor)
    {
        robotPartVisitor.Visit(this);
    }
}
  • ThermalImager
public class ThermalImager : IRobotPart
{
    public void Accept(IRobotPartVisitor robotPartVisitor)
    {
        robotPartVisitor.Visit(this);
    }
}
  • MechanicalArm
public class MechanicalArm : IRobotPart
{
    public void Accept(IRobotPartVisitor robotPartVisitor)
    {
        robotPartVisitor.Visit(this);
    }
}

注意我们在Accept()函数中如何引用访问者接口。这段代码使得我们的访问者能够操作我们的可访问者。

  1. 是时候构建我们的Robot了,通过在其构造函数中引用它们来附加所有核心部件:
using UnityEngine;

public class Robot : IRobotPart
{
    private IRobotPart[] robotParts;

    public Robot()
    {
        robotParts = new IRobotPart[] { new MechanicalArm(), new ThermalImager(), new Battery() };
    }

    public void Accept(IRobotPartVisitor robotPartVisitor)
    {
        for (int i = 0; i < robotParts.Length; i++)
        {
            robotParts[i].Accept(robotPartVisitor);
        }
        robotPartVisitor.Visit(this);
    }
}
  1. 最后,我们有我们的Client类,它通过实际触发访问者操作我们的机器人部件来充当一个概念验证:
using UnityEngine;

public class Client : MonoBehaviour
{
    void Update()
    {
        // Active robot
        if (Input.GetKeyDown(KeyCode.O))
        {
            IRobotPart robot = new Robot();
            robot.Accept(new RobotPartActivateVisitor());
        }

        // Shutdown robot
        if (Input.GetKeyDown(KeyCode.S))
        {
            IRobotPart robot = new Robot();
            robot.Accept(new RobotPartShutdownVisitor());
        }
    }
}

因此,我们已经实现了一个简单但灵活的访问者模式用例。需要注意的是,任何实现了Accept()函数的访问者都可以操作可访问对象。这种机制允许在不对对象直接修改的情况下对可访问对象执行各种操作。

概述

在本章中,我们回顾了访问者模式,这可能是本书中最高级的模式之一,因为它要求我们从不同的角度来处理面向对象编程,并开始将对象视为结构,而不是存在于堆上的抽象实体。现在我们可以运用所学知识,扩展访问者模式以实现需要操作复杂层次数据结构的系统,例如 XML 文件或目录树。

在下一章中,我们将回顾一个实用但简单的模式,这个模式在 Unity 中经常被过度使用,即外观模式(Façade pattern)。

实践练习

作为一项实际练习,我建议研究一下访问者模式(Visitor pattern)的高级用法。一个完美的例子是将访问者模式应用于导航和处理抽象语法树(Abstract Syntax Tree,AST)。这类实现可以展示访问者模式提供的架构可能性。

关于 AST 的信息,请参阅进一步阅读部分。

进一步阅读

第五部分:结构型模式

在本节中,我们将探讨结构型模式,这将使我们能够适应、扩展并将由不同程序员构建的游戏系统连接在一起,以便我们可以在多个项目中重用它们。本书中的设计模式将教会你与遗留代码一起工作的技能。

本节包含以下章节:

  • 第十二章,外观模式

  • 第十三章,适配器

  • 第十四章,装饰器

第十二章:外观

外观模式被认为是一种结构模式,因此,与这类模式的大多数模式一样,它主要关注识别建立对象之间简单关系的方法。外观模式是一个容易掌握的模式,因为其名称完美地体现了其设计。外观模式的主要意图是提供一个简化的前端接口,抽象出复杂系统的复杂内部工作。这种方法对游戏开发者来说是有益的,因为游戏主要是建立在复杂层次和交互系统之上的。

本章将涵盖以下主题:

  • 我们将回顾外观模式的基础知识

  • 我们将使用外观模式来实现一个保存系统

技术要求

这是一个实践章节,因此你需要对 Unity 和 C#有基本的了解。

我们将使用以下具体的 Unity 引擎和 C#语言概念:

  • 单例

  • 可序列化

如果你对这些概念不熟悉,请在开始本章之前复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频,了解代码的实际应用:

bit.ly/2I30suS

外观模式概述

外观模式的名字与建筑中的外观类似——正如其名所暗示的,它是一个隐藏复杂结构的表面。但是,与建筑不同,在软件开发中,外观模式的目标不是美化,而是简化。正如我们将在以下图中看到的,外观模式的实现通常仅限于一个充当复杂相互依赖子系统简化接口的单个类:

图片

正如我们所看到的,当客户端调用SaveManagerSaveGame()函数时,会有一系列调用到各种依赖和子系统(即ScoreSystemCloudManagerUIManager)。所有这些都在幕后发生;客户端并不知道需要调用多少个子系统来完成其请求。因此,为了保存当前游戏的状态,客户端只需要知道SaveManager类中有一个单一的功能,外观模式实现会在幕后完成剩余的工作。

优点和缺点

外观模式有一些实质性的优点,但也可能带来一些长期缺点:

优点如下:

  • 简化复杂代码体的接口:一个稳固的外观类将隐藏复杂性和依赖关系,同时提供一个简化的接口

  • 所有依赖调用的本地化:外观模式允许你将依赖关系本地化并分组到一个单独的类中

  • 简化重构:将子系统之间的复杂性和依赖性问题隔离在门面类中简化了重构过程,因为您可以在不影响客户端的情况下独立重构它们,因为接口保持一致

以下是一些需要注意的缺点

  • 隐藏混乱变得更容易:拥有过多的门面类可以使程序员更容易通过使他们的架构看起来简单易用来掩盖糟糕的代码,同时将潜在的长远架构问题掩盖起来。

  • 过多的管理者:门面类在 Unity 开发者中很受欢迎,他们通常通过结合单例和门面模式来实现它们。这种方法会导致一个成为全局可访问管理者的大量集合的架构。这种类型的设计变得非常难以测试和管理,因为所有管理者类都相互依赖。

门面建立了一个新的接口,而适配器则回收了一个旧的接口。在实现看起来和听起来可能相似的模式时,记住它们的目的不一定相同是很重要的。

一个用例示例

我们将使用门面和单例模式的组合来构建一个简单的存档游戏功能。我们的系统有几个步骤需要按特定顺序执行以完成保存玩家进度的过程。以下是步骤:

  1. 触发用户界面UI)反馈以指示游戏正在保存

  2. 获取当前玩家的数据(健康、分数、ID)

  3. 将玩家的数据保存到磁盘

  4. 将存档上传到云

我们必须尊重前面步骤的特定顺序,因为我们不能在获取玩家当前状态之前将数据保存到磁盘。但是,每次我们想要在我们的脚本中实现存档游戏事件时,都必须手动以正确的顺序输入每个步骤,这既耗时又容易出错。因此,我们将使用门面模式为我们的存档游戏系统建立一个简单的可重用接口。

代码示例

正如我们将要看到的,门面模式很简单,所以我们将保持以下代码示例简单直接:

  1. 首先,我们将为每个子系统的示例编写类:
  • Player:这个类代表我们的玩家组件:
using UnityEngine;

public class Player
{
    public int GetHealth()
    {
        return 10;
    }

    public int GetPlayerID()
    {
        return 007;
    }
}
  • ScoreManager:这个类负责管理评分系统;它将返回当前玩家的分数:
using UnityEngine;

public class ScoreManager
{
    public int GetScore(int playerId)
    {
        Debug.Log("Returning player score.");
        return 0;
    }
}
  • CloudManager:这个类负责管理当前玩家的云账户,包括上传他们的本地存档数据:
using UnityEngine;

public class CloudManager
{
    public void UploadSaveGame(string playerData)
    {
        Debug.Log("Uploading save data.");
    }
}
  • UIManager:最后,UI 管理器负责显示 UI 组件:
using UnityEngine;

public class UIManager
{
    public void DisplaySaveIcon()
    {
        Debug.Log("Displaying the save icon.");
    }
}
  1. 我们下一个重要的类是一个容器,它将保存我们想要保存的当前玩家的属性。请注意,它是Serializable——这是因为当我们将其保存到磁盘时,我们将序列化这个类的实例:
[System.Serializable]
class PlayerData
{
    public int score;
    public int playerID;
    public float health;
}
  1. 接下来是我们将作为外观实际使用的类。为了避免有一个长达十页的代码示例,我们将只专注于编写一个基本的SaveManager类:
using System.IO;
using UnityEngine;
using System.Runtime.Serialization.Formatters.Binary;

public class SaveManager : Singleton<SaveManager>
{
    private UIManager m_UIManager;
    private PlayerData m_PlayerData;
    private ScoreManager m_ScoreManager;
    private CloudManager m_CloudManager;

    public void SaveGame(Player player)
    {
        // 1 - Show the save icon on the corner of the screen.
        m_UIManager = new UIManager();
        m_UIManager.DisplaySaveIcon();

        // 2 - Initializing a new Player Data.
        m_PlayerData = new PlayerData();
        m_PlayerData.health = player.GetHealth();
        m_PlayerData.playerID = player.GetPlayerID();

        // 3 - Getting the player's high score.
        m_ScoreManager = new ScoreManager();
        m_PlayerData.score = m_ScoreManager.GetScore(player.GetPlayerID());

        // 4 - Let's serialize the player data.
        SerializePlayerData(m_PlayerData, true);
    }

    private void SerializePlayerData(PlayerData playerData, bool isCloudSave)
    {
        // Serializing the PlayerData instance      
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = File.Create(Application.persistentDataPath + "/playerInfo.dat");
        bf.Serialize(file, playerData);
        file.Close();

        // Uploading the serialized playerInfo.dat file 
        if (isCloudSave)
        {
            m_CloudManager = new CloudManager();
            m_CloudManager.UploadSaveGame(Application.persistentDataPath + "/playerInfo.dat");
        }
    }
}

正如我们所看到的,这个SaveManager类的简单示例展示了一个核心问题:保存玩家的进度有许多步骤和依赖。想象一下,如果我们每次想要触发保存游戏时都必须手动编写这些步骤,这将非常难以维护和调试。

  1. 我们可以在下面的Client类中看到外观模式在实际中的好处:
using UnityEngine;

public class Client : MonoBehaviour
{
    private Player m_Player;

    void Start()
    {
        m_Player = new Player();
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.S))
        {
            // Save the current player instance.
            SaveManager.Instance.SaveGame(m_Player);
        }
    }
}

现在,我们只需一行代码就可以从任何地方保存当前玩家的状态。这种好处之所以可能,是因为我们的SaveManager正在充当外观,为更大的代码库提供一个简化的接口。我们还本地化了整个保存游戏过程,这样我们就可以轻松地维护它。

摘要

我们现在工具箱中有了外观模式。它完美地适应了管理一个复杂代码库的现实,这个代码库拥有大量不断相互交互且相互依赖的子系统,就像大多数视频游戏一样。如果外观模式被明智地使用,而不是作为一种拐杖或掩盖混乱代码的方式,它可以成为你架构的基石。但最关键的一点是要记住,当你有一个依赖于多个子系统来运行的功能时,将那些依赖项本地化是一个好主意,这样你可以轻松地进行调试、维护和重构。

在下一章中,我们将探讨适配器模式,它是外观模式的一个近亲,但具有非常不同的设计和意图。

练习

在本章中,我们编写了SaveManager类的第一个草稿。作为一个练习,尝试为你自己的游戏编写一个完整的保存系统。如果你设计的是一个可以用于多个项目的可重用系统,这将是一项有价值的长期投资。从经验来看,我经常看到游戏项目在开发后期遇到困难,因为它们在早期没有建立一个稳固的保存和序列化系统,所以提前准备好一个可以随时使用的系统会非常有帮助。

进一步阅读

第十三章:适配器

在一个充满各种类型电缆和插头的世界里,我们都已经习惯了适配器的概念。适配器模式将是那些你容易掌握的模式之一,因为它与我们与技术的现实世界经验完美相关。适配器模式的名字完美地揭示了其核心目的;它通过在充当适配器的代码之间添加一个接口,为我们提供了一种无缝使用旧代码与新代码的方法。

本章将涵盖以下主题:

  • 我们将回顾适配器模式的基础知识。

  • 我们将使用适配器模式调整在线用户管理系统,而不修改任何代码。

技术要求

本章是一个实践章节;你需要对 Unity 和 C#有一个基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 封闭类

如果你对这个概念不熟悉,请在开始这一章之前复习一下。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

观看以下视频以查看代码的实际效果:

bit.ly/2UieM9v

适配器模式概述

如其名所示,适配器模式适配两个不兼容的接口;就像插头适配器一样,它不修改它调整的内容,而是将一个接口与另一个接口连接起来。当你处理无法因脆弱性而重构的遗留代码时,这种方法可能是有益的。

它们是实现适配器模式的两种主要方法;以下是一个快速分解:

  • 对象适配器:一种使用组合的简单方法

  • 类适配器:一种使用继承的更高级方法

同时尝试学习这两者可能会让人感到困惑,所以在这一章中,我们将通过实现对象适配器并简要回顾类适配器来尝试专注于适配器模式的核心目的。

让我们看看对象适配器和类适配器的并列图;核心差异可能很微妙,但相似之处很明显:

如你所见,在两种情况下,适配器类都位于客户端和被适配的类(适配者)之间。它们只是通过它们与适配者的关系而有所不同。

因此,对象适配器和适配器之间的核心差异如下:

  • 对象适配器通常包含一个适配者的实例,并将客户端的调用翻译成适配者;换句话说,它稍微像一个包装器。

  • 类适配器实现了预期的接口,同时继承了适配者;这是一种更高级的适配方法。

从经验来看,我发现适配器模式有时会与外观模式混淆。我们必须理解它们之间的核心区别是,外观模式提供了一个简单的接口来访问一组复杂的、相互依赖的子系统,而适配器模式则是将另一个类的接口适配,使其与客户端的期望保持一致。

因此,如果你试图通过一个单一接口适配多个类,那么你很可能实现的是外观模式而不是适配器模式。

好处和缺点

我认为适配器模式不是解决架构问题的长期解决方案;尽管它提供了一些好处,但其长期缺点始终应该被考虑。

以下是一些好处:

  • 无需修改即可适配:适配器模式的主要好处是它提供了一个在不修改代码的情况下适配代码的标准方法。

  • 可重用性和灵活性:这个模式允许以最小的更改继续使用遗留代码与新的系统一起使用;这立即带来了投资回报。

以下是一些缺点:

  • 持久化遗留代码:使用新系统与遗留代码一起使用是成本效益的,但从长远来看,可能会成为一个问题,因为旧代码可能会限制你的升级选项,因为它变得过时且与新版本的 Unity 或第三方库不兼容。

  • 轻微的性能损耗:因为你在对象之间重定向调用,可能会有轻微的性能损失。

从经验来看,将代码库从一个 Unity 版本迁移到另一个版本可能相当耗时。所以,如果你最终在电脑上安装了多个 Unity 版本,以便维护那些升级成本过高的遗留代码,请不要感到惊讶。

用例示例

假设我们正在处理一个典型的现实世界游戏开发场景。我们的主要在线程序员正在休假,并明确指示我们在他缺席期间不要对他的在线玩家管理系统进行任何修改。然而,我们的制作人需要对我们在线组件进行更改,因为他想向一位新投资者展示我们的直播服务。

为了按时完成这项工作,我们需要做出一些改变;因此,我们有两种选择:

  • 直接修改在线玩家管理系统,即使我们不是这段代码库的所有者,也不太了解它。

  • 找一种方法来扩展当前系统,并使用临时适配器实现所需更改,这将限制对我们同事代码的直接修改。

使用适配器模式,我们可以以结构化和一致的方式实现第二种选择。在下一节中,我们将通过一个简单的示例应用这个用例,这无疑将展示这个模式的有用性。

代码示例

正如我们提到的,我们将通过适配OnlinePlayer类来实施对我们在线玩家管理系统的一个更改,而不直接修改它。这个例子很简单,但根据经验,最好是通过首先实现最简单的系统来学习一个新的模式。

为了简洁起见,我们将适配以下OnlinePlayer类返回特定玩家全名的方式。请记住,我们无法重构或扩展这个类;我们只能适配它。我们将通过使用适配器模式的这两种主要形式来完成这项工作:

  • 对象适配器

  • 类适配器

对象适配器

以下OnlinePlayer类可以返回在线玩家的名字和姓氏,以及他们的全名。然而,实现这个类的程序员决定以正式的命名结构返回字符串。我们需要有一个标准序列的全名,即先名字后姓氏。

当然,我们可以单独调用名字和姓氏的GET函数,然后在客户端将它们连接起来,但这意味着我们可能需要在需要获取用户全名的地方都这样做。换句话说,我们失去了对全名返回方式的一致性和本地化控制。您可以想象,如果我们需要适配更复杂的东西,比如游戏中的货币交易系统,这可能会变得多么危险:

public sealed class OnlinePlayer : ScriptableObject
{
    public string GetFirstName(int id)
    {
        // Lookup online database.
        return "John"; // Retun a placeholder name.
    }

    public string GetLastName(int id)
    {
        // Lookup online database.
        return "Doe"; // Return a placeholder last name.
    }

    public string GetFullName(int id)
    {
        // Lookup online database and get full name 
        return "Doe Jonn";
    }
}

OnlinePlayer类中还有其他一些重要的事情需要注意;它是sealed,这意味着我们不能将其用作基类。因此,我们不能直接扩展它,所以适应它是我们的唯一选择:

  1. 让我们构建一个适配器类来修复我们GetFullName()函数的问题:
using UnityEngine;

public class OnlinePlayerObjectAdapter : ScriptableObject
{
    public string GetFullName(OnlinePlayer onlinePlayer, int userId)
    {
        return onlinePlayer.GetFirstName(userId) + " " + onlinePlayer.GetLastName(userId);
    }
}

如您所见,OnlinePlayerObjectAdapter类接收一个OnlinePlayer类的实例,并包装GetFullName()方法,因此返回预期的全名格式。因此,我们并没有修改或扩展被适配的类的行为,而只是调整它以满足客户端的期望。

  1. 让我们实现一个Client类来测试我们的实现:
using UnityEngine;

public class Client : MonoBehaviour
{
    private OnlinePlayer m_OnlinePlayer;
    private OnlinePlayerObjectAdapter m_OnlinePlayerAdapter;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.U))
        {
            m_OnlinePlayer = ScriptableObject.CreateInstance<OnlinePlayer>();
            m_OnlinePlayerAdapter = ScriptableObject.CreateInstance<OnlinePlayerObjectAdapter>();

            string FirstName = m_OnlinePlayer.GetFirstName(79);
            string LastName = m_OnlinePlayer.GetLastName(79);

            string FullNameLastFirst = m_OnlinePlayer.GetFullName(79);
            string FullNameFirstLast = m_OnlinePlayerAdapter.GetFullName(m_OnlinePlayer, 79);

            Debug.Log(FirstName);
            Debug.Log(LastName);
            Debug.Log(FullNameLastFirst);
            Debug.Log(FullNameFirstLast);
        }
    }
}

现在我们有了适配器,我们可以访问OnlinePlayer类中GetFullName()函数的原始实现,以及它的一个适配版本。这种方法为我们提供了很多灵活性,风险最小,因为我们没有修改任何东西,而只是进行了适配。

在本节中,我们实现了一个简单的对象适配器示例。在下一节中,我们将通过实现类适配器来回顾一个更复杂的适配器方法。

类适配器

在本节中,我们将在我们的OnlinePlayer类中修改一个细节;我们将移除密封修饰符,因为我们希望能够继承OnlinePlayer类。所以,让我们假装它从一开始就不在那里:

public class OnlinePlayer : ScriptableObject
{
    public string GetFirstName(int id)
    {
        // Lookup online database.
        return "John"; // Retun a placeholder name.
    }

    public string GetLastName(int id)
    {
        // Lookup online database.
        return "Doe"; // Return a placeholder last name.
    }

    public string GetFullName(int id)
    {
        // Lookup online database and pull the full name in this sequence [Last Name & First Name].
        return "Doe Jonn";
    }
}

要实现类适配器方法,让我们遵循以下步骤:

  1. 让我们先为我们的客户端实现一个目标接口;我们将称之为IOnlinePlayer
public interface iOnlinePlayer
{
    string GetFirstName(int userID);
    string GetLastName(int userID);
    string GetFullNameLastFirst(int userID);
    string GetFullNameFirstLast(int userID);
}

你应该注意到,我们通过添加一个新的接口来适配OnlinePlayer类,这将暴露我们正在改进的类的新的功能。这种方法是灵活的,正如你将在以下步骤中看到的那样。

  1. 现在,在我们的适配器类中,我们将实现IOnliePlayer接口:
public class OnlinePlayerClassAdapter : OnlinePlayer, iOnlinePlayer
{
    public string GetFullNameLastFirst(int userId)
    {
        return GetFullName(userId);
    }

    public string GetFullNameFirstLast(int userId)
    {
        return GetFirstName(userId) + " " + GetLastName(userId);
    }
}

它看起来很简单,但有很多事情在进行中。让我们尝试解开这个谜团:

  • OnlinePlayerClassAdapter正在实现IOnlinePlayer接口。

  • OnlinePlayerClassAdapter也继承了OnlinePlayer类。

  • 由于我们继承了OnlinePlayer类,GetFirstName()GetLastName()默认实现。

  • OnlinePlayerClassAdapter只需要显式实现GetFullNameLastFirst()GetFullNameFirstLast()

  • GetFullNameLastFirst()将调用重定向到OnlinePlayer父类内部实现的GetFullName()

  • GetFullNameFirstLast()实际上适配了向客户端返回全名的方式。

  1. 让我们看看我们如何利用Client类来利用这一点:
using UnityEngine;

public class Client : MonoBehaviour
{
    private iOnlinePlayer m_OnlinePlayer;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.U))
        {
            m_OnlinePlayer = ScriptableObject.CreateInstance<OnlinePlayerClassAdapter>();

            string FirstName = m_OnlinePlayer.GetFirstName(79);
            string LastName = m_OnlinePlayer.GetLastName(79);

            string FullNameLastFirst = m_OnlinePlayer.GetFullNameLastFirst(79);
            string FullNameFirstLast= m_OnlinePlayer.GetFullNameFirstLast(79);

            Debug.Log(FirstName);
            Debug.Log(LastName);
            Debug.Log(FullNameLastFirst);
            Debug.Log(FullNameFirstLast);
        }
    }
}

我们将客户端与适配类解耦,因为我们只需要在m_OnlinePlayer成员变量的赋值过程中将其指向适配器。对于客户端来说,与适配的OnlinePlayer类的交互相对透明,并且与之前的实现保持一致。

换句话说,我们能够在不修改OnlinePlayer类的同时,保持一致的接口来适配它。这就是适配器模式的核心目的。

总结

在本章中,我们将适配器模式添加到我们的工具箱中。这是一种在领域内非常有用的模式,因为对于专业程序员来说,最大的挑战之一就是处理遗留代码,这些代码通常由你不知道的人维护。因此,以一致的方式适配他人的代码而不造成回归和不必要的更改,是长期职业生涯和良好声誉的秘诀。

在下一章中,我们将回顾装饰器,这是一种更复杂和高级的结构模式。

练习

在本章中,我们实现了适配器模式的一个简单用例,但其投资回报在于将遗留代码适应到新的环境中。作为一个练习,我建议检查你的 Unity 项目,寻找可以适配到另一个项目中而不需要修改的组件或系统。

进一步阅读

第十四章:装饰器

装饰器是那些名称完美代表其目的的罕见模式之一。正如其名所示,装饰器模式允许我们装饰一个对象;当然,这是一个非常模糊的解释。所以,一个更具体但简单的核心目的解释是,它为我们提供了一种用新代码装饰旧代码的方法,通过动态地向对象添加功能。

本章将涵盖以下主题:

  • 我们将回顾装饰器模式的基本知识

  • 我们将构建一个系统,以动态地向步枪添加附件

技术要求

本章是实践性的,因此您需要对 Unity 和 C#有一个基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 构造函数

如果你对这个概念不熟悉,请在开始这一章之前复习它。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频,以查看代码的实际效果:

bit.ly/2U0MT6x

装饰器模式的基本知识

装饰器模式是你需要通过代码实现以完全理解的模式类型,因此我们将保持理论部分简短。在其最基本的形式中,装饰器模式为我们提供了一种机制,允许我们在运行时向对象添加行为,而无需在过程中更改对象。

正如其名所示,它装饰对象;但它通过在基类的构造函数中链式引用装饰器对象来实现。这听起来可能很抽象,但在实践中它是有效的,因为对象在内存中相互引用的方式。

让我们看一下以下图表,以可视化装饰器模式中类之间的关系结构:

图片

如您所见,有一个接口IRifle,它为所有希望成为步枪的类提供了一个实现合同。但,图中最重要的类是RifleDecorator。它是允许我们将WithScopeWithStabilizer装饰器附加到实现IRifle的任何步枪对象的类。

但是,在编写代码之前,让我们回顾一下使用装饰器模式时的核心优点和可能的缺点。

优点和缺点

装饰器模式享有极高的声誉;它甚至是 Python 语言的一个重要部分。因此,其优点通常超过了其缺点,正如以下列表所示:

以下是一些优点:

  • 替代子类化:装饰器模式侧重于向对象注入功能,而不是继承和扩展。

  • 可管理的排列组合:在生产的整个过程中,功能和需求都在不断变化;装饰者模式提供了一种通过分布到自包含组件中来添加功能的方法,而不需要修改核心实现。

  • 运行时动态性:装饰者模式允许我们在不直接修改对象的情况下在运行时向对象添加功能。

以下是一些缺点:

  • 代码复杂性:像大多数高级模式一样,实现装饰者模式可能会导致代码库变得更加复杂。

  • 关系复杂性:如果围绕一个对象有多个装饰者层,跟踪初始化链和装饰者之间的关系可能会变得非常复杂。

特定模式的缺点通常与它们给代码库带来的复杂性和冗余性有关。这主要是在一个由经验水平不同的成员组成的团队中工作时会遇到的问题,因为初级程序员可能没有通过阅读代码来识别特定模式的能力。

用例示例

武器是视频游戏的一个基本元素,尤其是在第一人称射击(FPS)类型中。在 FPS 游戏中拥有武器定制功能是一个非常酷且有利可图的特性。通过为新组件,如瞄准镜或消音器,附加到基本步枪上来升级它的能力,是非常吸引人的。但是,作为一个游戏程序员,必须以结构化和模块化的方式在代码中编写所有这些变体和配置,这可能会非常复杂。

但是,使用装饰者模式,我们可以在代码中重现将组件附加到可配置武器的现实生活概念。这正是我们将在下面的代码示例中做的。

适配器和装饰者模式相似,但适配器用于适配对象的接口,而装饰者增强了对象的责任。

代码示例

在下面的代码示例中,需要注意的一个特定事项是我们将使用构造函数。通常建议不要在 Unity 中使用它们,因为如果你正在使用Monobehaviours或其派生类的ScritableObjects,并将它们附加到场景中包含的GameObjects,引擎将自动初始化它们。但在这个例子中,我们将打破这个规则;主要是因为装饰者依赖于构造函数的内部机制,正如我们将在下面的代码片段中看到的:

  1. 让我们通过编写一个接口来开始我们武器定制系统的实现,这个接口将作为所有派生步枪类型的实现 合约
public interface IRifle
{
    float GetAccuracy();
}

如我们所见,这是一个简单的接口,它有一个返回步枪精度值的浮点数的函数。

  1. 现在我们已经为所有步枪对象定义了一个标准接口,让我们编写一个具体的步枪类,它将代表步枪的基本配置:
public class BasicRifle : IRifle
{
    private float m_BasicAccurancy = 5.0f;

    public float GetAccuracy()
    {
        return m_BasicAccurancy;
    }
}

BasicRifle类并没有做任何特别的事情;它只是实现了IRifle接口;但我们打算将其用作一个基础对象,我们将用附件来装饰它,从而提高其默认精度。

  1. 我们现在需要一个类来负责将装饰器附加到我们的BasicRifle对象上:
abstract public class RifleDecorator : IRifle
{
    protected IRifle m_DecoaratedRifle;

    public RifleDecorator(IRifle rifle)
    {
        m_DecoaratedRifle = rifle;
    }

    public virtual float GetAccuracy()
    {
        return m_DecoaratedRifle.GetAccuracy();
    }
}

我们在RifleDecorator类中实现了装饰器模式的核心。我们可以看到RifleDecorator类实现了IRifle接口,但有一个非常重要的细节需要注意。GetAccuracy()函数是虚拟的,这意味着RifleDecorator的任何派生类都能够重写它。

  1. 现在我们有了我们的装饰器类,让我们看看实际的装饰器对象如何在运行时将其自身附加到我们的BasicRifle对象上:
public class WithScope : RifleDecorator
{
    private float m_ScopeAccurancy = 20.0f;

    // Constructor
    public WithScope(IRifle rifle) : base(rifle) {}

    public override float GetAccuracy()
    {
        return base.GetAccuracy() + m_ScopeAccurancy;
    }
}

首先要注意的是构造函数;它接受一个IRifle类型的对象作为参数,然后调用其基类构造函数。这种做法乍一看可能非常混乱,但一旦我们实现了这个示例的客户端,它就会变得清晰。另一个需要注意的细节是,我们正在重写GetAccuracy()函数,但在返回路径中通过添加m_ScopeAccurancy到基值来改变整支枪的整体精度。

  1. 为了展示装饰器模式的灵活性,让我们在我们的示例中添加另一个装饰器:
public class WithStabilizer : RifleDecorator
{
    private float m_StabilizerAccurancy = 10.0f;

    // Constructor
    public WithStabilizer(IRifle rifle) : base(rifle) {}

    public override float GetAccuracy()
    {
        return base.GetAccuracy() + m_StabilizerAccurancy;
    }
}

WithStabilizer的实现与WithScope相同,只是它返回的最终精度值不同。

  1. 现在,是时候实现客户端了;这是我们触发装饰器模式装饰功能的地方:
using UnityEngine;

public class Client : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown("b"))
        {
            IRifle rifle = new BasicRifle();
            Debug.Log("Basic accuracy: " + rifle.GetAccuracy()); 
        }

        if (Input.GetKeyDown("s"))
        {
            IRifle rifle = new BasicRifle();
            rifle = new WithScope(rifle);
            Debug.Log("WithScope accuracy: " + rifle.GetAccuracy()); 
        }

        if (Input.GetKeyDown("t"))
        {
            IRifle rifle = new BasicRifle();
            rifle = new WithScope(new WithStabilizer(rifle));
            Debug.Log("Stabilizer+Scope accuracy: " + 
            rifle.GetAccuracy()); 
        }
    }
}

在这个类中最重要的元素是以下行中的构造函数调用链:

rifle = new WithScope(new WithStabilizer(rifle));

通过这一行代码,我们基本上通过链式调用基类构造函数,将WithScopeWithStabilizer装饰器附加到BasicRifle实例上。

因此,我们现在可以根据附加到BasicRifle实例的附件数量来获取不同的精度输出,如下所示。

以下代码返回精度为5.0f

IRifle rifle = new BasicRifle();
rifle.GetAccuracy();

以下代码返回精度为25.0f

IRifle rifle = new WithScope(rifle);
rifle.GetAccuracy();

以下代码返回精度为35.0f

IRifle rifle = new WithScope(new WithStabilizer(rifle));
rifle.GetAccuracy();

因此,通过使用装饰器模式,我们现在有一个动态武器定制系统的底层实现,我们可以扩展它来构建游戏武器运行时附件的集合。

摘要

在本章中,我们回顾了一个为游戏程序员提供灵活方式实现经常请求的功能——武器定制的模式。看起来装饰器模式非常适合完成这类任务。但正如你所想象的那样,装饰器可以用来实现所有类型的可定制系统和服务,例如以下内容:

  • 车辆升级

  • 装甲和服装

在下一章中,我们将从行为模式过渡出来,转而关注解耦器,从事件总线开始。

练习

在本章中,我们决定使用原生 C#构造函数,这是合适的,因为我们没有使用MonoBehavioursScritableObjects。但这种情况并不总是如此,因此,作为一个练习,你应该尝试重构我们刚刚完成的代码示例,但不使用任何构造函数,主要使用原生的 Unity MonoBehavioursScriptableObjects

你可以在官方 Unity API 文档的ScriptableObjects部分找到如何实现这一点的提示;请查阅进一步阅读部分以获取更多信息。

进一步阅读

第六部分:解耦模式

Unity 可能有一个强大的内置组件系统,但这不会阻止你在代码库中遇到紧密耦合的问题。你想要确保对象可以相互通信,而无需绑定到一起,以至于如果链中的某个元素缺失,整个系统都会崩溃。本节中的模式旨在帮助你解耦依赖关系,并提供一种编写更可扩展代码的方法。

本节包含以下章节:

  • 第十五章,事件总线

  • 第十六章,服务定位器

  • 第十七章,依赖注入

第十五章:事件总线

在本书的解耦部分,我们将回顾事件总线模式。但首先,我们需要解决经常出现在事件总线定义及其紧密相关的表亲事件队列定义之间的混淆。我们可以从它们的名字中快速总结出两者之间的核心区别。

公交车允许数据在不同组件之间流动,而队列收集需要按顺序间隔处理的数据列表。通过这个高级的公交车定义,我们可以得出结论,事件总线将专注于作为事件发布和广播的中心枢纽,而不是作为这些事件的队列。

因此,在本章中,我们将专注于构建一个事件总线,这将优化我们在 Unity 中解耦事件监听者和消费者事件的方式。

本章将涵盖以下主题:

  • 检查事件总线模式的基本原理

  • 实现一个可以适应任何游戏的全球消息系统

技术要求

事件总线是观察者模式的扩展,因此在开始这一部分之前,我建议您重新阅读第十章,观察者

我们还将使用以下特定的 Unity 引擎 API 功能:

  • UnityEvents

  • UnityActions

如果您对这些不熟悉,请查阅它们的官方 Unity API 文档,但请注意,我们将在本章的代码示例部分对其进行回顾。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际效果:

bit.ly/2OxHxto

事件总线模式概述

在事件总线模式周围可能存在一些混淆点。有时它被称为消息系统发布-订阅模式,后者是我们在本章中实现的最准确名称。但因为我们在这本书中采取了一种非常实际的方法,所以我们将会把这种模式的设计称为事件总线,这是一个更高级和系统化的名称。

正如我们在第九章中看到的,观察者,C#有原生的实现,通过提供事件驱动机制简化了集成事件的过程,允许主题和观察者相互通信。但 C#的原生事件系统确实有一个缺点——观察者需要意识到潜在主题的存在,否则可能会出现意外的行为。

因此,我们将学习如何使用事件总线——使用这种模式,我们将消除这种依赖关系,并使任何对象都能够发布事件并订阅它们,而无需彼此之间有任何直接依赖。因此,我们将从观察者/主题安排转变为更灵活的发布者/订阅者方法。

图片

让我们回顾一下事件总线图,并检查其元素:

如我们所见,有三个主要成分:

  • 出版商:这些对象可以请求中心管理特定事件并将它们广播给正确的听众

  • 事件中心:该对象负责协调发布者和订阅者之间的事件通信

  • 订阅者:这些对象将自己订阅到中心的广播事件频道,以便它们可以监听特定事件

优点和缺点

事件总线的优点和缺点相当适中——这是一个允许在不要求对代码库进行重大架构更改的情况下实现事件管理系统的模式:

优点如下:

  • 解耦系统:因为发布者和订阅者只通过事件总线进行通信,这减少了直接引用,并使对象彼此解耦

  • 广播频道:类似于电视或广播系统,您可以使用事件总线作为通过特定频道传输消息的方式,听众可以自行订阅

应注意的缺点如下:

  • 内存开销:在任何一个事件系统的底层,都有大量的低级内存机制被触发以管理对象之间的通信,因此如果您需要从每一帧中挤出毫秒级的处理时间,这可能不是最佳选择

示例用例

与本书中的其他模式不同,事件总线是一个功能完整且自成一体的系统。这一事实意味着我们可以在不将其映射到特定游戏系统的情况下实现它,并且它仍将成为我们游戏架构的一个关键组件。

我们几乎可以将事件总线视为一个全局服务,它为所有我们的组件提供了一个在特定频道上相互发送消息的方法。因此,在代码示例部分,我们将以原生形式实现事件总线,并确保它作为一项服务对所有我们的组件都是全局可访问的。

在开始代码示例部分之前,我建议回顾第六章,单例,因为我们打算将其用作事件总线类的基础。

代码示例

示例用例部分所述,我们将实现事件总线作为一个服务,所有我们的组件在需要向其他对象广播事件时都可以使用。

因此,让我们首先通过编写事件总线类来实现系统的核心:

using UnityEngine.Events;
using System.Collections.Generic;

public class EventBus : Singleton<EventBus>
{
    private Dictionary<string, UnityEvent> m_EventDictionary;

    public override void Awake()
    {
        base.Awake();
        Instance.Init();
    }

    private void Init()
    {
        if (Instance.m_EventDictionary == null)
        {
            Instance.m_EventDictionary = new Dictionary<string, UnityEvent>();
        }
    }

    public static void StartListening(string eventName, UnityAction listener)
    {
        UnityEvent thisEvent = null;
        if (Instance.m_EventDictionary.TryGetValue(eventName, out thisEvent))
        {
            thisEvent.AddListener(listener);
        }
        else
        {
            thisEvent = new UnityEvent();
            thisEvent.AddListener(listener);
            Instance.m_EventDictionary.Add(eventName, thisEvent);
        }
    }

    public static void StopListening(string eventName, UnityAction listener)
    {
        UnityEvent thisEvent = null;
        if (Instance.m_EventDictionary.TryGetValue(eventName, out thisEvent))
        {
            thisEvent.RemoveListener(listener);
        }
    }

    public static void TriggerEvent(string eventName)
    {
        UnityEvent thisEvent = null;
        if (Instance.m_EventDictionary.TryGetValue(eventName, out thisEvent))
        {
            thisEvent.Invoke();
        }
    }
}

如我们所见,我们正在将我们的类变成一个单例(Singleton),这将允许我们的EventBus实例全局可访问。但我们需要注意的最关键元素是,我们正在使用两个新的特定 Unity API 功能:UnityEventUnityAction

UnityEventUnityAction是.NET 原生委托类型的 API 包装器。在底层,它们的行为几乎与常规委托完全相同,但它们提供了 Unity 特有的额外功能,例如以下内容:

  • 检查器访问

  • 持久回调

我们在示例中使用它们是为了简化,同时确保我们最大限度地利用 Unity API 的功能。

对于UnityEvent提供的特定功能的更详细信息,请参阅进一步阅读部分的官方 API 文档。

如果我们将类进一步分解,我们可以看到四个核心函数使事件中心(Event Hub)功能得以实现:

  • Init(): 这个函数初始化一个字典,该字典将存储内存中的事件,这些事件是Subscribers注册的。

  • StartListening(): 这是一个Listeners使用的函数,用于将自己注册为监听特定事件。

  • StopListening(): 这个函数允许Listeners停止监听特定事件。

  • TriggerEvent(): 这个函数将触发一个事件并将其广播给所有监听者。

理论上,我们的工作已经完成——通过一个类,我们能够实现一个全局可访问的事件总线(Event Bus),它可以管理对象之间的事件通信。因此,现在我们唯一要做的就是编写一个发布者(Publisher)对象的示例,以及几个订阅者(Subscribers)来测试我们新的事件总线服务。

让我们从发布者开始,因为没有发布者,我们的Listeners除了沉默之外将没有可以监听的内容。我们将实现一个简单的发布者,根据用户输入触发特定事件的广播:

using UnityEngine;

public class EventPublisher : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown("s"))
        {
            EventBus.TriggerEvent("Shoot");
        }

        if (Input.GetKeyDown("l"))
        {
            EventBus.TriggerEvent("Launch");
        }
    }
}

我们的EventPublisher类很简单——它要求事件中心根据用户输入广播LaunchShoot事件。这种实现意味着任何监听名为LaunchShoot事件的Listeners都将被触发。

为了验证这个功能,让我们实现两个监听者,每个监听者都有不同的职责:

  • Rocket:这个类监听Launch命令事件,当它接收到这个命令时,将触发发射序列:
using UnityEngine;

public class Rocket : MonoBehaviour
{
    private bool m_IsQuitting;
    private bool m_IsLaunched = false;

    void OnEnable()
    {
        EventBus.StartListening("Launch", Launch);
    }

    void OnApplicationQuit()
    {
        m_IsQuitting = true;
    }

    void OnDisable()
    {
        if (m_IsQuitting == false)
        {
            EventBus.StopListening("Launch", Launch);
        }
    }

    void Launch()
    {
        if (m_IsLaunched == false)
        {
            m_IsLaunched = true;
            Debug.Log("Received a launch event : rocket launching!");
        }
    }
}
  • Cannon: 与Rocket类类似,Cannon监听Shoot命令,并在接收到消息时触发射击机制:
using UnityEngine;

public class Cannon : MonoBehaviour
{
    private bool m_IsQuitting;

    void OnEnable()
    {
        EventBus.StartListening("Shoot", Shoot);
    }

    void OnApplicationQuit()
    {
        m_IsQuitting = true;
    }

    void OnDisable()
    {
        if (m_IsQuitting == false)
        {
            EventBus.StopListening("Shoot", Shoot);
        }
    }

    void Shoot()
    {
        Debug.Log("Received a shoot event : shooting cannon!");
    }
}

监听者只需要通过调用StartListening()函数并指定他们想要监听的事件名称以及回调函数来注册自己为特定事件的监听者。事件总线将负责协调将事件广播给正确的监听者,并在需要时触发它们各自的回调函数。

我们还需要解决其他问题。请注意,我们在调用EventBus.StopListening()函数之前,使用OnApplicationQuit()检查应用程序是否正在退出,并验证m_IsQuitting的布尔值为false。这种方法是为了避免在应用程序退出后调用可能不再存在于内存中的对象。

总之,事件总线(Event Bus)在某种程度上几乎执行着与主板总线相似的功能,因为它在充当着不同组件之间的通信系统。但即使是像我们刚刚实现的这样一个简单的事件总线,如果需要的话,也可以扩展成一个更复杂的系统,比如事件队列(Event Queue)或多通道消息系统(multi-channel Messaging System)。

摘要

在本章中,我们回顾并实现了事件总线(Event Bus),这是一种关注解耦广播事件的对象和监听这些事件的对象之间关系的模式。通过利用新的原生 Unity API 功能,如UnityEvents,我们能够以最少的代码量快速实现这一模式。

在下一章中,我们将回顾服务定位器(Service Locator),这是另一种关注解耦依赖项之间复杂关系的模式,但这次是通过提供一种对象定位服务的方法来实现的。

我鼓励任何 Unity 程序员花时间阅读整个引擎的 API 文档,目前这些文档可在 Unity 的官方网站上找到,并尽可能多地记住其中的内容。这项练习将使你更加了解它提供的功能,甚至可能使你成为更快的程序员。对这一 API 的深入了解也会给你的同事或潜在的未来的面试官留下深刻印象。

练习

如本章开头所述,我们决定专注于事件总线模式,而不是它的近亲事件队列(Event Queue)。但是,总线机制可以被转换成队列。因此,作为一个实际练习,我建议将我们刚刚完成的事件总线示例进行转换,而不仅仅是将触发的事件转发给订阅者,它应该将它们保存在队列中以便按顺序处理。

您可以参考本章“进一步阅读”部分中指出的材料以获取灵感。

进一步阅读

第十六章:服务定位器

服务定位器是一个非常简单的模式,其名称完美地暗示了其目的,即定位服务。在游戏开发中,服务通常是提供特定功能的游戏机制相关的系统——例如:生成器、保存状态和在线连接。因为游戏主要由游戏内系统层组成,这些系统层相互通信、运行和同步以模拟交互式体验,所以服务定位器在系统组件之间创建了大量的依赖关系。

因此,通过一个中央定位器让服务相互查找可以简化组件之间的通信,同时避免显式引用系统可能需要以正确运行所需的依赖项的位置。这正是服务定位器模式所提供的:程序核心服务的全局访问点和注册表。

在本章中,我们将涵盖以下主题:

  • 服务定位器模式的基本原理

  • 实现一个作为运行时链接器的全局服务定位器

技术要求

下一章是实践性的,因此你需要对 Unity 和 C#有一个基本的了解。

我们将使用以下 Unity 特定的引擎和 C#语言概念:

  • 泛型

  • 单例

如果你对这些概念不熟悉,请回顾第六章,单例

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际效果:

bit.ly/2U8Mb6H

服务定位器概述

服务定位器是一个简单的模式,它背后没有太多的学术理论,所以我们可以说它属于实用模式的范畴。正如其名称所暗示的,它为客户端定位服务;它通过维护一个提供特定服务的类别的中央注册表来实现这一点。这个注册表可以在运行时通过服务在可用时注册自己来动态更新。

服务定位器的另一个常见组件是其本地缓存,它使用与对象池相同的原理。服务定位器可能会在内存中保留其最常请求的服务实例,以避免使用过多的内存。

让我们回顾一个典型服务定位器实现的图解:

如我们所见,我们可以很容易地说,服务定位器在客户端(请求者)和服务提供者之间充当代理,这意味着两者之间的关系是解耦的。客户端只有在需要解决依赖项时才需要调用服务定位器

重要的是要记住,软件架构术语“客户端”通常用来描述使用另一个类或其他类的功能性的类,它与应用程序的最终用户无关。系统可以是其他系统的客户端,而不需要任何人为输入。

好处和缺点

服务定位器是一个较新的模式;与更传统的模式相比,它在行业中的声誉相当有限。

以下是使用 Service Locator 的好处:

  • 运行时优化:Service Locator 可以通过动态检测根据上下文更好的库或组件来优化应用程序。

  • 上下文运行时定位器:内存中可以存在多个 Service Locators,每个 Service Locator 都针对特定的运行时上下文进行配置,例如测试、预发布和生产。

  • 比依赖注入更简单:Service Locator 比依赖注入(DI)驱动的架构更容易实现,主要是因为它是一种集中式管理依赖的方法。

以下是使用 Service Locator 的缺点:

  • 黑盒化:注册表中的服务可能对系统中的其他组件不可见。这种方法可能会使检测错误或回归变得更困难。

  • 安全漏洞:根据代码库的整体架构,Service Locator 可能会允许注入代码,这些代码可能会利用您的系统。

  • 全局可访问:如果实现为 Singleton,Service Locator 可能会遭受与全局可访问的管理器和组件相同的问题,使得它们更难进行单元测试。

用例示例

我们的使用案例将非常直接,我们不会专注于特定的游戏内系统。相反,我们将专注于构建一个简单的 Service Locator,它将能够动态地将客户端与服务链接起来:

  • 货币转换器:一个将游戏内货币转换为现实世界价值的服务

  • 照明协调员:一个管理我们场景中灯光的系统

  • 大厅协调员:一个与多人大厅协调以设置“死亡比赛”的服务

但是,当然,我们可以向注册表中添加许多可用的服务,但在这个例子中,我们将专注于这三个。

代码示例

正如我们从下面的代码示例中将要看到的,实现一个基本的 Service Locator 是一个简单直接的过程:

  1. 让我们先从实现最重要的成分开始:ServiceLocator类:
using System;
using System.Collections.Generic;

public class ServiceLocator : Singleton<ServiceLocator>
{
    private IDictionary<object, object> m_Services;

    public override void Awake()
    {
        base.Awake();
        FillRegistry();
    }

    private void FillRegistry()
    {
        m_Services = new Dictionary<object, object>();

        m_Services.Add(typeof(LobbyCoordinator), new 
        LobbyCoordinator());
        m_Services.Add(typeof(CurrencyConverter), new 
        CurrencyConverter());
        m_Services.Add(typeof(LightingCoordinator), new 
        LightingCoordinator());
    }

    public T GetService<T>()
    {
        try
        {
            return (T)m_Services[typeof(T)];
        }
        catch
        {
            throw new ApplicationException("The requested service is not found.");
        }
    }
}
  1. 这个版本的 Service Locator 有两个主要职责:

    • 使用FillRegistry()函数管理注册表

    • 使用GetService(T)函数向客户端返回指定的服务

这两个函数指的是以Dictionary形式存在的中央注册表。当然,我们可以将这些职责分离到单独的类中,而不是将它们封装在局部函数中,但在这个例子中,我们将保持简单。

现在我们已经设置了服务定位器,我们现在可以开始为我们的客户端实现一些服务。

  1. 我们的第一项服务是货币转换器;这在现代游戏中是必不可少的,考虑到它们通常包括游戏内购买和宝箱机制:
using UnityEngine;

public class CurrencyConverter
{
    public void ConvertToUsDollar(int inGameCurrency)
    {
        Debug.Log("Players in-game currency is worth 100$ US");
    }
}
  1. 我们的第二项服务是照明协调器;它负责管理场景中的所有灯光:
using UnityEngine;

public class LightingCoordinator
{
    public void TurnOffLights()
    {
        Debug.Log("Turning off all the lights.");
    }
}
  1. 我们的最后一项服务是大厅协调器;这确保了我们的玩家在需要时可以加入一个活跃的大厅:
using UnityEngine;

public class LobbyCoordinator
{
    public void AddPlayerToLobby()
    {
        Debug.Log("Adding a player to the lobby.");
    }
}

现在我们有三个服务,每个服务都有特定的职责,如果需要的话,可供客户端使用。但我们有一个明显的限制:目前,我们只能手动将服务添加到中央注册表,当然,这不是生产代码的最佳方法;然而,对于我们测试服务定位器的第一次实现来说,这是可以接受的。稍后,作为一个实际练习,明智的做法是为服务提供者添加动态注册到服务注册表的功能:

    // TODO: We need to be able to fill the registry dynamically.    
    private void FillRegistry()
    {
        m_Services = new Dictionary<object, object>();
        m_Services.Add(typeof(LobbyCoordinator), new 
        LobbyCoordinator());
        m_Services.Add(typeof(CurrencyConverter), new 
        CurrencyConverter());
        m_Services.Add(typeof(LightingCoordinator), new 
        LightingCoordinator());
    }
  1. 现在,对于我们的最后一个类,我们将实现客户端:
using UnityEngine;

public class ClientServiceLocator : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown("o"))
        {
            ServiceLocator.Instance.GetService<LightingCoordinator>
            ().TurnOffLights();
        }

        if (Input.GetKeyDown("c"))
        {
            ServiceLocator.Instance.GetService<CurrencyConverter>
            ().ConvertToUsDollar(10);
        }

        if (Input.GetKeyDown("l"))
        {
            ServiceLocator.Instance.GetService<LobbyCoordinator>
            ().AddPlayerToLobby();
        }
    }
}

一旦我们实现了客户端代码,我们就能欣赏到服务定位器的优势。我们现在能够访问代码库中的任何核心服务,而无需知道其类的位置或如何初始化它。我们有一个全局但简单的接口,可以从任何地方查询,它动态地将客户端与服务链接起来,同时解耦整个定位服务和初始化的过程。

摘要

在本章中,我们回顾了服务定位器模式,这是一种全局解决方案,可以解决依赖服务(功能)的对象之间管理的重复挑战。在其最简单形式中,服务定位器解耦了客户端(请求者)与服务提供者之间的关系。但在其最先进形式中,如果扩展到具有本地缓存的本地缓存,它还可以优化内存使用,当需要时可以重用提供者的实例。

在下一章中,我们将探讨依赖注入(DI)模式,可以说它在方法上与服务定位器相反,但具有相似的目的。

练习

在代码示例中,我们实现了一个简单的服务定位器版本,以便我们可以清楚地了解其核心意图和设计。但作为一个练习,我建议你将这个基本的服务定位器草案扩展成一个可以投入生产的版本,方法如下:

  • 将注册表和缓存组件封装到自包含的类中。

  • 使用工厂模式和原型模式组合实现缓存。

  • 实现服务动态添加到注册表的能力。

  • 为你的服务提供者编写一个标准接口,以便你可以有效地管理它们。

将设计模式视为爵士乐手对待旋律的做法是一种良好的实践。一旦你理解了一个模式的核心理念,就扩展它,即兴发挥,使其成为你自己的,同时保持对其基本设计的准确性。

进一步阅读

控制反转容器和依赖注入 模式,由马丁·福勒撰写(martinfowler.com/articles/injection.html#UsingAServiceLocator)

第十七章:依赖注入

当我作为一名网页开发者工作时,我第一次接触到了 依赖注入DI)模式,并且我已经使用了多年。然而,我注意到 DI 在游戏行业中并不为人所知。我怀疑这是因为这是一个为了解决面向业务应用程序的设计问题而开发的模式,而不是用于高性能软件,如视频游戏。

正如其名称所暗示的,DI 是关于注入依赖项的;一开始可能听起来有些抽象,但它是一个相当简单的概念。类通常需要其他类的实例来完成特定的功能。因此,而不是让类自己初始化其依赖项,我们通过其构造函数或其函数中的一个参数来注入它们。这种方法解耦了类之间的显式关系,并使得测试我们的代码变得更容易,因为我们可以轻松地注入模拟对象来执行单元测试。

正如你在本书中将会看到的,DI 有其局限性,并且它不一定与 Unity 的编程环境兼容。当你开始引入更高级的 DI 版本,即 控制反转IoC)容器时,这一点将变得特别明显。

本章将涵盖以下主题:

  • 我们将回顾 DI 模式的 fundamentals

  • 我们将探讨 IoC 容器背后的核心概念以及它们与 DI 的关系

  • 我们将解决由允许自定义赛车游戏中的超级摩托车初始配置的功能实现引起的依赖项问题

技术要求

下一章是实践性的;你需要对 Unity 和 C# 有基本的了解。

我们将使用以下特定的 Unity 引擎和 C# 语言概念:

  • 接口

  • 构造函数

如果你对这些概念不熟悉,请在继续之前先复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频以查看代码的实际应用:

bit.ly/2Oww7WM

依赖注入概述

正如其名称所暗示的,DI 模式的核心目的是将依赖项注入需要它们的类中。使用 DI 实现这一点有三种方法,如下所示:

  • 构造函数注入:我们通过类的构造函数注入依赖项。

  • 设置器注入:我们通过类的函数参数注入依赖项。

  • 接口注入:依赖项的接口提供了一个注入方法,将依赖项传递给客户端。

在本章中,我们只将回顾构造函数和设置器技术,因为它们是最常见的。

依赖通常是提供特定服务的类,其他类可以利用这些服务来完成特定功能。一个经典的例子是负责建立数据库连接以执行查询的管理器类。为了履行这一职责,数据库管理器依赖于特定数据库类型的接口类。

为了避免数据库管理器每次需要连接到特定类型的数据库时都检索和初始化特定的依赖项,我们可以在需要时提供它们。换句话说,我们正在解耦依赖与其依赖项之间的关系。

UML 图并不是描述 DI 模式目的的最佳工具,但让我们回顾一个简化的图,概述我们将要实现作为用例的内容:

图片

上一个图中展示的案例与我们所讨论的数据库管理器示例类似。我们有一个名为 Bike 的类,它需要一个引擎才能正常运行。我们不是让 Bike 类根据特定条件初始化特定类型的引擎,而是在其构造函数中接受 IEngine 类型的参数。采用这种方法,Bike 可以接收任何实现了 IEngine 的具体类,例如在这个例子中,是 JetEngineNitroEngine

这种安排为我们提供了大量的可扩展性;我们可以编写几十种不同类型的引擎,每种都有其特定的功能,而 Bike 能够接受它们,而无需对其当前结构进行任何修改。

当然,DI 并非没有缺点,你将在下一节中看到。

DI 遵循 IoC 的核心原则,即反转系统的控制流。在 DI 的情况下,这是关于反转依赖管理的过程。遵循 IoC 原则的另一个模式是服务定位器,你可以在第十六章服务定位器中查阅。

优点和缺点

与单例模式一样,DI 模式也有些争议,其实际的优缺点经常受到争议。我怀疑这是因为其设计简单,程序员往往对看起来过于简单的东西持谨慎态度,因为通常太好了而不可能是真的。

优点如下:

  • 松散耦合:让类接收依赖实例而不是显式初始化它们,可以减少代码库中的紧密耦合。

  • 可测试的代码:DI 通过允许注入模拟对象来运行特定场景,使得运行测试变得更加容易。

  • 并发开发:DI 提供了一种解耦对象并强制通过接口进行通信的方法。这种方法使得程序员团队编写相互利用的类变得更加容易。

缺点如下:

  • 争议:DI 是一种在团队中引起很多争论的模式,因为最佳方法并不总是清晰的,尤其是在考虑更高级的 DI 形式,如 IoC 注入容器时。

  • 框架依赖:DI 的基本形式非常有限;一旦达到一定程度的复杂性,就必需实现第三方 IoC 框架以可配置的方式管理依赖注入。因此,代码库通常变得依赖于框架,并且难以轻易移除。

  • 意大利面代码:过度使用依赖注入(DI)和相关最佳实践可能导致代码库过度封装,并拆分成过多的单个类,这使得理解起来变得困难。

为了测试应聘者对技术主题的辩论能力,面试官通常会要求应聘者对一个有争议的模式,如 DI 和 Singleton,提出自己的看法。作为一个面试者,展示对任何问题的平衡观点,考虑到其优点和缺点,是一种良好的做法。

用例示例

假设我们正在开发一个设定在未来的赛车游戏,其中有超级摩托车。我们必须快速实现一个功能,玩家可以在比赛开始前从可用选项中选择引擎和驾驶员来自定义他们的自行车。换句话说,我们的自行车对象有两个特定的依赖项:一个引擎和一个驾驶员。使用 DI 模式,我们将管理这些依赖项,而不会给我们的代码库增加不必要的复杂性。

首先,我们将查看在类中管理依赖关系的错误方法,以便您能够理解与相反方法相比 DI 的优点。

没有 DI 的错误方法

在进入我们用例的实现阶段之前,让我们首先回顾一个使用考虑不周的初始化和管理依赖关系的类的示例:

using UnityEngine;

public class Bike : MonoBehaviour
{
    public enum EngineType
    {
        Jet,
        Turbo,
        Nitro
    };

    public enum DriverType
    {
        Human,
        Android
    };

    private Engine m_Engine;
    private Driver m_Driver;

    public void SetEngine(EngineType type)
    {
        switch (type)
        {
            case EngineType.Jet:
                m_Engine = new JetEngine();
                break;
            case EngineType.Turbo:
                m_Engine = new TurboEngine();
            case EngineType.Nitro:
                m_Engine = new NitroEngine();
        }

        Debug.Log("The bike is running with the engine: " + m_Engine);
    }

    public void SetDriver(DriverType type)
    {
        switch (type)
        {
            case DriverType.Human:
                m_Driver = new HumanDriver();
                break;
            case DriverType.Android:
                m_Driver = new AndroidDriver();
        }

        Debug.Log("The driver of the bike is a: " + driver);
    }

    public void StartEngine()
    {
        if (m_Engine != null)
        {
            // Start the bike's engine
            m_Engine.Start();
            // Give control of the bike to the driver
            m_Driver.Control(this);
        }
    }
}

初看,这似乎是一种合理的做法,但让我们想象一下,我们正在一个游戏程序员团队中工作,每个人都在实现新的引擎行为类型。如果我们想让Bike类支持这些行为,我们需要修改EngineType枚举,并更新SetEngine()方法体内的switch案例。如果多个程序员同时在这个类上工作,这种方法可能会随着时间的推移变得非常麻烦。

我们在SetDriver()函数上也遇到了同样的问题;在这种安排下,添加新的驾驶员类型将变得很麻烦,并且可能会容易出错。所以,让我们通过逐步使用 DI 作为基础来实现相同的类。

使用 DI 的正确方法

DI 的实现相当直接,这也是它的主要优点。因此,本节应该不会令人痛苦:

  1. 让我们从编写我们的 Bike 类开始;我们可以说它是这个依赖注入(DI)模式示例中的实际客户端,主要是因为它是那个在注入过程中依赖接收依赖项的类:
using UnityEngine;

public class Bike : MonoBehaviour
{
    private IEngine m_Engine;
    private IDriver m_Driver;

    // Setter injection
    public void SetEngine(IEngine engine)
    {
        m_Engine = engine;
    }

    // Setter injection
    public void SetDriver(IDriver driver)
    {
        m_Driver = driver;
    }

    public void StartEngine()
    {
        // Starting the engine
        m_Engine.StartEngine();

        // Giving control of the bike to a driver (AI or player)
        m_Driver.Control(this);
    }

    public void TurnLeft()
    {
        Debug.Log("The bike is turning left");
    }

    public void TurnRight()
    {
        Debug.Log("The bike is turning right");
    }
}

正如你所见,SetEngine()SetDriver() 函数并不知道它们接收到的具体是哪种引擎或驱动器——它们只知道它们期望的是它们的通用类型。换句话说,Bike 类不再负责其依赖项的初始化过程。这种方法非常灵活;我们可以编写无限数量的引擎类,每个类都有其特定的行为,如果我们保持与 IEngine 接口实现合同的兼容性,我们就不需要直接修改 Bike 类以使其适合使用新的引擎。

你还会注意到这种方法同样适用于 driver 依赖项。Bike 不需要知道是谁在驾驶;它只需要知道控制该实体的实体实现了 IDriver 接口,这样它们就可以相互通信。

为了测试目的,这种灵活性很有帮助;我们可以在运行时轻松注入模拟的 enginedriver 对象,并在 Bike 实现上运行一些自动化的单元测试。

  1. 现在,让我们编写我们两种主要依赖类型的接口:引擎和驱动器:
  • IEngine 接口如下所示:
public interface IEngine
{
    void StartEngine();
}
  • IDriver 接口如下所示:
public interface IDriver
{
    void Control(Bike bike);
}
  1. 在接下来的步骤中,我们将为我们的自行车需要正确运行的每种主要类型的组件编写所有具体的类:
  • JetEngine 类如下所示:
using UnityEngine;

public class JetEngine : IEngine
{
    public void StartEngine()
    {
        ActivateJetStream();
        Debug.Log("Engine started");
    }

    private void ActivateJetStream()
    {
        Debug.Log("The jet stream is activated");
    }
}
  • NitroEngine 类如下所示:
using UnityEngine;

public class NitroEngine : IEngine
{
    public void StartEngine()
    {
        OpenNitroValve();
        Debug.Log("Engine started");
    }

    private void OpenNitroValve()
    {
        Debug.Log("The nitro valve is open");
    }
}

需要注意的是,每个引擎都封装了其内部机制,同时保持与 IEngine 接口实现的兼容性。正是这种一致的方法允许了依赖注入(DI)。

  • HumanDriver 类如下所示:
using UnityEngine;

public class HumanDriver : IDriver
{
    private Bike m_Bike;

    public void Control(Bike bike)
    {
        m_Bike = bike;
        Debug.Log("A human (player) will control the bike");
    }
}
  • AndroidDriver 类如下所示:
using UnityEngine;

public class AndroidDriver : IDriver
{
    private Bike m_Bike;

    public void Control(Bike bike)
    {
        m_Bike = bike;
        Debug.Log("This bike will be controlled by an AI");
    }
}

HumanDriver 类旨在将 Bike 的控制权交给玩家,我们将在即将到来的 Client 类中实现这一点。AndroidDriver 类旨在支持一个能够驾驶 Bike 并在比赛中作为玩家对手的 AI 实体。

  1. 最后,我们的 Client 类,我们将用它来测试我们的系统,如下所示:
using UnityEngine;

namespace Pattern.DependencyInjection
{
    public class Client : MonoBehaviour
    {
        // Bike controlled by the player
        public Bike m_PlayerBike;

        // Bike controlled by an android (AI)
        public Bike m_AndroidBike;

        void Awake()
        {
            // Set up a bike with a human driver and jet engine
            IEngine jetEngine = new JetEngine();
            IDriver humanDriver = new HumanDriver();

            m_PlayerBike.SetEngine(jetEngine);
            m_PlayerBike.SetDriver(humanDriver);
            m_PlayerBike.StartEngine();

            // Set up a bike with a AI driver and a nitro engine
            IEngine nitroEngine = new NitroEngine();
            IDriver androidDriver = new AndroidDriver();

            m_PlayerBike.SetEngine(jetEngine);
            m_PlayerBike.SetDriver(humanDriver);
            m_PlayerBike.StartEngine();
        }

        void Update()
        {
            if (Input.GetKeyDown(KeyCode.A))
            {
                m_PlayerBike.TurnLeft();
            }

            if (Input.GetKeyDown(KeyCode.D))
            {
                m_PlayerBike.TurnRight();
            }
        }

        void OnGUI()
        {
            GUI.color = Color.black;
            GUI.Label(new Rect(10, 10, 500, 20), "Press A to turn LEFT and D to turn RIGHT");
            GUI.Label(new Rect(10, 30, 500, 20), "Output displayed in the debug console");
        }
    }
}

我们的 Client 类相当简单;在 Awake() 函数中,我们将依赖项注入到两个 Bike 类的实例中:m_PlayerBikem_AndroidBike。在 Update() 函数中,我们监听玩家的输入,允许他们控制 m_PlayerBike 实例。

这可能看起来非常直接且过于简单,但如果我们适度使用,这种模式提供了很多可扩展性和灵活性,同时复杂性很小。在下一节中,我们将回顾依赖注入(DI)的更高级形式,它使用 IoC 容器。

你可能已经注意到,我们没有在我们的代码示例中使用构造函数注入;这是因为我们正在使用一个 MonoBehaviour 类,我们没有访问其构造函数的权限。一些 Unity 开发者确实使用 Awake() 函数在初始化过程中注入依赖项。

使用 IoC 容器进行 DI

IoC 容器通常以框架的形式出现;它们的主要职责是自动化 DI 流程和管理依赖项的生命周期。在我们开始之前,重要的是要注意,大多数 IoC 容器并不是为了与 Unity 的编码模型兼容而设计的,我不建议使用它们。另一方面,了解它们的存在也很重要。

如我们本章开头所述,依赖注入(DI)是一种便捷且直接的模式,但它有其局限性。在我们刚刚实现的代码示例中,我们同时管理了两个依赖项的注入,但想象一下,如果我们有数十个依赖项分布在多个类中会怎样。在这种类型的背景下,DI 可能会成为你架构中的瓶颈。这时,IoC 容器就变得很有用,因为它们可以自动化管理所有这些注入的过程。

以下是对大多数 IoC 容器提供的功能的简要总结:

  • 注册:容器提供了一种注册依赖项并将它们正确映射到依赖项的方法。

  • 解析:容器负责通过初始化和注入来解析依赖项。

  • 释放资源:容器将管理对象的生命周期,包括在它们不再需要时释放它们。

这份对 IoC 容器的快速回顾的目的不是争论它们是否必要,而是让我们意识到简单的 DI 模式有其局限性。一旦我们在注入的依赖项的复杂性和密度达到一定程度,我们就需要考虑实现或集成一个 IoC 容器框架来管理这个过程。

总是小心不要让你的代码库依赖于第三方框架;你可能会发现自己陷入了供应商锁定反模式,我将在本书的最后一章中更详细地描述它。

概述

在本章中,我们回顾了 DI 模式,这是一个随着时间的推移而越来越受欢迎的简单模式。它的名声可以通过这样一个事实来解释,即它解决了每个程序员每天都会面临的常见挑战,即管理类之间的依赖关系。换句话说,只要你不过度使用,它就是你的工具箱中的一个强大工具。

在下一章中,我们将探讨对象池模式,这是移动游戏程序员非常喜欢的另一个实用工具。

实践练习

作为一项实际练习,我建议使用一个流行的 IoC 容器框架编写一个 C#应用程序。因为大多数框架与 Unity 引擎不兼容,我建议使用原生方式,在 Visual Studio 中编写一个简单的 Windows 应用程序。

进一步阅读 部分,我添加了一个流行的 IoC 容器框架列表。

进一步阅读

以下是一些可能用作参考的书籍:

以下是一些可以考虑的 IoC 框架:

第七部分:优化模式

游戏行业的程序员通过掌握优化来证明自己的能力并获得精通。Unity 可能在某些方面预先进行了优化,但这可能是一个陷阱,因为性能问题通常很微妙,只有在部署到目标平台后才会出现。因此,在制作过程中早期养成实施正确设计模式的习惯可能会让你免于在发布日之前长时间加班。

本节包含以下章节:

  • 第十八章,对象池

  • 第十九章,空间分区

第十八章:对象池

对象池模式很容易理解;正如其名所示,它组织了一个对象池。可视化这个模式背后的设计意图的最简单方式是想象一个装满了各种颜色气球的游泳池。如果你愿意,你可以拿出所有的绿色气球,玩一玩,完成后,再放回去。换句话说,你始终有一组特定类型的对象可供使用,并在之后有一个地方来存储它们。

如果我们将这个概念转化为代码,我们得到的是已经包含在内存中的特定类型的对象,当我们需要时可以从中提取,完成后再将它们池化回内存。这种方法意味着我们为特定类型的现成对象使用了一个恒定的预留内存大小。正如你可能已经预想的,这对于视频游戏项目来说是一种非常优化的对象管理方式。

本章将涵盖以下主题:

  • 我们将回顾对象池模式的基础知识

  • 我们将实现对象池模式来管理僵尸游戏的可重用游戏对象

技术要求

本章是实践性的;你需要对 Unity 和 C#有基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • 预制体

  • 单例

  • 命名空间

如果你对这些概念不熟悉,请在开始本章之前复习它们。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

观看以下视频,看看代码的实际应用:

bit.ly/2WutcB2

对象池模式的概述

对象池模式通常在学术文档中被定义为一种创建型设计模式,但在这本书中,我们将它归类为优化模式,因为其核心目的与优化比创建过程更契合——至少在我们将要使用它的方式上是这样。

这个模式的核心概念很简单;一个以容器形式存在的池子包含了一组初始化后的对象。客户端可以请求对象池提供特定类型和数量的对象。一旦客户端使用完毕,必须将对象返回给对象池。这意味着池子总是会被填满,而不会耗尽。

以下图表展示了ClientObjectPool之间的来回交互:

图片

如你所见,可重用对象始终在内存中;只是它的所有者会在客户端和ObjectPool之间切换,这取决于它是否处于获取或释放状态。

优点和缺点

对象池模式在 Unity 开发者中相当受欢迎,但在更广泛的软件开发社区中也有其批评者:

优点

  • 可预测的内存使用:由于对象池模式是可配置的,您可以设置特定对象实例数量的限制。

  • 性能提升:由于对象已经在内存中初始化,因此避免了初始化新对象时的加载成本。

缺点:

  • 在已管理的内存上分层:有些人批评对象池模式在大多数情况下是不必要的,因为现代托管编程语言如 C#已经最优地控制内存分配。

  • 不可预测的对象状态:对象池模式的主要缺陷在于,如果管理不当,对象将不会返回到其默认状态,而是返回到当前状态。这可能导致在下次从池中取出对象时出现不可预测的行为,因为对象可能处于意外的状态。

对比于普通的 PC 或游戏机,对象池对于移动游戏来说具有很多附加价值,因为手机内存和资源有限。

用例示例

与其他创建型模式一样,对象池在设计生成系统时可以是一个很好的模式。但这次,我们将专注于客户端直接从对象池中拉取和池化,而不在它们之间有任何抽象层,这样我们就可以看到系统的工作情况。

为了给下面的代码示例提供一些背景信息,让我们想象我们正在制作一个《植物大战僵尸》的克隆版。像大多数僵尸游戏一样,我们有成群的僵尸向目标移动。每个群体可以包含各种类型的僵尸,例如以下几种:

  • Walkers

  • Runners

但是,重要的是要考虑的是,每次我们的玩家杀死其中一个僵尸时,我们不必在内存中销毁被击败僵尸的实例,而是可以将其送回对象池,以便之后再次使用。采用这种方法,我们正在回收僵尸类型的实体,而不是初始化新的实体。

在下一节中,我们将实现此用例并对其进行调整,以便它与 Unity 的 GameObject 一起工作。

代码示例

仅通过阅读代码可能难以理解本章内容,因为我们正在场景内部管理预制体。因此,我们建议您在我们的 Git 仓库github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018上下载与本书相关的 Unity 项目。

在 Unity 项目中,应该有一个名为 Object Pool 的文件夹,其中包含一个场景,该场景将包含运行此示例所需的全部依赖项。

但是,如果 GitHub 地址不可用,以下是一个快速步骤列表,用于在 Unity 场景中执行以下代码示例:

  1. 打开一个新的场景,添加一个空的GameObject,并将其命名为Object Pool

  2. 为每种僵尸类型创建一组预制体和相关脚本。以下是一些僵尸行为脚本的示例:

  • Runner:
using UnityEngine;

namespace Zombie
{
    public class Runner : MonoBehaviour
    {
        public void Run()
        {
            // Zombie runs!
        }
    }
}
  • Walker:
using UnityEngine;

namespace Zombie
{
    public class Walk: MonoBehaviour
    {
        public void Walk()
        {
            // Zombie walks!
        }
    }
}
  • Screamer:
using UnityEngine;

namespace Zombie
{
    public class Screamer : MonoBehaviour
    {
        public void Scream()
        {
            // Zombie screams!
        }
    }
}
  1. ObjectPool脚本附加到GameObject对象池。

  2. GameObject对象池的检查器中,配置ObjectPool实例将要管理的僵尸预制体列表。你可以参考以下截图作为参考:

现在,是我们编写ObjectPool类的时候了。正如你将看到的,它相当长,因此我们阅读它并随后审查其核心功能是至关重要的:

using UnityEngine;
using System.Collections.Generic;

public class ObjectPool : Singleton<ObjectPool>
{
        // The objects to pool.
        public GameObject[] objects;

        // The list of pooled objects.
        public List<GameObject>[] pooledObjects;

        // The amount of objects to buffer.
        public int[] amountToBuffer;

        public int defaultBufferAmount = 3;

        // The container of pooled objects.
        protected GameObject containerObject;

        void Start()
        {
            containerObject = new GameObject("ObjectPool");
            pooledObjects = new List<GameObject>[objects.Length];

            int i = 0;
            foreach (GameObject obj in objects)
            {
                pooledObjects[i] = new List<GameObject>();

                int bufferAmount;

                if (i < amountToBuffer.Length)
                {
                    bufferAmount = amountToBuffer[i];
                }
                else
                {
                    bufferAmount = defaultBufferAmount;
                }

                for (int n = 0; n < bufferAmount; n++)
                {
                    GameObject newObj = Instantiate(obj) as GameObject;
                    newObj.name = obj.name;
                    PoolObject(newObj);
                }

                i++;
            }
        }

        // Pull an object of a specific type from the pool.
        public GameObject PullObject(string objectType)
        {
            bool onlyPooled = false;
            for (int i = 0; i < objects.Length; i++)
            {
                GameObject prefab = objects[i];

                if (prefab.name == objectType)
                {
                    if (pooledObjects[i].Count > 0)
                    {
                        GameObject pooledObject = pooledObjects[i][0];
                        pooledObject.SetActive(true);
                        pooledObject.transform.parent = null;

                        pooledObjects[i].Remove(pooledObject);

                        return pooledObject;
                    }
                    else if (!onlyPooled)
                    {
                        return Instantiate(objects[i]) as GameObject;
                    }

                    break;
                }
            }

            // Null if there's a hit miss.
            return null;
        }

        // Add object of a specific type to the pool.
        public void PoolObject(GameObject obj)
        {
            for (int i = 0; i < objects.Length; i++)
            {
                if (objects[i].name == obj.name)
                {
                    obj.SetActive(false);
                    obj.transform.parent = containerObject.transform;
                    pooledObjects[i].Add(obj);
                    return;
                }
            }

            Destroy(obj);
        }
    }

因此,以下是对ObjectPool类中每个函数所发生情况的快速概述:

  • Start(): 在此函数中,我们正在初始化一个list(),它将包含我们的池化对象实例。

  • PullObject(): 通过调用此函数,客户端可以通过指定对象类型的名称来请求池中的对象实例。如果我们有存储,我们返回其实例;如果没有,我们初始化它的一个新实例。

  • PoolObject(): 客户端可以使用此函数将对象实例返回到池中。ObjectPool将使返回的对象失效,并将其作为子对象附加回自身,以便将其包含在内。

我们还可以看到,我们的ObjectPool类是一个单例;这是一种常见的做法,因为我们通常需要全局访问对象池,并且始终可用。要了解如何实现单例,请参阅第六章,单例

下一步是使用Client类测试我们的ObjectPool

using UnityEngine;

public class Client : MonoBehaviour
{
    void Update()
    {
            if (Input.GetKeyDown(KeyCode.G))
            {
                GameObject walker = ObjectPool.Instance.PullObject("Walker");
                walker.transform.Translate(Vector3.forward * Random.Range(-5.0f, 5.0f));
                walker.transform.Translate(Vector3.right * Random.Range(-5.0f, 5.0f));
            }

            if (Input.GetKeyDown(KeyCode.P))
            {
                object[] objs = GameObject.FindObjectsOfType(typeof(GameObject));

                foreach (object o in objs)
                {
                    GameObject obj = (GameObject)o;

                    if (obj.gameObject.GetComponent<Zombie.Walker>() != null)
                    {
                        ObjectPool.Instance.PoolObject(obj);
                    }
                }
            }
        }

我们的Client类相当简单:

  • 如果玩家按下G键,我们从ObjectPool请求一个 Walker 僵尸的实例。然后,我们将它随机放置在场景中。

  • 如果玩家按下P键,我们将场景中当前的所有 Walker 对象送回池中。

就这样;通过一个类,我们可以实现一个简单、可配置和可扩展的对象池。

摘要

我们刚刚将对象池模式添加到我们的工具箱中;这是 Unity 开发者最有用的模式之一,因为我们从代码示例中看到,我们可以轻松地回收场景中已经存在的 GameObject,而无需初始化新的 GameObject。当处理包含大量数据和组件的巨大预制体时,此模式可以帮助你避免不稳定的帧率。

在下一章中,我们将探讨空间分区模式——这是构建开放世界游戏时理解的一个重要主题。

练习

现在你已经学完了所有核心创建型模式,例如工厂模式、原型模式和对象池模式,将所有这些模式结合起来构建终极生成系统将是一项非常有价值的实践练习。目标是拥有一个系统,它可以重用特定类型的对象实例,并且只有在必要时才创建新的实例,如果需要,则使用工厂。

进一步阅读

第十九章:空间划分

在本章中,我们将回顾空间划分模式;空间划分的概念在计算机图形学中很普遍,用于以最佳方式组织虚拟空间中的对象。这种方法也适用于管理 Unity 场景中放置的 GameObject。通过实现空间划分模式的核心原则,我们可以将充满二维或三维对象的大型环境划分为两部分,同时仍然能够保持一定程度的性能一致性。正如您在本章中将会看到的,这种模式是使大型 AAA 开放世界游戏制作成为可能的核心成分之一。

本章将涵盖以下主题:

  • 我们将回顾空间划分模式背后的基本原理

  • 我们将实现一个迷你游戏,其中捕食者在场景中猎捕猎物

技术要求

本章是实践性的,您需要对 Unity 和 C#有基本的了解。

我们将使用以下特定的 Unity 引擎和 C#语言概念:

  • LINQ

如果您不熟悉这个概念,请在继续之前进行复习。

LINQ 是一种非常强大的查询语言,与 SQL 有些相似;当您想简单地遍历数据结构时,它可以节省时间。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Game-Development-Patterns-with-Unity-2018

查看以下视频,以查看代码的实际运行情况:

bit.ly/2FAyWCf

空间划分模式的概述

游戏程序员经常面临的问题是如何快速定位场景中与参考点最近的特定实体,例如玩家角色。在 Unity 中,有许多解决这个问题的方法,如下所示:

  • 实现一个射线投射系统,该系统将扫描玩家角色周围的区域,并报告特定实体的位置。

  • 使用 Unity 的 API 功能,例如GameObject.Find()函数,在场景中定位特定实体,然后比较它们的坐标与玩家角色的坐标。

第一个选项是有效的,但如果我们的三维环境复杂,可能很难定位我们正在寻找的所有实体,因为它们可能被其他对象遮挡,无法被射线相交。第二个选项在性能方面可能不是最佳选择,因为我们可能需要遍历包含场景中每个实体的列表,直到找到特定类型的每个实例。

我们可以通过使用空间分区模式来解决这种类型的技术挑战;它是为此目的而设计的。我们需要首先解决的问题是这个模式的名字。术语空间分区可能会误导:我们不是在组织或修改我们正在分区的虚拟空间。我们做的是相反的;我们在方程中移除空间。

我们通过将场景中的三维物体放入一个平面数据结构中来实现这一点,该数据结构在内存中有效地表示这些物体之间的距离,而无需对精确坐标进行计算。这种方法允许我们快速直接地计算找到最接近或最远离参考点的实体。

换句话说,我们正在将虚拟空间细分成一个更容易分析的结构。一个易于在内存中表示的通用结构(通常用于将空间划分为单个容器)是一个固定网格。在以下图中,您可以看到这个概念的可视表示。网格包含正方形,我们将它们称为单元格。这些单元格包含单位。这些单位可以是任何东西——一个特定的敌人角色类型或散布在广阔地图上的隐藏宝藏箱:

图片

现在,让我们想象这个网格叠加在一个大型开放世界地图的 RPG 视频游戏上。每个单元格(正方形)代表一个虚拟的 2x2 平方公里区域。我们知道我们的玩家角色在地图上的一个特定单元格(正方形)中出生,但我们想给他提供快速前往一个充满他可以战斗的 2 级怪物的区域的选择。通过使用空间分区,我们可以轻松地计算内存中特定类型的最近实体,而无需扫描整个三维环境。

计算结果可以向我们建议一个包含 2 级敌人最大群体的附近单元格(正方形)。有了这个信息,我们可以将我们的玩家角色移动到建议单元格(正方形)内的随机位置,以便他可以掠夺该区域。正如您将在下一节中看到的那样,空间分区简化了管理位于复杂二维和三维空间中的实体的过程。

优点和缺点

这种模式的缺点相当有限(在大多数情况下并不存在),因为它非常容易使用。

优点如下:

  • 可重用性:我们可以使用空间分区模式来优化我们管理由二维或三维空间中的实体组成的事物的方式(例如,用户界面)。

  • 简化:空间分区使得实现计算对象之间空间关系的代码变得更加容易。这对数学能力不强的人来说非常有用。

缺点是:

  • 不太动态:如果你试图管理在广阔区域内不断移动的实体,空间划分可能会失去其所有的优化优势。因此,如果你有一个场景中充满了以全速弹跳的物体,你需要不断地更新包含实体及其网格位置的集合的数据结构。在这种情况下,这个过程可能会消耗大量资源,不值得付出努力。

游戏程序员应该掌握的最重要技能是数学。了解设计模式对于进入行业是必要的,但它并不像对高级数学的深入理解那样重要。

用例示例

假设我们需要快速原型化一个简单的迷你游戏,该游戏模拟一个非玩家捕食者角色在地图上狩猎猎物。在环境中生成实体(猎物和捕食者)的过程并不复杂;实际上,它相当简单。然而,我们如何知道我们的捕食者是否接近潜在的猎物并将其移动到那里呢?

考虑以下可能的解决方案:

  • 我们可以查询场景中的每个物体,并将它们的坐标与捕食者的坐标进行比较。

  • 我们可以实现一个射线投射系统,扫描捕食者附近的每个物体,以发现潜在的猎物。

这些解决方案可能有效,但它们在短时间内实施可能会很繁琐。然而,使用空间划分模式,我们可以通过确保场景中的所有实体都包含在一个按相对位置组织猎物和捕食者的数据结构中来避免这个过程。正如你将在我们的代码示例中看到的那样,编写这个实现非常快且有用,尤其是在你匆忙并想在代码中草拟一些基本的 AI 导航行为时。

代码示例

以下代码示例可能看起来非常基础,但它可以很容易地扩展以实现更复杂的使用案例。从某种意义上说,它是一个我们将能够在此基础上构建的基础:

  1. 让我们从实现我们模式的核心元素Grid开始:
using System;
using System.Linq;
using UnityEngine;

public class Grid: MonoBehaviour
{
    private int m_SquareSize;
    private int m_NumberOfSquares;

    public Grid(int squareSize, int numberOfSquares)
    {
        // The size can represent anything (meters, km)
        m_SquareSize = squareSize;

        // Squares permits to subdivide the grid granulary
        m_NumberOfSquares = numberOfSquares;
    }

    public void AddToRandomnPosition(IUnit unit)
    {
        unit.AddToGrid(UnityEngine.Random.Range(0, m_NumberOfSquares));
    }

    public int FindClosest(IUnit referenceUnit, IUnit[] list)
    {
        if (list != null)
        {
            // Using LINQ queries
            var points = list.Select(a => a.GetGridPosition()).ToList();
            var nearest = points.OrderBy(x => Math.Abs(x - referenceUnit.GetGridPosition())).First();
            return nearest;
        }
        else
        {
            throw new ArgumentException("Parameters cannot be null", "list");
        }
    }
}

你应该注意的第一件事是AddToRandomnPosition()函数,在其中我们通过Random.Range()调用将单位添加到网格中的方块。我们这样做有两个原因。我们想快速测试我们的Grid实现,所以我们模拟了实体在随机位置的环境中分散。我们还想展示我们如何结合使用空间划分和生成系统来管理特定优化网格空间内的实体生成。换句话说,我们可以在初始化将占据它的东西之前,在内存中对场景的虚拟空间进行划分。

另一个需要分析的功能是FindClosest();请注意,我们使用了两个 LINQ 查询。第一个查询从一个单位列表中提取网格位置列表。第二个查询是查询这个列表,以找到相对于参考单位最近的单元格。对于那些从未使用过 LINQ 的人来说,它是一种内置的 C#查询语言,允许使用一行代码在集合中查找和提取元素。当你需要原型设计和快速编写使用数据结构和集合的实现时,这是一个非常出色的工具。

  1. 现在,我们需要一种方法让我们的单位将自己注册到Grid的特定单元格中。让我们首先实现一个接口来管理我们的单位类型:
public interface IUnit
{
    // The Unit can add itself to the grid
    void AddToGrid(int cell);

    // The Unit can return is current grid position
    int GetGridPosition();
}

这是一个相当直接的接口;GetGridPosition()函数返回Unit的网格位置。可能出现的疑问是,为什么我们不实现一个返回Unit在场景中实际位置的函数?这是因为,在 Unity 中,如果一个 GameObject 附加了Transform组件,我们可以直接要求这个组件返回它在三维场景中的位置。换句话说,我们正在使用 Unity 的 API 为我们做繁重的工作。

  1. 我们将为我们的代码示例实现两种类型的单位;让我们从Prey开始:
using UnityEngine;

public class Prey : MonoBehaviour, IUnit
{
    private int m_Square;

    public void AddToGrid(int square)
    {
        m_Square = square;
    }

    public int GetGridPosition()
    {
        return m_Square;
    }
}
  1. 接下来是我们的Predator类;他猎捕我们的Prey
using UnityEngine;

public class Predator : MonoBehaviour, IUnit
{
    private int m_Square;

    public void AddToGrid(int square)
    {
        m_Square = square;
    }

    public int GetGridPosition()
    {
        return m_Square;
    }
}

我们可以看到,我们的PredatorPrey都有两个主要职责,将它们的位置链接到网格中的特定单元格,并在需要时返回该单元格编号。

  1. 最后,我们的Client类,我们使用它来在Grid上生成Prey并释放Predator,如下所示:
using UnityEngine;

namespace Pattern.SpatialPartition
{
    public class Client : MonoBehaviour
    {
        private Grid m_Grid;
        private IUnit[] m_Preys;

        void Start()
        {
            m_Grid = new Grid(4, 16);
            Debug.Log("Grid generated");
        }

        void Update()
        {
            if (Input.GetKeyDown(KeyCode.P))
            {
                IUnit prey;
                int numberOfPrey = 5;
                m_Preys = new IUnit[numberOfPrey];

                for (int i = 0; i < numberOfPrey; i++)
                {
                    prey = new Prey();
                    m_Grid.AddToRandomnPosition(prey);
                    m_Preys[i] = prey;

                    Debug.Log("A prey was spawned @ square: " + m_Preys[i].GetGridPosition());
                }
            }

            if (Input.GetKeyDown(KeyCode.H))
            {
                IUnit predator;
                predator = new Predator();
                m_Grid.AddToRandomnPosition(predator);
                Debug.Log("A predator was spawned @ square: " + predator.GetGridPosition());

                int closest = m_Grid.FindClosest(predator, m_Preys);
                Debug.Log("The closest prey is @ square: " + closest);
            }
        }

        void OnGUI()
        {
            GUI.color = Color.black;
            GUI.Label(new Rect(10, 10, 500, 20), "Press P to spawn prey on the grid.");
            GUI.Label(new Rect(10, 30, 500, 20), "Press H to hunt some prey.");
            GUI.Label(new Rect(10, 50, 500, 20), "Open Debug Console to view the output.");
        }
    }
}

就这些了;请注意,我们从未需要处理对象的实际三维坐标来找到它们的相对位置。通过将空间划分为网格并将对象包含在其中,我们避免了大量的不必要的计算。我们通过分块来降低复杂性。

当然,在我们的代码示例中,我们选择了简单的方法,在将Units添加到Grid中的特定方格之前,避免了计算它们的相对位置,但如果需要的话,这可以很容易地添加。最重要的启示是,如果我们可以在一个可以轻松搜索和操作的数据结构中分区和管理它们,我们就应该始终避免在三维空间中的实体上进行复杂的计算。

摘要

在本章中,我们采取了一种简单的方法来学习一个提供复杂问题解决方案的模式,即如何在空间中优化组织对象。我们现在有一个可以用来构建开放世界游戏和快速原型化以网格为中心的游戏(例如,解谜游戏)的工具。

在本书的最后一章,我们将回顾一个与我们刚刚探讨的内容完全相反的主题:反模式,它是设计模式的对立面。

练习

在我们的代码示例中,我们实现了一个关于空间划分模式的直接应用案例。然而,我们仅限于二维空间;作为一个实际练习,我建议在此基础上扩展这个基本示例,并在三维空间中组织对象。作为灵感,我建议观察魔方的结构设计。注意,它由一系列小立方体组成;每个小立方体都可以被视为一个组中的单元格。

进一步阅读

第八部分:Unity 中的反模式

在本节中,我们将通过揭示它们的邪恶孪生兄弟——反模式,来探索设计模式的阴暗面。这些负面模式可能存在于你组织的每个层面,并以非常微妙的方式对你的代码库造成退化。

以下章节包含在本节中:

  • 第二十章,反模式

第二十章:反模式

在整本书中,我们通过实施各种类型的模式来回顾了软件架构的最佳实践。但你可能会问自己,如果这些模式如此有益,为什么不是每个人都使用它们?或者为什么我们仍然经常看到充斥着错误的游戏问世?

如果现在的程序员可以轻松地获取大量关于软件开发最佳实践的信息,那么合理地假设,我们不应该有理由在合理的时间内交付稳定的视频游戏和应用。但在本章中,我们将探讨为什么在软件开发行业中,即使是非常有能力和才华的团队最终也会生产出杂乱的代码,并且无法交付一个稳定的产物。

在前几章中,我们探讨了旨在有益并带来积极结果的模式。但现在,我们将研究它们的邪恶双胞胎,即反模式。这些破坏性的模式很微妙;它们并不总是潜伏在你的代码中,而是通过在每个组织层面引起恐惧、不确定性和怀疑来伤害你。这就是为什么它们如此难以识别,正如我们将在下一节中看到的那样。

本章将涵盖以下主题:

  • 我们将回顾一系列常见的反模式

反模式

目前,软件开发的每个领域都有超过一百种反模式被专家们记录下来。我们无法在本章中全部回顾,但我已经列出了一份简短的清单,其中包含我发现的一些与设计模式误用相关的内容,无论是直接还是间接的。但我还列出了我在职业生涯中亲身经历过的那些。

与已建立的设计模式相比,关于反模式的学术研究并没有得到充分的记录,因此在特定反模式的命名上存在很多差异。因此,以下材料中的很多内容都是我对普遍存在的反模式的解释,而不是官方定义。那么,现在让我们深入主题,回顾一些我亲身经历并推荐避免的最相关的反模式。

假装精通

"如果人们知道我为了达到精通付出了多少努力,那么这一切就不会显得那么神奇了。"

  • 米开朗基罗

这是什么? 程序员可以轻松访问大量信息、工具和库,使他们能够轻松地开发他们想要的任何东西。因此,这些优势使许多初级开发者相信他们是他们技艺的大师,当他们只是在复制粘贴他人的工作。

为什么这是错误的? 认为已经知道一切的想法比任何东西都更能阻碍你学习。这种危险的心态使你对自己的不足视而不见,使你无法处理反馈。结果,你将永远无法进步,你将最终成为职业生涯中一个平庸的程序员,即使你拥有高级或技术总监等头衔。

根本原因是什么? 这种过早的失望的主要原因在于,像 Unity 引擎这样的工具简化了制作游戏的过程,以至于几乎任何人都可以做到。但这意味着很少有人理解他们所使用的工具或编程语言在引擎底层的运作方式。

以为例子,仅仅因为你能够用 C#编写程序,并不意味着你就是 C#专家,但了解语言库的复杂性将使你成为专家。

如何避免这种情况?

以下是一份专业习惯列表,将帮助你避免陷入这种反模式的陷阱:

  • 学习,学习,永远不要停止学习。

  • 避免追求像高级、技术领导或 CTO 这样的头衔,而应专注于真正掌握你的技艺。

  • 每次你使用一个新的工具、库和语言时,尽可能多地研究其起源以及其复杂性。

  • 每天都要谦卑。接受自己并非无所不知,并且成为真正的资深程序员需要几十年的时间。

  • 教学相长,写博客,并在论坛上回答技术问题。换句话说,将你所知的知识传授出去,同时吸收新的信息。这种方法将帮助你验证和构建你的学习。

创业公司给出的职位名称与大公司给出的职位名称并不等价。所以,当你从一个小型独立工作室过渡到 AAA 工作室时,你最终又回到了一个更初级的位置,这并不奇怪。原因是简单的:在大团队中晋升到高级职位更难,因为你需要与更多的程序员竞争更好的职位。

对复杂性的恐惧

“不要害怕完美——你永远达不到。”

  • 拉斐尔·达利

这是什么?

我个人多年来一直受到这种反模式的困扰。这是对简单性总是最好的编程方法的过度热情的结果,因此,你应该避免任何可能看起来稍微复杂一些的解决方案,将其视为阻力最小的路径。

为什么这是不好的?

对复杂性的非理性恐惧可能会阻止你使用复杂和高级的设计模式或算法来解决问题。因此,你减少了成长潜力,限制了学习机会。最终,这可能会阻止你达到成熟和资深水平。

根本原因是什么?

坚信最简单的解决方案是解决任何技术问题的最佳途径。但通常,这只是一个避免进行研究和离开舒适区的借口。

如何避免?

每次你必须决定在简单或复杂解决方案之间做出选择时,你应该问自己以下问题:

  • 我目前是在解决技术挑战中感到投入,还是只是试图完成任务?

  • 我会因为建议一个更高级的解决方案而害怕看起来很愚蠢,因为我并不理解它吗?

  • 我实施的简单解决方案是否会随着时间的推移与当前代码库的整体架构相匹配,或者它只是修补了问题?

因此,总结来说,在决定选择简单或复杂解决方案时,总是问自己这个简单的问题:你是选择最易于访问的方法,因为它是对的,还是因为你只是懒惰,不愿意采用需要更多努力的先进方法?

你经常听到程序员说,复杂性会导致更多的错误。这是真的,但更准确地说,是未管理和误解的复杂性导致了更多的错误。

管理者太多

“我们拥有的管理者没有我们应有的那么多,但我们宁愿少一些,也不愿有太多。”

  • 拉里·佩奇

这是什么?

经理人很棒;他们提供了一个独特的接口,可以访问一组复杂的子系统。由于视频游戏是一个由不断相互交互的系统组成的庞大集合,因此作为接口的管理者可以非常有帮助,有助于减少依赖性。

为什么这是错误的?

如果每个类都是一个管理者,最终会导致管理者之间相互依赖。换句话说,管理者变成了其他管理者的子系统,直到你发现自己处于你试图避免的相同境地,一个依赖性的混乱。另一个负面点是,管理者通常被实现为单例,这意味着你在代码库中散布了全局依赖。

以下是一个代码示例,展示了可能过于依赖 Manager 类的软件架构。如果你在你的源代码中看到类似的情况,你可能需要重构你的架构:

using UnityEngine;

public class GameManager : MonoBehaviour
{
    private Player m_Player;

    void Start()
    {
        // Get the player ID
        m_Player = PlayerManager.Instance.GetPlayer();

        // Sign-in to online services
        OnlineManager.Instance.LoginPlayer(m_Player);

        // Load save game data of the player
        SaveManager.Instance.LoadSaveGame(m_Player);

        // Load player preferred controller configuration
        IInputConfiguration inputConfig = SaveManager.Instance.GetInputConfig(m_Player);
        InputManager.Instance.LoadControllerConfig(inputConfig);
    }
}

根本原因是什么?

根本原因通常是缺乏经验或懒惰的程序员,他们没有考虑代码库的整体架构,而是专注于即时结果。

如何避免这种情况?

这里有一份可能帮助你避免这种反模式的良好习惯列表:

  • 每次你即将使用一个特定的模式时,总是考虑使用一个可能更适合的替代方案。换句话说,避免默认选择最简单的解决方案。

  • 跟踪你的架构和你正在使用的模式。如果你看到标题中包含 Manager 的类太多,就提出一个警告。

  • 如果你正在为你的核心系统实现单元测试时遇到问题,这是一个很好的迹象,表明你的架构中可能存在问题,这可能与有太多的单例或类似全局管理者的类有关。

新的模式,或者现有模式的排列组合,正在定期出现。阅读关于这个主题的新书,以保持对这些模式保持警觉是一个好习惯。

意面代码

"有机建筑寻求优越的使用感,以及更细腻的舒适感,体现在有机的简单性中。"

  • 弗兰克·劳埃德·赖特

那是什么?

意面代码是过度封装和架构被分割成太多单独类别的结果。

为什么这是错误的?

大多数程序员在其职业生涯中听说过意大利面代码这个术语。它通常用来描述无结构和混乱的代码,这种代码通常是由初级程序员产生的。但意面代码可以被认为是相反的;它通常是经验丰富的程序员制作的过度设计的代码的结果,他们缺乏让其他人阅读其工作的愿望。

在这两种情况下,导航和维护受到这些反模式影响的代码库可能会变得困难。

根本原因是什么?

对编程和设计模式采取宗教和教条的态度可能会让你写出看起来准确但对其他人来说却难以阅读的代码。

如何避免这种情况?

这里有一些可能帮助您避免这种反模式的技巧:

  • 在必要时,愿意为了可读性而牺牲准确性

  • 总是考虑设计模式确实为你提供了结构,但通常是以牺牲可读性为代价的

  • 为观众编写代码,并记住可能阅读它的人可能没有与你相同的技能集

大多数专业程序员不会自觉地使用设计模式,通常因为他们不理解它们或者不知道如何正确实现它们。因此,要成为一名优秀的程序员,您必须比其他人更了解所有可用的模式以及如何正确使用它们。

鬼魂

"确实,最好是推迟,以免我们匆忙完成得太少,或者完成它的时间太长。"

  • 特尔图良

那是什么?

鬼魂对象通常是解决临时架构问题的代码的结果,但它们在代码库中保留的时间比应该的要长。

为什么这是错误的?

你必须维护的代码密度通常与你每次进行更改时可能需要修复的 bug 频率有关。另一个副作用是,鬼魂类在你的代码库中徘徊可能会引起对做出更改的恐惧,因为可能会在错误的时间出现未知对象。

根本原因是什么?

可以称为鬼魂的鬼魂对象和类,是良好意图变坏的结果。通常,它们的类被实现来解决临时的架构问题,但程序员从未有机会完成它们的设计,因此你最终会得到内存中存在的对象,但它们存在的原因并不明显。

如何避免这种情况?以下是一些可能帮助您避免这种反模式的技巧:

  • 不要使用你不完全理解的设计模式

  • 安排每周代码库审查并删除过时的代码

  • 使用源代码分支策略来管理重要组件的重构

  • 在你的代码中添加 TODO 注释,并要求你的团队定期审查并采取行动

  • 在实施新的架构之前编写文档,以便你的团队可以在你做出更改之前审查你的计划并提供反馈

对于程序员来说,保持极简主义是一种良好的心态。代码可能很复杂,但它永远不应该因为无用的事物而臃肿。始终关注本质,移除非必要的内容。

过早优化

“完美是通过缓慢的步骤实现的;它需要时间的双手。”

  • 伏尔泰

什么是它?

过早优化是在代码需要之前对其进行优化和完美化的行为,因此浪费了宝贵的产品时间。

为什么这是错误的?

在优化上投入比所需更多的时间是浪费时间的一种最糟糕的方式,同时也浪费了雇主的时间。大多数设备每年都在变快,因此程序员越来越不需要优化他们的代码以在有限的硬件上运行得更快。

根本原因是什么?

经验不足通常是根本原因。

如何避免这种情况?

在优化代码之前,始终对其进行性能分析。对于那些可能不知道的人来说,性能分析是使用诊断工具来分析系统性能的行为。通常,你将发现代码中的性能瓶颈仅限于源代码的特定区域,因此通过关注这些区域,你可以提高速度,而无需重构整个代码库。

就像一位优秀的机械师一样,程序员应该有一个工具箱,里面装满了可以帮助他们更快、更好地工作的工具。

供应商锁定

“这不是对技术的信仰。这是对人们的信仰。”

  • 史蒂夫·乔布斯

什么是它?

供应商锁定发生在你开始在代码库中集成第三方组件、插件、框架或库时,但变得依赖于它们以使代码正常工作。

为什么这是错误的?

在 Unity 项目的背景下,依赖于第三方库可能会限制你升级到 Unity 的新版本的能力,因为你可能需要等待供应商的补丁以避免回退。

根本原因是什么? 从第三方供应商购买即插即用的组件和库可以节省大量的生产时间,因此很容易过度依赖它们。

如何避免这种情况? 在购买他们的产品并将它们集成到代码库之前,你应该研究供应商。例如,如果他们没有更新他们的支持论坛,这可能表明他们没有计划在不久的将来发布更新,这可能会限制你在需要时获得即时支持的能力。

作为一名 Unity 开发者,你在编写任何东西之前应该始终检查 Unity 资产商店,因为可能已经有其他人已经以更好的方式完成了你想要做的事情。

按数字管理

这是什么?

按数字管理是指基于由工具(如 Excel 电子表格或报告)生成的统计数据来做出管理决策,而不是基于对项目实际情况的准确分析。

为什么这是错误的?

在生产力报告中表达的数据往往不能反映团队的质量或潜力。它们可能隐藏由动态人际互动引起的问题,而不是揭示它们。这种对数字的关注可能会在关键决策过程中使项目经理失明。换句话说,你能通过程序员一周内修复的 bug 数量来定义程序员的效率吗?答案是,因为特定 bug 的复杂性不是恒定的。你不能用同样的方式评估一周内修复五个简单 bug 的程序员和在同一时期内解决一个但非常复杂的 bug 的程序员。

根本原因是什么?

数字容易解释和证明,尤其是在与没有技术专长、只能通过非常一般的指标评估项目的更高管理层沟通时。这种方法可能导致一个组织花费时间关注数字而不是实际结果。

如何避免这种情况?

高级程序员应该挑战那些使用一般统计数据和数字来评估项目进度的项目经理,通过提供更具体的质量改进指标。以下是一个例子:

  • 服务更新与停机时间

  • 随时间发现的和修复的 bug 数量

在你 40 岁之后,为了确保自己在科技行业有一个长期的职业生涯,最关键的事情之一是回到学校,获得管理学的文凭或证书。这种教育将使你能够过渡到长期领导角色,公司可能会在你积累了数十年经验后鼓励你考虑这一角色。

技术面试

“我选择一个懒惰的人来做一件困难的工作。因为一个懒惰的人会找到一种简单的方法来完成它。”

  • 比尔·盖茨

这是什么?

在程序员招聘过程中,技术面试的概念本身可能听起来不是一个反模式,但我提出它是一个,并且它会对团队生成的源代码质量产生副作用。对于那些从未经历过程序员技术面试的人来说,它涉及一系列测试,这些测试是给候选人以验证他们的技能和知识的。考试可能包括在白板上、一张纸上或在在线测试环境中编写关于编程的答案。我认为技术面试是一个行业范围内的反模式。

为什么这是错误的? 技术面试流程的核心问题是,你只能测试你已经知道的内容。因此,你最终会招聘到与你镜像般的候选人。结果,你将组建一个缺乏各种不同技能的团队。如果您的唯一目标是拥有一个非常专业的团队,这种方法是有效的,但这很少见。大多数公司需要拥有技能多样的员工来平衡组织中的任何弱点。

例如,如果你的技术面试的重点是数据结构,因为这是你作为面试官的优势,那么你可能会淘汰在该领域较弱但在其他领域(如设计模式)较强的候选人。但是,因为你只根据你认为是重要的技术偏见来评估,你可能会错过那些能为你的团队带来新技能的候选人。

根本原因是什么? 我们行业程序员招聘流程之所以如此不一致的主要原因在于,很少有人了解程序员的工作以及如何评估他们作为候选人的能力。因此,招聘经理更倾向于根据候选人的最终技术测试分数来评判他们,从而将候选人的价值简化为一个单一的数字。

此外,还有一些面试官的行为模式或流程也可能是根本原因的一部分:

  • 难题制造者:难题制造者是一种通过提出巧妙谜题来测试候选人技能的面试官。这种方法通常会让大多数候选人感到困惑,并将面试过程变成一场压力游戏。

  • 热椅子:臭名昭著的热椅子面试类型类似于警察审讯,目的是通过快速连续的问题来隔离候选人的弱点和优势。通常,一个面试官会扮演“坏警察”的角色,在提问时更加激进,而另一个则扮演“好警察”的角色,在候选人回答某些问题耗时过长时提供帮助。这种方法最终会耗尽候选人的精力或迫使他们以他们认为面试官想要听到的答案来回答。这不是了解候选人潜力的合适方法。

  • 白板面试:白板面试包括让候选人通过在白板上写下答案来回答技术问题。这种评估候选人的方法存在一个特定的问题;大多数程序员在他们的职业生涯中从未在纸上或白板上编写过代码,所以当在压力情境下(如面试)被迫这样做时,会导致大量关于他们实际技能水平的错误否定。

如何避免这种情况? 几乎每个人都同意,招聘优秀的程序员是一个昂贵且具有挑战性的过程,但这意味着你需要更有创意地处理技术面试,以免拒绝那些只是你团队现有成员复制品的优秀候选人。

这里有一些可以帮助你避免这种反模式的提示:

  • 尝试看看候选人有什么独特和有价值的。找一个能教你和你的团队新东西的候选人。

  • 不要寻找弱点。相反,尝试理解候选人的优势,并看看它们是否与他们的潜在弱点相平衡。

  • 总是考虑到程序员在行业中的技能可能有很多,这取决于他们的专业。例如,平均的网页开发者可能不如 3D 程序员擅长数学,但他们可能在数据库规范化或客户端-服务器应用程序设计方面更出色。

  • 当候选人未能回答一个技术问题时,问问自己这是否是因为他们不理解它,可能没有足够的技能去做,或者可能因为考试过程而过于紧张。换句话说,在评估申请人的实际技能水平时,面试的背景很重要,而不仅仅是最终分数。

即使你是一位经验丰富的专业程序员,你也绝不能低估现代技术面试过程的潜在难度。你的多年经验可能成为一种劣势,因为面试官通常想评估你是否仍然了解你的计算机科学基础知识。换句话说,你可能需要回答一些自大学以来你可能没有复习过的主题的问题。因此,在参加面试之前,翻阅那些旧课本并学习基础知识是个好主意。

摘要

我们已经到达了旅程的终点。在这本书中,我们探讨了各种设计模式,每种模式都有其独特的功能。这本书最重要的收获是,在开始编写代码之前,你应该始终验证是否存在与系统设计意图相匹配的模式。这种方法避免了重复造轮子,并为你提供了一种一致的编程方法,这将有助于你整个职业生涯。

但这一章也揭示了看似合理的设计或管理决策,如果没有意识到其背后的动机和潜在后果,可能会迅速出错。换句话说,作为程序员,我们需要在每个层面上都意识到我们决策的潜在影响,否则我们可能会成为反模式的受害者。

练习

对于我们的最终练习,我为你列出了一个日常习惯清单,这将确保你在游戏行业中作为程序员的长期和成功职业生涯。然而,如果你不逐渐提高你的技能,几乎可以肯定你会陷入平庸,甚至可能变得无关紧要。相信我;这发生在我身上,直到有一天,我决定改变我的习惯,再次专注于真正掌握我的技艺。

这里有一些良好的习惯要养成:

  • 每年至少学习一种新的编程语言。

  • 通过参加练习面试编程考试来定期检查你的技能。

  • 每年获得一项新的技术认证,如 PMP、CCNA 和 CEH。

  • 列出你作为程序员的全部弱点,并每天努力克服它们。

  • 尝试每周至少参加一次与技术相关的聚会活动或会议。

  • 加入技术专业组织,如 ACM 和 IEEE,并使用提供的资源。

  • 通过每天阅读技术和游戏行业新闻来保持对发生的事情的了解。

  • 学习其他领域可能与你的领域相关的课程,包括管理、UI 设计和动画。

  • 为自己列一个与技术和编程相关的博客和 YouTube 频道的清单。每天至少阅读一篇博客文章和观看一个视频。

  • 每月参加一次编码训练营或订阅至少一个培训课程。别忘了完成它们。

  • 每年至少阅读两本关于编程或相关领域的书籍。

  • 开一个 GitHub 账户,并至少为一个开源项目做出贡献,即使只是几行代码。习惯这个过程和社区。

  • 学习冥想;这是一份压力很大的工作;知道如何在压力下保持冷静将有助于你保持心理健康并避免过度劳累。

对于我们的最终练习,我建议你列出你最喜欢的模式,并问问自己为什么喜欢它们。是因为它们易于实现,还是因为它们解决了你代码中的实际架构问题?换句话说,确保你不会因为错误的原因而使用特定的模式,永远不要懒惰,在编写代码时始终保持清醒的选择。

进一步阅读

组织:

博客:

YouTube:

科技新闻:

在线课程:

书籍:

posted @ 2025-10-25 10:31  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报