Unity-2022-示例-全-

Unity 2022 示例(全)

原文:zh.annas-archive.org/md5/aba11502735b24896b63168e3ffe47d5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你好!Unity 游戏引擎为有志于将创意愿景变为现实的游戏开发者提供了一个激动人心的机会。本书旨在为那些致力于制作完成游戏、遵循最佳实践并希望商业发行游戏的人而设计。在完成这一全面的旅程结束时,你不仅将获得 Unity 2022 的知识和技能,还将获得成就感,知道你离实现创建和发布游戏的目标又近了一步。

本书旨在指导你有效地构建游戏项目,涵盖从代码组织到资产管理以及场景层次结构的一切。我深入探讨了基本的游戏设计原则,包括构建核心游戏循环、定义玩家动作、实现机制以及建立胜负条件。

在章节中,我们解决新手游戏开发者可能遇到的重要挑战,例如游戏设计概念上的差距、对 Unity 功能和 Scripting API 的熟悉程度,以及 C#编程的熟练度。Unity 构建游戏元素的独特方法是我们方法的核心,强调由 C#脚本驱动的基于组件的方法。

本书采用实践方法学习,我将引导你通过一系列项目。我们将从一个简单的 2D 对象收集游戏开始,逐渐过渡到沉浸式的 3D第一人称射击FPS)和混合现实MR)体验。每一章都建立在上一章的基础上,引入新的概念和技术,并提供实际示例、动手练习以及偶尔的挑战或额外活动。这样,你不仅会理解理论,还会获得宝贵的实践技能。

此外,我还关注了传统学习材料中常被忽视的必要方面,例如为商业化发行游戏、项目持续性和版本控制,以及在整个生命周期内支持游戏。你还将深入了解Unity 游戏服务UGS)以及将游戏作为服务运营的过程。这种全面的方法确保你为游戏开发的各个阶段都做好了充分的准备。

在本书结束时,你将拥有 Unity 2022 的坚实基础知识,并能够以可维护和可扩展的方式构建项目以商业发行游戏。因此,让我们共同踏上这段旅程,释放你在 Unity 充满活力的生态系统中的游戏开发者潜力!

本书面向对象

本书面向有志成为游戏开发者的人士,特别是那些致力于在 Unity 中制作完整游戏、遵循编码最佳实践并渴望商业游戏发布的人。理想的读者已经安装了 Unity,并具备对 Unity 编辑器界面进行导航的基础知识。他们还拥有良好的文件组织技能、初级的艺术技能,以及通过 C#脚本实现基本功能的一些经验。

本书涵盖内容

第一章, Unity 2022 基础知识,提供了学习如何使用 Unity 2022 轻松创建游戏的入门步骤!本章涵盖了安装 Unity Hub 和探索可用的模板以启动游戏项目。它还涵盖了如何使用包管理器提供额外的功能,同时保持项目足迹小并避免不想要的工具。本章最后通过开始一个新的游戏项目,了解 GameObject,并创建一个简单的 2D 角色来结束。

第二章, 创建 2D 收集游戏,深入探讨了通过添加额外的 GameObject 来扩展场景,为收集游戏的开始部分创建 2D 俯视角设置。本章涵盖了使用 Unity 的 Tilemap 功能进行高效环境创建。此外,本章还介绍了 C#编程语言的基本概念,并指导你创建自定义脚本以增强游戏环境中的功能。本章最后概述了使用 Unity 的最新基于事件的输入系统集成玩家输入。

第三章, 完成收集游戏,介绍了如何使用 Unity 的CinemachineCM),一个强大的相机控制系统,在游戏环境中跟随玩家。它还将介绍将用户界面UI)集成到游戏中,利用 Unity 的 UI 系统(uGUI)和自定义编写的 C#组件,包括计时器和得分跟踪等功能。

第四章, 创建 2D 冒险游戏,提供了使用 Unity 2D 工具(如 Sprite Shape)创建 2D 侧视角冒险游戏的指南。本章涵盖了导入艺术作品、整合动态移动平台以及优化游戏性能。到本章结束时,你将深入理解游戏设计文档,并具备设计交互式 2D 环境的能力,包括提供次要动作的触发器,以创造更具吸引力的玩家体验。

第五章继续冒险游戏,概述了如何使用 Unity 2D 动画包创建一个绑定和动画的 2D 玩家角色。本章将涵盖使用 PSD 导入器设置角色头像,实现输入动作映射以使用控制器脚本来控制玩家移动,以及使用 Mecanim 来动画化角色。

第六章Unity 2022 中的对象池介绍,介绍了对象池,这是一种在游戏开发中用于维持性能和避免游戏过程中出现延迟的关键优化模式。本章利用这一模式,通过使用 Unity 的对象池 API 来维持高效的玩家射击机制。

第七章润色玩家的动作和敌人的行为,专注于学习如何通过引入 Unity 的基于图节点着色器创作工具 Shader Graph 和轨迹渲染器组件来提高玩家角色艺术作品的视觉美学。然后,本章展示了如何使用可脚本化对象(SOs)来配置不同类型的敌人,创建多样化的非玩家角色(NPC)敌人。本章以介绍通过状态模式进行敌人行为管理作为结尾。

第八章扩展冒险游戏,提供了建立健康管理和伤害施加机制以装备玩家、敌人和任何可破坏对象的步骤,使其拥有简单而有效的健康系统。本章还介绍了创建一个能够实现动态生成的敌人波生成器。

第九章完成冒险游戏,介绍了创建一个全局事件系统,该系统便于不同 C#类之间的通信,并促进模块化和可扩展性。利用新创建的事件系统,下一节将涵盖开发适用于任何数量自定义任务的寻宝系统。此外,本章还涵盖了从 Unity Asset Store 集成和定制一个谜题系统,以满足游戏开发者的特定需求。

第十章创建 3D 第一人称射击游戏(FPS),提供了使用 ProBuilder 和预制件在 Unity 编辑器内设计和构建灰盒 3D 环境的技能。它还将涵盖如何快速集成 FPS 角色控制器与 Unity 启动资产,以及如何将现有的 2D 代码用于环境交互的优化和适应到 3D API 方法中。

第十一章继续 FPS 游戏开发,提供了通过替换预制件和材质、抛光资产、使用 Polybrush 散布对象以及通过光照烘焙和光照探针改进光照来更新和增强上一章的 3D 环境的指导。然后介绍了应用磨损效果以增加真实感和使用贴图装饰环境的方法。

第十二章通过音频增强 FPS 游戏,专注于通过向 Unity 游戏项目添加音频来改善玩家体验。在整个章节中,你将获得创建音频管理器和相关可重用音频播放组件的技能和知识,以播放音乐、环境声音和声音效果SFX)。本章以实现玩家角色的脚步声和游戏关卡内的混响区域为例结束。

第十三章使用传感器、行为树和 ML-Agents 实现 AI,介绍了使用 Unity 的 AI 导航包和上一章重构的 2D 组件来实现必要的人工智能AI)行为和 NPC 导航。本章还涵盖了通过引入传感器和行为树来增强敌人 NPC 动态。它探讨了将机器学习ML)工具与 Unity ML-Agents 集成,以向游戏中添加高级 AI 功能。

第十四章使用 XR 交互工具包进入混合现实,提供了开发使用玩家的物理空间来创建沉浸式和新型游戏体验的混合现实MR)游戏和体验的知识。本章使用 Unity XR 交互工具包在混合现实中制作最终 Boss 房间遭遇。它探讨了设计过程、与 AR Foundation 平面合作、放置交互对象和实现机制。

第十五章以商业可行性完成游戏,通过探讨游戏即服务GaaS)、Unity DevOps 和 LiveOps 资源、通过 Unity 版本控制进行源代码管理、游戏内经济、平台分发和 UGS 实现,提供了有效的游戏开发项目管理的基本知识。它涵盖了管理并保护项目开发生命周期、分发具有商业可行性的游戏以及触及目标玩家群体。

为了最大限度地利用这本书

您需要在计算机上安装 Unity Hub 和 Unity 2022 编辑器版本。如果您还没有安装 Unity,第一章将指导您安装这两个软件。所有代码示例都将与 Unity 2022.3 的最新 LTS 版本兼容。所有代码示例和可下载的项目文件都已与出版时可用的最新 Unity 2022.3 LTS 版本进行了确认。

本书涵盖的软件/硬件 操作系统要求
Unity Hub Windows, macOS, 或 Linux
Unity 2022 编辑器 Windows, macOS, 或 Linux
C#版本 9
Unity 游戏服务(UGS)

要完成章节中提供的示例,您只需要一个 Unity ID 账户和一个 Unity Personal 许可证(免费)。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Unity-2022-by-Example。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“在Assets/Sprites/Character目录中创建一个新的文件夹。”

代码块设置如下:

internal class Tags
{
    // Ensure all tags are spelled correctly!
    public const string Player = "Player";
}

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在窗口底部右边的几何面板中,确保权重已启用。”

提示 | 最大化 Unity 窗口

要最大化 Unity 中的当前活动窗口,如图 10.3中场景视图窗口所示,您可以使用键盘快捷键Shift + 空格键。

联系我们

我们欢迎读者的反馈。

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

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告此错误。请访问www.packtpub.com/support/errata并填写表格。

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

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

分享您的想法

一旦您阅读了《Unity 2022 by Example》,我们很乐意听到您的想法!请点击此处直接转到此书的 Amazon 评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在旅途中阅读,但无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

别担心!现在,每本 Packt 书籍都附带一本免费的 DRM-free PDF 版本,无需额外费用。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取好处:

  1. 扫描二维码或访问以下链接:

packt.link/free-ebook/9781803234595

  1. 提交您的购买证明。

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件地址。

第一部分:Unity 简介

本部分概述的任务将为您提供在创建新 2D 游戏项目时有效导航和使用Unity 编辑器所需的知识。设置本书的第一个 2D 项目——一个简单的 2D 俯视物体收集游戏——将引导您完成 Unity Hub 中项目创建的关键步骤,介绍 Unity 编辑器的窗口和工具栏,在层次结构和场景窗口中创建 2D 对象,并在检查器窗口中设置对象属性,如排序层

本部分包括以下章节:

  • 第一章, Unity 2022 基础知识

第一章:Unity 2022 的基础知识

使用 Unity Hub 开始 Unity 2022 的学习非常简单。Unity Hub 扮演着几个非常实用的角色,我们将介绍如何安装它并了解其功能。在本章中,我们不仅将安装 Unity Hub 和 Unity 编辑器,还将分解不同的模板,以便启动您的游戏和 AR/VR 项目。

除了提供新项目起始基础的模板外,Unity 还通过包管理器提供额外的功能。包管理器允许 Unity 保持项目的小型尺寸,并且不会通过默认安装不需要或不想要的工具(指相对简单的组合程序,用于完成一项任务)来膨胀编辑器。熟悉和理解可用的包将无疑有助于节省时间并提高项目的质量。

在 Unity 编辑器中找到您的路径只是介绍的一半。本章的后半部分将教您如何创建内容并使事物可交互。我们将通过在编辑器中首先创建一个简单的 2D 角色来实现这一点,使用内置工具。这一切都始于 GameObject – Unity 的构建块。

本书采用基于项目的学习方法,因此我们将一步步设计游戏、创建游戏并在过程中解决问题。

在本章中,我们将涵盖以下主要主题。

  • Unity Hub – 选择 2D Universal Render Pipeline (URP) 模板

  • 了解 Unity 编辑器和安装包

  • 介绍 GameObject!关于 Transform组件 的全部内容

  • 2D Sprites 使用 Sprite Creator – 理解 Sprite Renderer 和绘制顺序

  • 游戏设计文档(GDD) – 介绍 2D 收藏品游戏

到本章结束时,您将能够创建新的 Unity 项目,熟悉在 Unity 编辑器中找到您的路径,理解游戏设计文档的初始标准,并准备好创建基于 2D Sprite 的角色,这是我们游戏的第一元素。

技术要求

要跟随本章的内容,您需要一个运行 Windows 7+ 64 位、Mac OS X 10.12+ 或 Linux(Ubuntu 16.04、18.04 和 CentOS 7)的计算机。您需要足够的空闲硬盘空间,不仅用于 Unity 编辑器的安装,还用于项目文件。我们建议 Unity 安装文件夹为 25 GB,安装临时文件(通常位于您的操作系统安装驱动器上)的空闲空间为 3 GB,项目文件应有 10 GB 即可。

Unity Hub – 选择 2D URP 模板

Unity Hub 使管理已安装的 Unity 编辑器版本和为已安装的编辑器添加或删除模块变得简单,并有助于管理您不同的项目。如果您是 Unity 的完全新手,那么上一句话可能有点令人困惑。为什么我们必须管理不同的已安装编辑器?简单来说,软件会发生变化。随着 Unity 编辑器的演变,它引入了新的功能和对其脚本 API 的更改。

小贴士

根据一般原则,一旦您开始生产,就不应该升级您项目使用的 Unity 编辑器版本。这样做可能会产生不良影响,例如渲染损坏或无法编译的代码。我们将在安装 Unity 编辑器和选择项目模板的后续部分中进一步讨论这个问题。

安装 Unity Hub

让我们首先安装 Unity Hub,开始我们的旅程。在这本书中,我们将使用Unity 个人版许可证,这是 Unity 的免费版本。这里的免费并不意味着我们在构建游戏的功能或能力上会受到限制。它只是意味着,如果您符合需要付费许可证的标准(如果您在过去的 12 个月内收入或筹集的资金少于 10 万美元),则可以免费使用此版本。

重要提示

如果您是符合条件的在校学生,您可能想查看unity.com/products/unity-student上的Unity 学生计划。它提供对Unity Pro、一系列优质资源和Unity 游戏服务的访问,例如云构建,这些是专业人员和工作室在 Unity 上构建游戏时使用的。

好的,让我们开始吧。按照以下步骤安装 Unity Hub:

  1. 前往unity.com/download并选择您操作系统的下载链接。这将把UnityHubSetup.exe下载到您的下载文件夹中。

  2. 在您的网络浏览器中点击可执行文件,导航到您的下载文件夹,然后双击可执行文件以启动安装。

  3. 保持默认设置或更改安装路径,如果您想将安装位置安装到不同的硬盘驱动器(仅使用本地驱动器,不要使用网络驱动器,因为这可能会引起问题)。

  4. 点击安装并完成安装过程后,点击完成以运行 Unity Hub。

当您第一次打开 Unity Hub 时,如果您还没有创建,系统会提示您登录创建账户。您的 Unity 账户,也称为Unity ID,将用于许可。Unity 要求有效的许可证才能安装 Unity 编辑器。个人版许可证是免费的,Unity Hub 会为您生成一个。

现在 Unity Hub 已经安装,让我们继续安装 Unity 编辑器。

安装 Unity 编辑器 – 选择哪个版本?

我们已经安装了新的 Unity Hub,现在准备安装 Unity 编辑器版本。如前所述,Unity Hub 允许你安装多个 Unity 编辑器版本,以管理你将在未来创建的不同项目。在没有安装任何编辑器版本的情况下打开 Unity Hub 将默认提示安装LTS流中的最新 Unity 编辑器版本。LTS 简单来说就是长期支持。这通常是锁定并基于新项目构建的最佳版本选择,因为它将是可用的最稳定版本,并且保证在未来两年内得到支持。为了确保 LTS 版本的稳定性,不会向工具或脚本 API 添加新功能。如果你想使用最新的引擎功能,你必须选择一个较新的技术流,因为 LTS 流中不会引入额外的工具或技术。

好的,简单来说,这意味着什么?

  • 如果你今天开始生产或即将发货,并且希望在开发发布周期的整个过程中获得稳定性和支持,请选择 LTS 流。在撰写本文时,这是 2020.3 LTS。

  • 如果你今天开始生产但想利用更新的工具和技术,并且最新官方发布版本即将成为新的 LTS 流,请选择最新官方发布版本。在撰写本文时,这是 2021.2(当 2021.3 正式发布时将成为最新的 LTS 流)。

  • 如果你想在可用工具和技术的最前沿进行创作,并且不介意管理潜在的崩溃和错误,那么请选择最新的预发布(beta)版本。

重要提示

本书专门为 Unity 2022 编写。如果你已经安装了更早版本的 Unity,那么说明或功能可能会有所不同,因此可能很难——或者不可能——跟随操作。我们建议安装 Unity 2022 的最新版本,以完成我们在以下章节中创建的项目。

要将你的生产进度与 Unity LTS 版本进行比较,请参考以下时间线:

图 1.1 – Unity 平台发布时间线

图 1.1 – Unity 平台发布时间线

Unity 在unity3d.com/unity/qa/lts-releasesUnity QA资源页上提供了详细的 LTS 发布信息。

按以下步骤安装 Unity 2022 编辑器:

  1. 如果你刚刚完成 Unity Hub 的安装,你将被提示安装 Unity 编辑器的最新 LTS 版本。我们想要安装一个特定的 Unity 2022 版本,因此请通过点击对话框右下角的跳过安装继续,除非在你阅读本文时,2022 LTS 版本已经发布,在这种情况下,你可以直接安装它并跳过剩余步骤!

  2. 在 Unity Hub 主窗口中,在左侧面板中选择安装

  3. 点击窗口右上角的安装编辑器按钮。

  4. 官方发布标签应该默认选中(最新的测试版可以在预发布标签下找到)。

  5. 长期支持(LTS)部分下面的其他版本部分,找到列出的最新 2022 年发布版本并点击安装

  6. 你将要看到的下一个屏幕是选择开发工具、平台和文档模块。由于我们假设你是第一次安装 Unity,并且我们目前对我们的第一个项目没有具体的要求,所以我们只需保留默认设置。点击继续

Unity Hub 现在将下载并安装所选版本。根据你的互联网连接和硬盘速度,这需要一些时间——平均基础安装需要大约 3GB 的下载(临时文件)和大约 7GB 的空闲硬盘空间用于安装。

在本节中,我们了解了可用的不同 Unity 编辑器版本以及如何安装它们。在下一节中,我们将了解渲染管道,以确定如何进行新项目的开发。

渲染管道是什么?

在下一节中,当我们创建项目时,我们将选择一个模板作为项目的基础。这需要一些解释才能完全理解模板的选项。我们将创建一个 2D 游戏(由平面图像表示的两个维度的游戏),因此选择 2D 模板是有意义的,但这些是 2D 可用的模板名称:2D2D (URP)和2D Mobile。我们不会创建移动游戏,因此我们可以排除2D Mobile,但(****URP)是什么意思?

如果你对视频游戏内部的工作原理不熟悉,渲染指的是如何将 2D 图形或 3D 模型绘制到屏幕上以生成图像。随着时间的推移,随着 Unity 将渲染技术进化以更好地适应创作者制作的各类游戏,他们意识到需要做出改变并改进渲染技术。引入了一种性能良好且可定制的渲染管道架构,以最佳方式服务于创作者,这被称为可脚本渲染管道SRP)。名为2D的模板将使用 Unity 的内置渲染器,而2D (URP)将使用通用渲染管道Universal RP,或URP)。通用 RP 是 Unity 提供的一个默认 SRP,作为在广泛设备平台上制作高性能游戏的起始基础,这最终将取代内置的遗留渲染器作为默认设置。

重要提示

我们将在整本书中讨论与向项目中添加渲染器功能相关的 URP 功能集,但不会直接将其与内置渲染器功能进行比较。URP 与内置渲染器之间的功能比较表可以在docs.unity3d.com/Packages/com.unity.render-pipelines.universal%407.1/manual/universalrp-builtin-feature-comparison.xhtml找到。

本节教你什么是渲染管线以及如何选择正确的 2D 项目模板。现在,你将使用所学知识来创建我们的第一个项目!

创建项目

我们将使用 Unity Hub 提供的模板之一从头开始创建我们的项目,以确保为我们的游戏渲染需求正确设置一切。由于我们将从创建 2D 游戏开始,我们将选择2D (URP) Core模板。Core 意味着在此上下文中,模板不会提供任何示例资产、样本或学习内容。它将提供一个带有预配置 URP 2D 渲染器设置的空 2D 项目——这正是我们所需要的!

按照以下步骤在 Unity Hub 中创建一个新的 2D URP 项目:

  1. 在左侧面板中选择项目,然后点击窗口右上角的新建项目按钮。

  2. 确认窗口顶部的编辑器版本设置为已安装的 2022 版本。

  3. 在列表中找到2D (URP)模板并点击选择它。

  4. 接下来,在右侧面板中,通过在项目 名称字段中输入名称来给你的项目命名。

  5. 最后,在位置字段中验证安装路径,然后点击创建项目

现在,你可以按照前面的步骤创建你的项目,同时参考以下截图(确保在顶部已正确选择编辑器版本,以防你已经安装了多个版本):

图 1.2 – Unity Hub 项目模板

图 1.2 – Unity Hub 项目模板

在本节中,你学习了如何安装 Unity Hub 并安装了特定版本的 Unity 编辑器。然后,你学习了渲染管线是什么以及它与创建新项目的关系。现在,我们将通过介绍其主功能来继续讨论 Unity 编辑器。

了解 Unity 编辑器和安装包

刚刚创建了一个新的 2D URP 项目并打开了编辑器,让我们来浏览一下 Unity 2022 的界面!在本节中,我们将仅介绍最常用的功能,而在后续章节中处理我们的项目时,我们将更深入地探讨特定窗口和工具栏的功能,并在所需任务的上下文中提供信息。

当我们第一次打开 Unity 编辑器时,它将使用默认的 Windows 布局,如下面的截图所示:

图 1.3 – Unity 2022 编辑器默认布局

图 1.3 – Unity 2022 编辑器默认布局

您将最常使用的通用编辑器窗口如下:

  • 场景 – 这是构建我们内容的可视化窗口。添加到场景层次结构并具有渲染组件的对象将在场景和游戏视图中可见。

  • 游戏 – 您在游戏视图中看到的模拟代表玩家将在最终可玩发行版中渲染的内容。您也将在这个窗口中进入播放模式时进行游戏测试。

  • 项目项目窗口类似于您操作系统的文件管理器。这是您导入和组织构成您正在创建的项目资产(例如 3D 模型、2D 图像、声音与音乐、插件等)的文件的地方。

  • 层次结构 – 这就是场景的GameObject 层次结构(关于 GameObject 的更多内容将在下一节中介绍)。了解如何在层次结构窗口中组织场景的对象(例如父子关系)对于有效地工作在您的项目中将至关重要,因此这将是接下来章节讨论的主题。

  • 检查器 – 在 Unity 的自然顺序中,检查器窗口紧随其后,因为它直接与层次结构窗口中的 GameObject 相关联。当在场景层次结构中选择一个对象时,检查器会显示其所有详细信息(变换和组件)。

  • 控制台 – 信息、警告、错误以及任何相关的跟踪信息都会在一个可排序和过滤的列表视图中显示。调试项目中出现的任何问题将执行控制台信息显示。

注意,在默认布局中,点击标签页,例如图 1.3中的游戏控制台,它将“浮出”到“前台”以便交互。标签页也可以拖动并停靠到其他窗口,以提供完全定制的布局。

除了这些窗口之外,Unity 还有一些工具栏,例如以下这些:

图 1.4 – Unity 2022 编辑器工具栏

图 1.4 – Unity 2022 编辑器工具栏

  • 主工具栏A) – 上左角的按钮提供对您的Unity 账户(Unity ID)、Unity 云服务以及当前版本控制系统VCS)的访问(我们将在后面的章节中处理版本控制)。中间的按钮是播放(用于进入播放模式)、暂停和步进控制。在右侧,按钮是撤销历史记录全局搜索图层可见性下拉菜单,最后是编辑器布局下拉菜单(如前所述,我们正在查看默认窗口布局;您可以使用预设之一或保存自己的布局)。

  • 场景工具栏B) – 从左侧开始的工具包括:

    • 工具句柄位置(中心、旋转中心) – 当在场景中移动对象时,动作将基于此位置,要么是对象的中心,要么是对象的旋转中心。

小贴士

如果您的 GameObject 在场景视图中的锚点位置看起来不正确,别忘了检查这个设置!

  • 工具句柄旋转(全局,本地) – 在场景中旋转对象时,它们相对于全局本地空间中的变换进行旋转。

小贴士

您可能需要在不同空间设置(全局或本地)之间切换,才能正确旋转对象。如果您的旋转看起来不正确,别忘了检查这个设置!

  • 剩余的工具包括网格可见性和吸附设置、绘制模式、2D 或 3D 场景视图(我们目前处于 2D 模式),场景照明、音频、效果、隐藏对象、场景视图相机设置和 Gizmos 的切换。这里有很多内容需要解释,但不用担心,随着我们在接下来的章节中创建项目,我们会逐一介绍这些功能。

  • 操作工具栏C) – 一个浮动工具栏,也称为叠加层,位于场景窗口内。此工具栏提供了在场景视图中与 GameObject 一起工作的基本工具。这些工具包括视图、移动、旋转、缩放、矩形和变换。

编辑器窗口底部还有一个名为状态栏的工具栏(图中未显示)。状态栏主要提供特定进程的当前状态,例如最后的控制台警告或错误消息(左侧),光照生成的进度(右侧),以及代码编译的旋转图标(右下角)。

阅读更多 | Unity 文档

您可以在docs.unity3d.com/2022.3/Documentation/Manual/UsingTheEditor.xhtml找到更多关于 Unity 界面的信息。

在本节中,您了解了熟悉的编辑器窗口和工具栏以及它们如何操作场景视图中的对象。现在,让我们看看如何通过包扩展编辑器中的功能和工具。

Unity 包管理器

在了解通用 RP 之前,您已经通过这些包接触到了包的概念。由于我们从 URP 模板开始,所以我们不需要做任何特殊操作,但 Unity 通过包提供了可脚本渲染管线的支持!包提供了一种方式,让 Unity 能够在不要求安装新编辑器的情况下,提供多个版本的引擎功能或服务。您甚至可以尝试包的最新预发布版本,以保持在技术前沿,并在遇到任何问题时快速回退到稳定或备用版本。包管理器可以从顶部菜单访问:窗口 | 包管理器

图 1.5 – Unity 包管理器

图 1.5 – Unity 包管理器

包管理器中的功能集是一组常见的工具包,它提供了一种更简单、更流畅的安装体验。2D功能集(如图 1.4*所示)是为使用 2D 项目的创作者设计的。在我们的案例中,由于我们又从2D URP模板开始,2D功能集已经导入到我们的项目中(由绿色勾选标记表示)。我们可以开始了!

如果您需要修改项目中任何包,从顶部菜单的包管理器下拉菜单中,您可以通过项目内查看已存在于项目中的包,或者选择Unity 注册表我的资产(您在 Unity 资产商店购买的资产)。管理包就像在列表中选择它,然后从底部右窗格显示的按钮中选择一个可用功能一样简单。例如,下载安装移除更新

小贴士

包是项目特定的,因此您需要确保为每个新创建的项目安装所需的包!

新增功能:Unity 2022

包管理器窗口中,您现在可以多选列表中的包,以单个操作添加、更新或移除。

在本节中,您学习了关于编辑器窗口和工具栏的内容,以及如何使用扩展编辑器功能的包添加/移除功能和工具。接下来,我们将开始学习 GameObject。

介绍 GameObject – 关于 Transform 和组件的所有内容

简而言之,您想添加到场景中的任何内容都将作为 GameObject 添加。GameObject 是场景中所有存在的物体的基础构建块。它还充当添加功能的容器,通过组件添加功能。当一个 GameObject 被添加到层次结构中时,它默认是激活的,但可以通过名称字段左侧的复选框(图 1.6中的主摄像机左侧)将其停用——“关闭”添加到其中的所有组件。组件可以是视觉的或功能性的,或者在某些情况下,两者都是!功能组件实现了 Unity 的脚本 API,Unity 提供了支持引擎许多功能的组件。您将使用 C# 语言创建自定义组件脚本,这将在第二章*中介绍。

在本节中,您将学习如何将 GameObject 添加到场景中,然后您将介绍 Transform 组件,并学习如何与组件一起工作。

将 GameObject 添加到场景中

使用 创建 菜单可以轻松地将新的空游戏对象和特定类型的对象添加到 层次结构 中。创建 菜单可以从 Unity 的顶部菜单中的 GameObject 下轻松访问,从 层次结构 窗口直接使用顶部的 +(加号)图标下拉菜单,或者通过在 层次结构 窗口中的任何位置右键单击。可以将 预制件 和其他支持类型的游戏对象直接通过从 项目 窗口中拖放添加到场景视图中 – 这通常是完成某些任务的最快方式,我们将在构建本书的项目时利用这一功能。

变换组件

添加到场景中的游戏对象都有一个默认的变换组件,它决定了其在 3D 空间中的 位置旋转缩放 – 这是一个使用三个相互垂直的坐标轴(Y-Up,即 X 轴、Y 轴和 Z 轴)的笛卡尔坐标系。可以通过手动操作变换(通过输入值)、使用 操纵工具(在 场景 窗口中点击和拖动)或通过代码(使用 Unity 脚本 API 访问游戏对象的 变换 属性和方法)来执行场景中图形的位置操作。

在下面的屏幕截图中,我们可以看到 Vector3 在 3D 空间中的表示:

图 1.6 – 变换检查器和相关的 3D 坐标系统

图 1.6 – 变换检查器和相关的 3D 坐标系统

在前面的图中,我们可以看到所有轴相交的 000(即所有轴相交的点)。所有轴上的 旋转 值都是 0,这意味着没有对此游戏对象应用旋转。同样,所有轴上的 缩放 值都是 1,这意味着没有对此游戏对象的默认缩放应用缩放。我们将在创建我们的玩家角色图形时修改这些值,在 *2D 精灵与 Sprite Creator – 理解精灵渲染器和绘制 * 顺序 * 部分中。

在创建我们的玩家之前,让我们更详细地讨论组件,因为我们将会直接与它们打交道。

组件

变换 部分下方,我们可以看到已经添加了几个组件,这些组件提供了用于我们的场景主相机(场景层次结构中的相机决定了玩家在游戏视图中看到的渲染内容)的功能。要为这个相机添加更多功能,请点击 检查器 窗口底部的 添加组件 按钮。您将看到一个过滤和可搜索的组件列表,可以添加到项目中。这些组件不仅包括 Unity 提供的,如 图 1.6 中的 相机 组件,还包括您已经创建并添加到项目中的任何脚本。在下一章中,我们将讨论创建脚本并将它们作为组件添加。

可以添加、删除和复制/粘贴组件,并且可以复制/粘贴它们的值,甚至可以将它们保存为预设!所有这些都可以通过组件标题部分完成。点击并按住标题,可以在检查器窗口中手动上下移动组件,或右键单击以移动和其他功能。所有上述功能都可以通过右键单击对话框弹出窗口或组件标题右侧的图标访问;那些是引用预设和一个垂直省略号,相当于在标题上右键单击。

组件为您的项目添加了强大的功能,但当组件结合并与其他组件协同工作时,事物会变得更加完美。Unity 基于这种组件架构。我们将深入了解如何最佳地构建您的项目以利用 Unity 组件,采用易于工作且对设计师和开发者都友好的单一职责设计模式。

在本节中,您了解了组件的重要性以及如何在检查器中与之协同工作。我们将贯穿整本书使用组件,从下一节的精灵渲染器开始。

使用 Sprite Creator 创建 2D 精灵 – 理解精灵渲染器和绘制顺序

让我们直接深入实践我们刚刚学到的关于 GameObjects 的知识,通过创建一个简单的基于精灵的角色来作为集合游戏中的玩家使用。这将是我们书中的第一个项目!我们将使用 Unity 内置的精灵 创建器图形从头开始制作玩家角色。

在本节中,我们将创建一个新场景,添加精灵,并学习如何操纵和分层精灵以制作我们的玩家角色。

创建新场景

首先,让我们通过转到文件 | 新建场景(或使用Ctrl/Cmd + N快捷键)来创建一个新场景。这将打开新建场景对话框,并提示我们选择场景模板。我们将使用Lit 2D (URP)模板,因为我们将在使用通用 RP 的同时处理 2D,并且我们想充分利用 URP 提供的所有高级照明功能。

重要提示

当场景打开时,立即保存是一个好习惯!现在通过转到文件 | 保存(或按Ctrl/Cmd + S),选择您的项目中的一个文件夹(通常是Assets/Scenes),并给它一个描述性的名称。现在,每次您进行更改并想要保存进度时,只需使用Ctrl/Cmd + S快捷键来保存。您希望定期这样做并养成习惯——意外崩溃时,您不希望丢失任何重要的进度,这些进度将需要重新创建。

在创建我们的新场景后,让我们添加一些精灵!

使用 Sprite Creator 创建精灵

接下来,在我们的场景中使用(0, 0, 0)创建一个精灵。耶!

让我们将这个对象设置为一个新的空 GameObject 的父对象,以创建具有良好结构的玩家对象——将图形与我们将作为组件添加到根 GameObject 的功能性分开。我们将通过以下步骤来完成:

  1. 通过在(0, 0, 0)内再次右键单击,创建一个新的空 GameObject。

  2. 新 GameObject 的默认名称,正如你所猜想的,是GameObject。它默认被突出显示以供编辑,因此你可以轻松地不进行额外步骤地重命名它。现在我们将使用默认名称,所以按Enter键。

  3. 创建另一个新的 GameObject,但这次将其命名为Graphics

  4. 现在,我们将通过拖放设置玩家角色的 GameObject 结构。首先,点击并拖动圆形对象到图形,然后点击并拖动图形对象到GameObject

重要提示

我们本可以通过首先在场景层次结构中选择圆形GameObject,右键单击它以打开创建菜单,然后选择创建空父对象来节省几个步骤。前面的过程对于演示层次结构窗口中的 GameObject 如何被设置为父对象并移动到其他位置是至关重要的。现在尝试一下,通过转到编辑 | 撤销(或Ctrl/Cmd + Z)来撤销父化,然后重新进行 GameObject 的父化。

  1. 最后,将根对象从GameObject重命名为Player。首先,在层次结构窗口中选择它,并按F2(Windows)或Enter(Mac)键。或者,在层次结构中选择已选项目,使用检查器窗口顶部的名称字段来重命名它。你应该在你的场景中获得以下精灵和 GameObject 设置:

图 1.7 – 2D 精灵玩家角色 GameObject 层次结构

图 1.7 – 2D 精灵玩家角色 GameObject 层次结构

你学习了如何将精灵形状添加到场景中,并理解了将 GameObject 设置为父对象以创建良好结构的方法。在我们完全发挥你那神秘的美术才能来创建玩家角色之前,首先了解如何在场景视图中导航将肯定是有益的。

在场景视图中导航

在场景视图中移动可以帮助你的绘图工作,通过放大/缩小细节并专注于你正在工作的部分。在2D 模式下,你将仅使用平移和缩放:

  • 通过按住右鼠标按钮,使用视图工具工具栏叠加中的“手”图标)点击并拖动,或你也可以使用键盘,按箭头键来平移场景视图。

  • 缩放场景视图,通过滚动鼠标滚轮进行放大或缩小。

  • 此外,你还可以通过在层次结构窗口中双击对象来在场景视图中将其聚焦。

额外阅读 | Unity 文档

你可以在docs.unity3d.com/2022.3/Documentation/Manual/SceneViewNavigation.xhtml上了解更多关于场景视图导航的信息。

我们已经准备好在下一节开始构建我们的角色了。

创建我们的玩家角色

在我们的收集游戏项目中,我们将为玩家角色创建一只瓢虫。我们将在 Unity 编辑器中直接使用精灵创建器精灵来设计我们的角色!在后面的章节中,我们将导入原始艺术资产,用作游戏中的不同精灵。现在,我们将限制使用一些基本形状来构建我们的角色设计,但只要有些创意,结果可以看起来相当漂亮。我们将广泛使用位置旋转缩放的变换值及其相应的操作工具工具栏覆盖)来绘制我们的角色。

重要提示

在早期开发阶段由程序员创建的占位符图形,在某些情况下,甚至在艺术家完成艺术品之前,通常被称为“程序员艺术”。这个术语有时被负面使用,以表示平庸的艺术作品,但不要让这阻止你发挥创意!像 Geometry Dash、140 和 VVVVVV 这样的游戏都使用了简单的图形,效果极佳。

让我们从在层次结构窗口中选择圆形对象(我们之前创建的)开始(或双击以在场景视图中将其聚焦)——请注意,我们希望将所有新的精灵都作为图形对象在层次结构中的子项(如图 1**.8所示)。这将作为瓢虫的身体,所以让我们给它一个漂亮的红色。

精灵渲染器组件在精灵字段下方有一个用于颜色的字段,表示我们正在使用圆形精灵形状。点击颜色会弹出颜色选择器对话框(见图 1**.8)。当你选择了一个漂亮的红色调——如对话框右上角所示——只需点击对话框标题栏中的关闭按钮(x)。

图 1.8 –瓢虫精灵渲染器组件和颜色选择器对话框

图 1.8 –瓢虫精灵渲染器组件和颜色选择器对话框

由于我们的身体精灵拥有漂亮的红色调,我们可以在下一节中通过操作其变换来塑造它。

使用操作工具

图 1**.8中,你可以看到我们已经完成了瓢虫角色设计。现在让我们回顾一下创建它的过程。你应该在场景视图中有一个红色圆圈,但它需要变得…不那么圆形。让我们通过在 Y 轴上缩放它来将其变成一个椭圆。这可以通过在变换|缩放|Y字段中输入一个值或手动使用矩形工具(在图 1**.8中的工具栏覆盖中选中,也可以使用缩放工具)来实现。

重要提示

2D 场景视图由 X 轴和 Y 轴分别表示水平和垂直值。Z 轴将表示深度,但在2D 模式中,我们不会操作 Z 轴值,而是使用精灵排序图层顺序

当使用矩形工具手动操作时,单击并拖动围绕圆形对象的边缘(1 个轴)或角落(2 个轴)以调整其大小。

两个可以帮助更容易地创建形状的修改键是ShiftAlt键——但请按住这些键进行拖动,不要在这样做之前按它们。

  • 在缩放时保持当前宽高比,同时按住Shift键进行拖动。

  • 要从中心支点均匀地缩放对象的两边,在拖动时按住Alt键。

小贴士

您可以在操作对象时同时按住ShiftAlt键。

当您有一个看起来不错的身体形状时,让我们继续创建它的轮廓。轮廓将有助于将我们的玩家角色与背景环境之间提供良好的分离。在仍然选中圆形对象的情况下(您可以在场景视图中简单地单击形状来选择它们),按Ctrl/Cmd + D进行复制。这将创建一个圆形精灵的副本,并为每个复制的副本在名称中递增一个数字。将新形状的颜色设置为黑色,并均匀地将其缩放得略大于红色圆形。在执行此操作时,您可能会意识到我们现在有一个问题——黑色形状隐藏了红色身体。让我们修复它。

精灵图层和排序

检查器中,附加设置精灵渲染器组件中的一个部分。如果排序图层图层顺序字段没有直接显示在它下面,请单击附加设置以展开它(参见图 1.7 的检查器窗口底部)。我们可以通过两种方式更改精灵的绘制顺序:1)通过指定排序图层或 2)通过在图层顺序字段中指定的值。

排序图层是我们将在接下来的章节中深入探讨的主题,因为我们目前只需要一个图层——将图层想象成书中的页面,其中它们从前到后的顺序可以重新排列。现在,将0放在每个形状上,使每个形状处于相同的深度。由于我们希望黑色形状位于红色形状的后面,将其设置为-1。现在您可以调整其大小,为红色形状提供一个漂亮的轮廓厚度。

小贴士

您可以在选择时按住CtrlShift键,选择多个形状并一次性更改所有形状的图层顺序值。

创建剩余的瓢虫角色只是复制场景中已有的精灵形状或创建一个新的形状的问题。可用的精灵创建器形状有方形圆形胶囊形菱形六边形

通过在场景视图中单击并拖动精灵并将它放置到适当位置来移动形状。将鼠标指针悬停在角落点(蓝色圆点)附近将显示旋转光标。当此光标显示时,单击并拖动可以旋转它。

额外阅读 | Unity 文档

您可以阅读更多关于定位 GameObject 的信息,请参阅 docs.unity3d.com/2022.3/Documentation/Manual/PositioningGameObjects.xhtml

现在就创建你自己的瓢虫玩家角色吧。使用所有的 变换 字段,矩形工具 和修饰键,以及 层顺序 字段来绘制。你将在创建本书中的项目时反复执行这些动作,不仅限于精灵,还包括 UI 元素。享受这个过程吧!

在本节中,你学习了如何创建一个新的 场景,添加和复制精灵,并使用 层顺序 值来操纵这些精灵以制作我们的玩家角色。在结束这一章之前,我们将讨论游戏设计。

游戏设计文档 (GDD) – 介绍 2D 收集游戏

在整本书中,我们将使用所谓的 游戏设计文档 (GDD) 来定义和构建我们将要创建的游戏。这份文档将作为我们决定如何开发游戏核心方面的参考点。我们的游戏将会很简单,但功能丰富。GDD 中写的大部分信息都是不言自明的,但一些概念可能比较新,所以让我们先来回顾一下它们:

  • 游戏的名称是什么?

    • 这个很简单,所以现在不要过于担心!任何作为工作标题的东西都行得通 – 享受这个过程吧!
  • 游戏的核心循环是什么?

    • 核心循环是使您的游戏成为玩家享受和满意的体验的关键。它是玩家为了完成目标而反复执行的一系列动作。

让我们看看一个示例 GDD,它将填充我们将在整本书中制作的 2D 收集游戏:

游戏名称 外部世界
主题、背景 或类型是什么? 2D 科幻平台游戏
摘要 整体是什么? 一款冒险游戏,将玩家带上一段从和平的耕作到与被邪恶的外星植物实体感染的机器人系统战斗的旅程。游戏发生在一个外星星球上,玩家种族在星球表面建立了栖息地。栖息地是完全自动化的,并由中央控制系统管理的机器人维护。一个邪恶的外星植物实体已经渗透到控制系统中,并分别控制了机器人和中央系统。外星实体的目标尚不清楚,但必须阻止它,否则玩家种族将无法在这个星球上生存。
游戏独特的特色是什么? 多种游戏模式提供新颖且令人兴奋的游戏体验:模拟、冒险和射击。
哪些游戏启发了你 以及为什么? Metroid、Mega Man 和 Stardew Valley。
描述游戏玩法、核心循环和 进步。 在计时器倒数的压力下收集能量碎片,清理太空站栖息地中的幼苗!

表 1.1 – 游戏设计文档 (GDD)

随着时间的推移,我们将根据需要添加到 GDD 中,但这将为我们提供一个良好的起点。耶!

本节向您介绍了简单的 GDD 模板,并学习了在制作引人注目的游戏时需要回答的基本但关键问题。

摘要

本章简要介绍了安装 Unity Hub、安装 Unity 编辑器和选择 2D 通用 RP 模板创建新 2D 项目的原因。在本章中,你学习了 包管理器 对于添加针对我们项目需求特定的工具和功能到 Unity 编辑器的重要性。然后你学习了如何浏览不同的窗口和工具栏,并使用它们在我们的新 2D 场景中创建和操作 GameObjects。我们还通过在 Unity 编辑器中绘制我们的玩家角色,提前开始了构建收集游戏的工作——在这个过程中学习了层排序顺序的重要性。

最后,我们通过为游戏命名、撰写摘要和指定 GDD 中的某些游戏玩法来开始定义我们正在创建的游戏。这将为游戏的生产提供必要的方向。

在下一章中,我们将深入探讨创建“外部世界”收集游戏环境,学习如何通过脚本实现游戏机制,并添加基本 UI。

图片来源

图 1**.1 – Unity 平台发布时间线

  1. 网页 参考: blog.unity.com/engine-platform/unity-20221-tech-stream-is-now-available

  2. 图片 URL: blog-api.unity.com/sites/default/files/2022-05/image2.jpg

第二部分:二维游戏设计

在本部分,你将继续进行二维收集游戏的游戏设计,你将创建环境并在 GDD 中处理游戏玩法赢/输条件的关卡设计。你将学习如何使用新的输入系统控制玩家角色,定义的机制功能用于与收集品和危险交互,以及向 GameObject 添加 C#脚本(组件)。你将学习如何为二维游戏创建自己的基于物理的交互性。

本部分包括以下章节:

  • 第二章, 创建二维收集游戏

  • 第三章, 完成收集游戏

第二章:创建一个 2D 收集游戏

第一章中,你被介绍了 Unity 编辑器和其常见的窗口和工具栏。我们还创建了我们的第一个玩家角色——瓢虫!遗憾的是,瓢虫目前正坐在一片浩瀚的虚无之中,没有任何目标为其存在提供价值。

在本章中,我们将通过首先向场景中添加更多的 GameObject 并创建一个为外部世界收集游戏(我们的瓢虫们确实会感到高兴)的 2D、自上而下环境来开始将之前获得的知识付诸实践。

使用Tilemap功能创建 2D 自上而下环境和设计关卡是一个简单的任务。它允许你创建一个瓦片调色板,然后只需在 Unity 场景视图中直接绘制关卡。

仅使用 GameObject 和 Unity 的内置组件所能做的有限,因此你将接触到C#语言。用 C#编写自己的脚本允许你创建游戏和体验所需的功能。

我们将讨论如何创建脚本,并了解编写代码的最佳实践方法,包括结构、原则和模式,这将使你的代码易于工作、维护和扩展。这些编写代码的方法将对你个人以及团队环境中的工作都有益,因为在行业中广泛采用了这些结构和实践。

我们将要处理的第一个 C#脚本是一个控制器,用于解决我们的瓢虫角色无法移动的问题。在整个书中,我们将使用一种问题解决的方法来定义过程,因为它以分析的心态来界定编码需求,将问题分解成更小的任务,同时考虑整体解决方案。我们还将处理玩家输入,因此你将学习如何设置和连接你的代码到 Unity 的新基于事件的输入系统

在本章中,我们将涵盖以下主要主题:

  • 使用Tilemap创建一个 2D、自上而下的游戏环境

  • C#中创建脚本的介绍——IDE、SOLID 原则和设计模式

  • 使用新输入系统编写简单的玩家控制器

到本章结束时,你将能够快速创建一个 2D、自上而下、基于瓦片的游戏环境,设计一个关卡,并了解如何在考虑最佳实践原则和设计模式的同时创建和编辑 C#脚本。我们的瓢虫角色也将能够通过响应玩家输入来探索一个酷炫的新环境。

技术要求

要跟随本章的内容,你需要安装Visual Studio Community 2022;我们将在整个章节和书中的项目中使用这个版本。这应该已经在第一章中与 Unity 编辑器一起安装了。

您可以在 GitHub 上下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

使用 Tilemap 创建 2D、俯视游戏环境

我们将首先完成的事情来创建收集游戏环境是进行一些水平设计。级别的设计将影响游戏玩法——游戏的乐趣和挑战性。这不会是一个对水平设计的全面深入研究;毕竟,我们正在制作一个非常简单的收集游戏。

我们现在将专注于水平设计的一个原则——引导玩家。由于这是一个 2D、俯视视角的游戏,我们引导玩家的最简单方法就是使用水平中的形状并引入危险。在这样的简单游戏中我们能做的有限,但这些原则可以应用于更大、更复杂的游戏。

设计的可视化不是每个人的强项——我们有时都需要创造性的帮助和灵感。虽然游戏设计文档GDD)擅长描述事物,但它只能用这么多字来传达。正如人们所说,一张图胜过千言万语,所以看看我最初为收集游戏级别设计所做的草图:

图 2.1 – 收集游戏级别的草图

图 2.1 – 收集游戏级别的草图

如果你缺乏灵感或者只是喜欢跟随(这里没有人会因为你这样做而评判你),那么就使用我的草图来达到你自己的目的。当你深入这本书的时候,我可能听起来就像一个破唱片,但请记住这一点——如果你不觉得开心,你就是在做错事。

接下来,让我们探索一些引导玩家的例子。

水平设计 – 引导玩家

通过在水平中创建形状(也称为引导线),我们鼓励玩家向期望的方向移动。例如,从玩家生成的位置开始,让我们先给他们一个直接在他们面前的开阔区域。

这个开阔区域的一侧将开始向内倾斜,随着它远离玩家面对的方向,这将创造出箭头形状。这种形状会无意识地引导玩家向前移动,朝着“箭头”所指的方向。

生成/重生/消失玩家

生成指的是在游戏中创建玩家。这通常发生在游戏开始时,或者在开始一个关卡时。

重生是指玩家在死亡或需要玩家在特定位置重新开始的某些游戏事件后重新生成。

当玩家消失时,他们将从游戏世界中移除。

形状是我们水平设计中用来引导玩家通过环境的工具之一,另一个是危险。让我们在下一节中看看如何使用危险。

危险

在视频游戏中,关卡设计可以通过视觉提示影响玩家的移动,其中之一就是危险元素。这些元素可以引导玩家向下一个目标前进,推动游戏进程,同时通过在玩家必须访问以完成游戏目标的地方放置危险元素来改变关卡难度。

阅读材料 | 2D 关卡设计

《2D 游戏中的关卡设计模式》由Ahmed Khalifa著:www.gamedeveloper.com/design/level-design-patterns-in-2d-games

在本节中,你了解到形状和危险元素是简单的概念,可以引导玩家,如果做得好,它们可以产生很好的效果。要使引导微妙并融入/隐藏到环境设计中,需要练习。

让我们通过创建 2D 收集游戏关卡来开始练习。在下一节中,我们将使用 Unity 2D 工具集的一部分,即Tilemap功能,这个工作流程的第一步是创建一个Tile Palette

创建 Tile Palettes

你可能在上次我们处理玩家角色时关闭了 Unity。要返回项目中我们上次停止的地方,请打开 Unity Hub。默认视图将列出你之前创建的所有项目。列表还包括一些关于项目的附加信息,例如上次修改时间和用于它的编辑器版本。以下截图是一个示例:

图 2.2 – Unity Hub 项目列表

图 2.2 – Unity Hub 项目列表

从 Unity Hub 添加和删除项目

你可以从 Unity Hub 添加和删除项目以保持项目列表的相关性。通过使用窗口右上角的打开按钮从磁盘添加项目。使用列表中每个条目右侧的水平省略号来删除项目。请注意,从 Hub 中删除项目不会从磁盘上删除它,并且需要在 Hub 外手动清理。

打开你之前从2D URP Core模板(如图 2.2 所示,它是My Project项目)创建的项目。只需在列表中单击项目名称即可完成此操作。

在实际创建我们的瓦片地图之前,我们需要一些精灵图像来工作。以下部分将介绍向项目中添加精灵,这些精灵将构成我们第一个瓦片地图的基础,用于构建游戏关卡。

2D 游戏资源

为了创建我们关卡的环境,我们将使用一些免费提供的游戏资源。在接下来的章节中,我们将使用原始艺术资源,并采用不同的艺术流程和工作流程,这样你将获得更全面的知识,当处理 Unity 中的 2D 艺术时。

免费游戏资源

Kenney提供无附加条件的现成游戏资源!数千个精灵可供使用——适用于许多不同的主题和流派——您可以在自己的项目中使用,任何类型的用途都允许,甚至包括商业用途。您可以在以下位置找到所有 Kenney 资源列表:kenney.nl/assets

Kenney是一个提供许多预制精灵图集和瓦片图资源的网站。这两个图形术语之间的主要区别可以概括如下:

  • 精灵图集图像是由几个较小的图像组成的图像(图 2.5中的左侧图像)

  • 瓦片图是由几个较小的图像组成的图像,这些图像以二维网格的形式排列(图 2.5中的右侧图像)

重要注意事项

精灵图集瓦片图这两个术语经常被互换使用,意义差别不大。我在以下章节中尝试从实际角度澄清这些术语,并提供示例。

2D 艺术通常以精灵图集的形式提供,因为单个图像是在游戏引擎中处理图像的更优方式,而不是为每个资产使用单独的图像。即使 2D 艺术以单个图像的形式提供,Unity 也有工具可以将单个精灵图像组合成单个图像以进行优化(在这种情况下,Unity 中的组合或打包图像被称为精灵图集)。如前所述,Kenney 提供以精灵图集和瓦片图形式的游戏资源。

优化注意事项

精灵图集更加优化,因为它们减少了渲染器(显卡)必须执行的绘制调用次数。图集上包含的所有精灵可以同时绘制,将整个精灵图集视为单个绘制,而如果它们是单独的精灵,则需要单独绘制。这种减少绘制调用的次数可以提高游戏性能(提高帧率)。

现在我们已经了解了 2D 游戏资产图像是什么,让我们在下一节中从 Kenney 导入一些资产,以了解实际导入和使用图像的过程。

导入精灵

将图像导入 Unity 很容易,但让我们首先通过创建一些文件夹来定义我们项目中的组织结构。按照以下步骤创建SpritesTile PalettesTile Palettes/Tiles文件夹:

  1. Assets文件夹中。

  2. 项目窗口内右键点击以打开创建菜单(或者在项目标签上方直接点击+按钮)并选择创建 | 文件夹

  3. 将文件夹命名为Sprites并按Enter键。

  4. 重复步骤 2以创建Tile Palettes文件夹。

  5. 现在,在选择了Tile Palettes文件夹后,重复步骤 2以创建Tiles文件夹(此文件夹是Tile Palettes文件夹的子文件夹)。

我们将在集合游戏项目中使用 Kenney 的两个精灵图集来创建游戏环境 – 俯视坦克 Redux塔防俯视)游戏资源。您可以直接从 kenney.nl/assets 网站下载精灵图集,或者它们也包含在 GitHub 项目中提供的书籍项目文件中(参考 技术要求 部分的链接)。

一旦创建了文件夹并下载了精灵图集,我们现在就可以导入精灵了。太好了!

您可以通过从文件管理器(Windows)/Finder(Mac)拖动它们并将它们拖放到 Unity 项目 窗口中,或者通过在 项目 窗口中右键单击并选择 导入 新资源… 来将精灵导入 Unity。

首先,我们将使用 塔防(俯视) 游戏资源的 towerDefense_tilesheet.png 精灵图集。将图像导入到 Assets/Sprites 文件夹中,一旦资源导入完成,在 项目 窗口中选择它将在 检查器 窗口中显示精灵导入设置。

图 2.3 – 精灵导入设置

图 2.3 – 精灵导入设置

由于精灵图集包含许多图像,我们首先想要将 精灵模式 改为 多个。每次更改导入设置时,您都必须点击字段列表底部的 应用 按钮。一旦点击,我们将继续切片精灵图集以创建可单独选择的精灵。

2 的幂纹理

最好将纹理的尺寸设置为所有边都是 2 的幂的尺寸。尺寸为 2、4、8、16、32、64、128、256、512、1,024 或 2,048 像素(px)。请注意,纹理不必是正方形的(例如,256 x 1,024)。Unity 将使用非 2 的幂纹理,尽管它们不会被压缩(占用更多视频内存)并且不会优化(在某些情况下,加载和渲染速度较慢)。当它们无法被压缩时,检查器窗口将显示警告,如图 2.3* 底部所示。

切片精灵图集

精灵模式 设置为 多个 时,导入的图像现在被视为精灵图集,需要定义几个较小的图像。如果我们跳过此步骤,精灵图集中的所有较小图像将无法访问,我们无法将它们分配给 精灵渲染器 或为在 瓦片地图 瓦片调色板 中绘制或填充我们的关卡创建瓦片。

通过单击 精灵编辑器 按钮位于 精灵模式 部分字段下方来打开精灵编辑器(参考 图 2.3)。您会看到精灵图集实际上是一个瓦片地图图像,因为精灵已经为我们排列在一个二维网格中。这使得切片变得轻而易举!

接下来,点击6464。这个 Kenney 瓦片图的精灵单元是 64 像素方形,但你可能需要处理不同大小的精灵,因此你必须相应地设置此值。

我们可以保留对话框中其余字段的默认值,然后通过点击切片按钮继续将图像切片成精灵。切片结果应如下所示:

图 2.4 – 精灵编辑器塔防(俯视)瓦片图

图 2.4 – 精灵编辑器塔防(俯视)瓦片图

裁剪精灵

对于一些没有完全填满网格单元的精灵,如果某些间距没有调整得恰到好处,你可能会从相邻的精灵中得到一些溢出。这可以通过在精灵编辑器中点击有问题的精灵,然后点击裁剪按钮(位于切片按钮的右侧)来轻松修复。

让我们继续切片我们将用于收集游戏环境中的对象的另一个 Kenney 资产 – Top-down Tanks Redux 游戏资产的 Assets/Sprites/topdowntanks_onlyObjects_default.png 精灵图集。

同样,导入后,在项目窗口中选择图像以在检查器窗口中显示导入设置,将精灵模式设置为多个,点击底部的应用按钮,最后点击精灵****编辑器按钮。

你可能会立即注意到这张图片的不同之处 – 它不是一个瓦片图。Kenney 为比 Unity 更多的游戏引擎提供了游戏资产作为精灵图集。虽然这个精灵图集可能在其他一些游戏引擎中工作得很好,但不幸的是,它在 Unity 中却不行。

假设较小的图像有更多的填充(由围绕图像的透明像素表示的空白空间)。在这种情况下,Unity 的自动切片类型设置可能按预期工作,将切片分割成单个精灵 – 它在有足够填充的情况下可以这样做 – 但在这种情况下,它没有工作,并将整个精灵图集图像视为单个精灵。

因此,要使用与 Unity 的自动切片不兼容的填充不足的精灵图集中的图像,你必须打开你喜欢的图像编辑软件(例如,Adobe Photoshop、Gimp 或 Krita)并重新排列图像以添加填充,或者按单元格大小排列它们进行切片。

如以下截图所示,我选择从原始 Kenney 精灵图集中复制图像(左侧)并创建一个新的瓦片图图像,具有一致的单元格大小布局(右侧 – 此图像包含在本书的项目文件中)。

图 2.5 – 精灵编辑器 – 自动切片与网格切片

图 2.5 – 精灵编辑器 – 自动切片与网格切片

优化提示

为了减少使用称为批处理渲染技术的绘制调用,可以将精灵图集添加到项目中。将单个精灵、精灵图集图像甚至文件夹分配给精灵图集,将它们打包成一个单独的精灵图集图像以进行渲染。这个图集将是唯一被绘制的资产,而不是多个精灵图像,其中每个额外的精灵图像都会向渲染器添加一个绘制调用。

作为一项附加活动,从 创建 菜单中选择 创建 | 2D | 精灵图集项目 窗口中创建一个精灵图集。然后,您可以将导入的切片精灵图集分配给 打包对象 列表或 精灵 文件夹本身。您可能需要验证在 项目设置 | 编辑器 | 精灵打包器 | 模式 = 精灵图集 v2 – 启用 中该功能是否已启用(默认)。

现在我们已经将瓦片图分成单个精灵,我们准备创建一个瓦片调色板。精灵将被添加到瓦片调色板中,以创建用于绘制关卡的瓦片。让我们在下一节中创建我们的第一个瓦片调色板。

附加阅读 | Unity 文档

精灵 编辑器docs.unity3d.com/2022.3/Documentation/Manual/SpriteEditor.xhtml

创建瓦片调色板

要开始创建瓦片调色板,请执行以下步骤:

  1. 首先,通过转到 窗口 | 2D | 瓦片调色板 打开 瓦片调色板 窗口。

  2. 我们将使用 塔防(俯视) 游戏资源作为我们的主要环境图,因此通过点击 创建新调色板 下拉菜单并选择 新建调色板 来创建一个新的 瓦片调色板。这个下拉列表是我们将选择当前调色板以在场景视图中绘制瓦片图的地方。

  3. 命名为 Environment Main;保持默认设置,因为我们使用的是矩形网格。

  4. 点击如图 图 2.6 所示的 创建 按钮。

  5. 瓦片调色板是一个保存在 项目 文件夹中的资产,因此您将被提示保存它。选择我们之前创建的文件夹 – Assets/Tile Palettes

图 2.6 – 创建瓦片调色板和瓦片

图 2.6 – 创建瓦片调色板和瓦片

附加阅读 | Unity 文档

创建瓦片调色板:docs.unity3d.com/2022.3/Documentation/Manual/Tilemap-Palette.xhtml

调色板创建完成后,我们可以继续进行在开始绘制关卡之前所需的最后一步,我们将在下一节通过向调色板添加瓦片来解决这个问题。

创建瓦片

瓦片是瓦片调色板用来将精灵绘制到场景中的东西。你可能想知道为什么我们已经有切片并准备好作为单独图像使用的精灵,还需要创建瓦片。那是因为瓦片在绘制瓦片图时可以提供额外的功能。其他类型的瓦片包括 规则瓦片动画瓦片,但我们将从默认的瓦片类型开始使用。

让我们先从将 towerDefense_tilesheet.png 精灵图集从 Assets/Sprites 文件夹拖动到我们已为他们准备的 Assets/Tile Palettes/Tiles 文件夹开始。结果应该看起来像 图 2**.6 中的瓦片调色板窗口。

在我们开始绘制我们的关卡之前,让我们快速查看两种非常有用的瓦片类型。

瓦片类型

常见的瓦片类型包括:

  • 规则瓦片:这允许我们创建规则,其中指定的相邻瓦片将被用来更容易地绘制复杂形状。这本质上是一个自动绘图工具。这是一个节省大量时间的方法!

要创建一个新的规则瓦片,切换到 Assets/Tile Palettes/Tiles 中的 Rule Tiles 文件夹。在如图 检查器 窗口中,在 Environment Area 1 内右键单击:

图 2.7 – 规则瓦片设置

图 2.7 – 规则瓦片设置

我们将添加瓦片规则来创建一个带有适当角落精灵的填充正方形,与水平和垂直边精灵相邻。这总共需要九个精灵——四个在边缘,四个在角落,一个在中心。

设置 9,然后按照 图 2**.7 作为指南,分配环境精灵图集中的所有精灵。一旦分配了精灵,我们就可以设置“3 x 3”的盒子,这些盒子可以可视化规则的行为。再次使用 图 2**.7,相应地设置盒子规则。

额外阅读 | Unity 文档

规则 瓦片docs.unity3d.com/Packages/com.unity.2d.tilemap.extras%403.0/manual/RuleTile.xhtml

  • 动画瓦片:这允许我们通过分配几个精灵以指定速度相互替换来创建精灵动画。

要创建一个新的动画瓦片,切换到 Assets/Tile Palettes/Tiles 中的 Animated Tiles 文件夹。在 项目 窗口中右键单击以打开 创建 菜单,然后在新的文件夹中转到 创建 | 2D | 瓦片 | 动画瓦片。要使用此瓦片,将一系列精灵拖到 拖动精灵或精灵纹理资产以开始创建动画瓦片 部分。

额外阅读 | Unity 文档

动画 瓦片docs.unity3d.com/Packages/com.unity.2d.tilemap.extras%403.0/manual/AnimatedTile.xhtml

为了快速回顾我们创建的所有文件夹以组织和包含所有我们的艺术资产,我们的 项目 窗口应该看起来类似于以下内容:

图 2.8 – 项目文件夹结构

图 2.8 – 项目文件夹结构

在本节中,你学习了如何创建瓦片调色板,了解了如何导入和切割精灵图集以创建单个精灵,并探讨了使用规则瓦片使绘制更容易的优势。

这是我们绘制收集游戏关卡环境所需的所有先决设置,所以让我们开始做吧。在下一节中,我们将添加几个瓦片图,用于构成关卡设计的不同组件。

使用瓦片图构建收集游戏环境

我们需要一个新场景来放置游戏关卡,所以请转到文件 | 新建场景 (Ctrl/Cmd + N)。这将打开新建场景窗口,在那里我们将选择Lit 2D (URP)场景模板,然后点击窗口右下角的创建按钮,如图下截图所示:

图 2.9 – 新场景模板

图 2.9 – 新场景模板

场景模板 – Unity 文档

Unity 提供了一套内置的场景模板,但您可以添加自己的用户定义模板来创建包含所有起始内容的新场景。有关创建场景模板的更多信息,请参阅 Unity 文档:docs.unity3d.com/2022.3/Documentation/Manual/scene-templates.xhtml

一个新的游戏关卡,因为我们的游戏将完全由单个场景(即单个游戏关卡)组成。

在保存了我们的新游戏场景后,我们就可以在下一节开始添加瓦片图了!

向场景添加瓦片图

瓦片图是添加到 GameObject 上的组件,它提供的功能就像我们之前讨论过的任何其他组件一样。瓦片图组件存储和管理用于创建 2D 环境和关卡所使用的瓦片资源。它还依赖于一个网格组件,该组件提供视觉引导并使绘制的瓦片对齐。我们将制作多个瓦片图,以服务于不同的视觉和功能目的。

要在场景中创建瓦片图,请从瓦片图 – 背景开始。请参考图 2.11 以了解在层次结构窗口中此操作的示例。

在下一节中,我们将学习如何控制多个瓦片图的绘制顺序。

层级排序

就像我们在单个精灵上有排序层一样,我们在瓦片图上也有。瓦片图排序层将允许我们在不同的层上绘制精灵,以控制前后层叠(绘制在其他人之上或之后的对象)并应用于整个瓦片图(构成瓦片图的全部精灵)。

让我们通过点击排序层下拉列表创建一个新的排序层用于背景瓦片地图,当前该下拉列表的值为默认,然后点击添加排序层…检查器窗口将切换到标签与层设置,我们将指定以下层及其顺序:背景碰撞器对象前景

图 2.10 – 排序层

图 2.10 – 排序层

渲染器将按照它们在这里出现的顺序绘制层,层 0背景)在后面,层 4前景)在前面,中间的层将在它们各自的位置绘制。

点击+按钮将向列表中添加一个新层,而点击已选中的层旁边的-按钮将移除它。将字段左侧的=向上/向下拖动(例如,层 0)将重新排序。一旦我们完成添加和设置层顺序后,再次点击层次结构窗口中的Tilemap - Background,并在瓦片地图渲染器排序层下拉字段中选择背景

我们仍然有在同一个排序层上创建第二个瓦片地图背景的选项,我们可以通过使用层中顺序字段来控制哪个背景绘制在另一个背景之上/之下——如果你有两个不同风格的背景环境并且想要分别管理它们,这将很有用。

我们终于准备好在下一节开始使用我们的瓦片进行绘制了!

在场景视图中绘制关卡

如果你曾经使用过绘图程序,那么在瓦片调色板工具栏中的绘画工具将看起来很熟悉,只是你现在将限制在网格上绘画。画笔、填充、框填充和橡皮擦工具都是绘图程序中的标准绘画工具。

我们不会详细涵盖所有绘画工具,但我鼓励你在绘制你的环境和关卡设计时自己探索它们 – 不要害怕犯错!像 Unity 中的大多数事情一样——以及几乎每个程序——你可以通过使用 Ctrl/Cmd + Z 快速撤销操作。

在进行过程中记得保存你的进度,以保留你喜欢的部分。享受你的绘画过程,并花时间进行实验,因为你永远不知道这种创造力的方向在哪里。

场景导航

作为在绘制关卡时在场景视图中导航的提醒,请参考第一章中的导航场景视图部分。

每次你想在场景视图中使用绘画工具进行绘制时,请遵循以下步骤:

  1. 首先,选择你想要绘制的瓦片地图,无论是在层次结构窗口中还是在瓦片调色板窗口内,使用活动瓦片地图下拉列表(位于工具栏下方)。

  2. 使用位于左侧活动瓷砖图部分下方瓷砖调色板下拉列表中选择你想要绘制的瓷砖调色板。例如,我们将选择我们之前创建的环境主调色板。

  3. 通过在瓷砖部分网格中点击它来选择要绘制的瓷砖。记住,我们是从我们的关卡设计的背景开始,所以选择一个可以用来填充大面积的实心方块瓷砖。

  4. 要绘制单个瓷砖,请在场景视图中点击网格单元格,或者通过点击并拖动来绘制精灵的连续线条,选择画笔工具(画笔图标)。

  5. 要绘制一个用所选瓷砖填充的矩形形状,请选择方块填充工具(方块图标)。

  6. 你还可以使用填充工具(水桶图标)用所选瓷砖填充更大的连续网格单元格区域。

  7. 橡皮擦工具可以通过点击它们或点击并拖动来擦除单元格网格中的瓷砖。

下图说明了在层级窗口中选定的背景瓷砖图(A),在检查器窗口中查看其组件(B),我们正在使用它来绘制背景的环境调色板(环境主)(C),以及当前选定的瓷砖(草地)(D)和正在使用的绘画工具(画笔)(E)。

图 2.11 – 层级和瓷砖调色板窗口中的瓷砖图

图 2.11 – 层级和瓷砖调色板窗口中的瓷砖图

额外阅读 | Unity 文档

在瓷砖图中绘制docs.unity3d.com/2022.3/Documentation/Manual/Tilemap-Painting.xhtml

作为我们在这里绘制内容的提醒,我们正在为我们的收集游戏关卡创建背景——定义一般游戏区域。为了可视化整体关卡设计计划,可能有助于首先在纸上绘制一些你的想法,然后使用这些想法在编辑器中开始绘制(如有需要,请参考我的初始草图作为指南)。

纸质原型设计

纸质原型设计是游戏设计过程中广泛使用的方法,它可以帮助你在投入时间编写代码或创建数字艺术资源之前测试你的想法。这也是验证你的游戏玩法和早期发现潜在问题的快速方法。

在尝试你的关卡设计时,请考虑我们将在以下部分引导玩家到达关卡中我们将放置收集品的区域。不要害怕犯错;根据游戏测试对游戏进行更改是您将不得不定期做的事情——迭代更改以改进游戏玩法或平衡难度。

在调整环境和其他关卡设计元素之前,让我们确保通过调整相机设置,我们的艺术资源在游戏视图中看起来最好。

图像设置以获得清晰的图形

Unity 在新场景中提供的默认设置并不总是适合您将要工作的游戏资产。因此,我们将调整我们的精灵图导入设置和场景相机,以确保它们看起来最好!

我们在瓦片图图像中的单个图像是 64 像素的方形(注意,这些尺寸可能不同,取决于游戏设计对艺术作品所需尺寸的规定)。与瓦片图图像一起工作的最佳方式是确保单个精灵大小等于网格大小。由于瓦片图网格单元格的大小设置为等于一个 Unity 单位,因此我们必须将每单位像素数设置为等于一个 Unity 单位。

要设置我们的塔防(俯视)资产中精灵的每单位像素数,请点击位于Assets/Sprites文件夹中的towerDefense_tilesheet.png图像以查看导入设置在64(像素)。

现在,我们必须解决相机设置问题,以便 64 像素的精灵图像在屏幕上以原生分辨率表示。这是我们所说的清晰图形——原生大小。这需要一点数学计算和针对目标平台的首选分辨率的决定。假设大多数桌面系统上的玩家——我们的目标平台是收集游戏——的屏幕分辨率为 1,920 像素宽 x 1,080 像素高,让我们使用这个值。

场景层次结构窗口中选择主相机;然后,在投影部分,通过将屏幕分辨率高度除以每单位像素数,然后将结果除以 2 来设置正交相机投影大小(这是一个垂直值)——我们之所以除以 2,是因为大小值是相机垂直视场的一半。以下是计算结果在检查器窗口中的样子 – (1,080 ÷ 64) ÷ 2 = 8.4375

图 2.12 – 相机的正交大小

图 2.12 – 相机的正交大小

现在垂直延伸的精灵数量对于清晰图形来说是最佳的!为了在编辑器中可视化结果,切换到游戏视图(通过点击位于主工具栏下方的场景旁边的标签)并将纵横比下拉列表值设置为全高清(1920x1080)——默认情况下;这是设置为自由纵横比

在本节中,我们学习了如何在场景视图中绘制瓦片以创建背景环境并定义关卡的游戏区域。我们还学习了如何使我们的艺术资产看起来最好!接下来,我们将添加一些 2D 灯光——利用通用渲染管道(通用 RP,或 URP)的 2D 功能——以增强环境设计。

添加 2D 灯光

通用 RP 2D 渲染器允许我们通过添加 2D 灯光来增强我们的环境!让我们在代表环境设计中光源的一些瓦片位置添加一些 2D 灯光。按照以下步骤添加一些光源

  1. 首先,为将绘制在背景瓷砖图之上的瓷砖创建一个新的瓷砖图,并将其命名为Tilemap - Objects

  2. 排序层设置为对象

  3. 创建一个新的Environment Objects

  4. topdowntanks_onlyObjects_default精灵图集中的精灵添加到创建瓷砖中。

  5. 瓷砖 调色板窗口中,将Tilemap - Objects设置为活动瓷砖图

  6. 选择星形瓷砖,并使用画笔工具在场景中绘制几个。

重要提示

要专注于特定瓷砖图,同时将场景中的其他 GameObject 淡出,请使用瓷砖图聚焦模式(场景视图中的浮动叠加层)。

好的,环境中有一些光源;现在,按照以下步骤给它们添加一些 2D 光源:

  1. 在场景的Lights根目录下创建一个新的空 GameObject。

  2. 右键单击Lights GameObject——以打开创建菜单——然后选择Light | Freeform Light 2D | Circle

  3. 使用移动工具,将其定位在其中一个瓷砖上。

  4. 你可以尝试看看什么对你来说看起来不错。我将灯光的混合部分的混合样式值更改为加法(默认是乘法),以便更好地可视化示例截图。对于完成的游戏,我会降低全局光源的强度,并在环境中添加的许多 2D 光源上使用乘法混合样式,以设定正确的光照设计基调。

额外阅读 | Unity 文档

灯光混合 样式docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4015.0/manual/LightBlendStyles.xhtml

  1. 调整半径内聚光角度/外聚光角度强度衰减强度值,直到满意。

  2. 对于你环境设计中的所有光源,按需重复此操作!

你可能需要调整场景中的整体光照,以充分利用 2D 光源。通过在场景层次结构窗口中选择全局光 2DGameObject 并调整光 2D 组件的强度值来完成此操作。

在场景中添加 2D 光源的结果可以在图 2.13*中看到——在玩家角色(瓢虫)的左右两侧。在接下来的章节中,我们将使用不同类型的 2D 光源。

在本节中,你学习了如何有效地将 2D 光源添加到你的环境设计中。下一节将解决一个缺失的要求,通过使用TilemapCollider2D组件使我们的关卡可玩。

使关卡可玩——Tilemap Collider 2D

在我们能够使关卡可玩之前,我们需要对关卡设计做一些准备——我们将在下一章通过添加自定义脚本和映射输入来使玩家角色可控制。

为了防止玩家穿越到关卡中指定的区域,我们需要在 tilemap 中添加一种特殊的碰撞器。碰撞器是 Unity 物理系统的一部分,为开发者提供了与 GameObjects 交互的方式,类似于现实世界中的物体工作方式。

碰撞器阻止事物相互进入,并且对象的碰撞都可以通过代码作为事件来响应——我们将在添加游戏功能时进一步探讨这一点。

让我们在场景中添加一个新的 tilemap 来定义玩家不允许进入的区域。这将与创建之前的 tilemap 类似,但需要添加一个 TilemapCollider2D 组件:

  1. 为将要在背景 tilemap 上绘制并包含玩家无法穿越的区域的新 tilemap 创建一个新的 tilemap。命名为 Tilemap - Collider

  2. Sorting Layer 设置为 Collider

  3. 让我们使用一个 Rules Palette

  4. Assets/Tile Palettes/Rule Tiles 文件夹拖入。

  5. Tile Palette 窗口中,将 Tilemap - Collider 设置为 Active Tilemap

  6. 选择规则图块,并使用 Filled Box 工具在场景中绘制区域。

现在,为了使这个 tilemap 具有交互性,在 Hierarchy 窗口中选中 Tilemap - Collider,在 Inspector 窗口的底部点击 Add Component 按钮。在打开的对话框顶部的搜索字段中输入 Tilemap,然后选择 Tilemap Collider 2D 项以添加它——我们可以使用默认值。

参考以下图示,了解我们正在创建的内容:

图 2.13 – 场景视图中碰撞器关卡设计示例

图 2.13 – 场景视图中碰撞器关卡设计示例

现在你已经了解了如何添加 tilemap、在场景中绘制图块以及排序 tilemap 层以设置正确的绘制顺序,花些时间填充环境细节。这是你实验环境设计细节和思考玩家如何通过关卡完成游戏目标——收集物品的时间。

在本节中,你学习了关卡设计的一些关键元素。你了解了如何通过导入和切割精灵图集图像来创建和操作 tilemap。为了绘制游戏关卡,你使用了图块来绘制单个精灵和区域。通过添加灯光和使图形清晰,你完成了设计。最后,我们通过使用碰撞器创建了可交互的对象。

在接下来的几节中,我们将通过添加 C# 脚本来移动玩家和收集物品,将这些概念结合起来。

C# 脚本创建入门 – IDE、SOLID 原则和设计模式

在 Unity 中制作游戏时,你需要创建自己游戏特定功能。我们需要学习 Unity 如何提供编程支持,使用 C# 语言编写脚本以实现这一点。

在本节中,您将了解默认代码编辑器,深入了解 C#语言的具体内容,并学习编写可管理、可维护和可扩展代码的最佳实践方法。

IDE – Visual Studio Community 2022

Unity 作为 Unity 编辑器安装的一部分提供的默认集成开发环境IDE)是Microsoft Visual Studio 2022 社区版。社区版对学生和个人免费,提供功能强大的 IDE,包括适用于开发每个阶段的全套工具和功能,包括针对 Unity 的特定工具。

额外阅读 | Unity 视觉脚本

我们将在本书中使用 Visual Studio 2022 编写 C#代码,但值得注意的是,Unity 还提供了一个使用基于图系统的视觉脚本选项,而不是传统的代码:unity.com/features/unity-visual-scripting

Visual Studio 2022 的界面,拥有标准窗口、功能和可编辑的脚本,可以如下所示:

图 2.14 – Visual Studio 2022 社区版

图 2.14 – Visual Studio 2022 社区版

除了基本的脚本编辑功能外,让我们快速了解一下 Visual Studio 2022 提供的一些功能:

  • IntelliSense:通过提供命名、参数信息和单词的建议来帮助代码完成,节省您按键!

  • IntelliCode:通过人工智能AI)增强您的软件开发,可以自动完成代码 – 甚至整行!

  • 代码镜头:在 IDE 中保持对代码结构的关注,无需离开,代码引用和上下文信息尽在指尖!

  • 实时共享:提供实时协作编码会话,您可以与项目中的其他成员共享 – 一起工作,更快地产生结果!

  • 集成调试:允许开发者在代码运行时控制执行、逐行执行代码并交互式地检查其状态 – 看看您的代码在运行时做了什么!

额外阅读 | Visual Studio

Visual Studio 2022 的新功能docs.microsoft.com/en-us/visualstudio/ide/whats-new-visual-studio-2022?view=vs-2022

在打开 IDE 编辑脚本之前,我们首先需要创建一个!创建脚本可以通过几种方式完成。良好的做法是从一开始就保持项目文件的组织,因此让我们首先在项目根目录下创建一个名为Scripts的新文件夹 – Assets/Scripts

要在此文件夹中创建新脚本,请执行以下操作:

  1. 确保在项目窗口中当前选中的文件夹是Scripts文件夹。

  2. 项目窗口内右键单击以打开创建菜单(或使用+按钮)。

  3. 选择C# 脚本

  4. 将脚本重命名为其默认名称 PlayerController(它当前被高亮显示,以便立即命名)。

小贴士

不要在脚本文件名中使用空格或以数字开头命名脚本。这两者都是 C#类的命名规则违规,因此你的脚本将无法工作。你可以使用下划线字符代替空格。然而,这种命名约定风格在开发者社区中通常是不受欢迎的——除非它是私有成员变量的名称的第一个字符(这是 C#中的常见约定,并在本书中使用)。

作为使用良好命名约定标准的参考——并且坚持使用(因为一致性和良好的命名可以提高所有人的代码可读性)——请参阅以下指南:C#编码标准和命名约定

你也可以通过窗口底部的 添加组件 按钮直接在 检查器 窗口中添加新脚本(即组件)(当你当前选中一个 GameObject 时)。点击 添加组件 按钮,然后选择 脚本 >

你将被提示输入脚本名称,然后点击 Assets 文件夹——因此我更喜欢之前提到的方法在特定文件夹中创建。

新脚本是从 Unity 提供的默认模板生成的(你可以修改模板以适应个人需求;请参阅以下 C# 脚本模板 提示框)。新脚本也赋予了 .cs 文件扩展名,表示它们是 C#脚本文件(文件扩展名在 Unity 项目窗口中不可见)。

我们在这里将其命名为 PlayerController,因为下一节我们将编写的第一个 C#代码是赋予我们之前创建的瓢虫玩家角色从玩家输入移动的能力。

C#脚本模板 - Unity 文档

脚本模板是 Unity 在创建新脚本时生成默认 C#代码所使用的,为你的脚本提供了一个常用的代码起点。你可以根据自己的更改、添加、注释等修改模板。

更多信息可以在以下链接中找到:如何自定义 Unity 脚本模板

Unity 在脚本生成过程中也将脚本中的类命名为 PlayerController(这是脚本模板提供的一个功能)。请注意,对于需要添加到 GameObject 中的脚本(否则,如果你尝试添加,你会得到一个错误:“无法添加脚本组件‘ScriptName’,因为找不到脚本类。’”),文件名和类名必须匹配。参见图 2.14 以了解新脚本内容的示例。

当安装 Unity 编辑器时,VS2022 已安装并配置为项目中 C# 脚本的默认脚本编辑器。您可以通过转到 编辑 | 首选项… 并选择 外部工具外部脚本编辑器 下拉菜单将设置为 Microsoft Visual Studio 2022 [版本] 来验证此设置。

现在我们已经创建了一个脚本并确认了脚本编辑器已配置,编辑脚本就像执行以下任何一项操作一样简单:

  • 项目 窗口中双击脚本。

  • 对于添加到场景中 GameObject 的组件,在 检查器 窗口中右键单击组件(或使用垂直省略号菜单)并选择 编辑脚本

  • 使用 Unity 项目 资源管理器 窗口从 VS 内部打开。

我们已经学会了如何在 VS 中创建一个新的脚本并打开它进行编辑,但我们仍然需要理解我们所看到的 C# 代码的含义。在下一节中,让我们剖析玩家控制器脚本的生成 C# 代码。

C# 语言——面向对象编程(OOP)

Unity 通过开源的 .NET 公共语言运行时(CLR)提供编码支持,并使用 C# 语言——这些都是微软的技术。C# 是一种托管语言,这意味着它通过——主要是——管理内存和 CLR 安全执行代码(即不是由操作系统直接执行的字节码)提供了一个安全的编码环境。

代码可以编译为即时运行(JIT)或预编译运行(AOT),具体取决于使用的 Unity 提供的两个脚本后端中的哪一个——Mono 或 中间语言到 C++(IL2CPP)。这里的主要区别在于编译时间,但某些平台构建可能需要其中一个。

Unity 是一个 C++ 游戏引擎

Unity 游戏引擎是用 C++ 编写的,但 C# 被用作“更友好”的编程语言。C# 中的 Unity 对象类型与原生 C++ 对象相对应。

好吧,前面的段落相当无聊。所以,现在来点必要的“书呆子”警告…我们将在这和接下来的两个部分中讨论一些编程概念、原则和设计模式。虽然这些内容不会教你如何编写循环或评估变量的代码基础知识,但我认为它们是开发者旅程开始时的重要介绍。

不仅学习基础知识(例如如何编写循环和评估变量的代码)很重要,而且还要理解代码应该如何结构的更广泛概念——为了你未来的开发者自我(以及可能为了你未来的开发者队友)。

尽管我已经尝试使这一节简洁并提供了相关的现实世界类比,但这些关于代码应该如何结构的更广泛概念可能对一些人来说还太早,所以在这种情况下,不要害怕——仍然要阅读并尽力理解,但如果很难理解,也不要担心。

我们将在书中通过项目来实现所有这些(也许你甚至会回忆起这里所说的内容,或者想要回来重新阅读)。

好的,现在我们已经讲完了这些,让我们深入探讨那些将提升你在 Unity 开发者旅程中编码技能的概念!

C#语言

C#(发音为 C sharp)是一种现代的、面向对象、类型安全、受管理的编程语言。面向对象编程是一种计算机编程模型,它将软件设计围绕对象组织,对象可以包含数据和代码(类),而不是函数和逻辑。

C#主要是一种类型安全的语言,这意味着特定类型只能通过它们定义的协议进行交互,这确保了每种类型的连续性。例如,你不能像与数字类型交互一样编写与字符串类型交互的代码。

面向对象编程有四个基本概念用于处理对象。我们现在将简要回顾这些概念,并在接下来的章节中编写游戏代码时应用这些概念:

  • 封装:隐藏对象的内部数据和行为(类)以防止其他对象访问,并且只允许通过公共方法访问;防止对象直接修改其他对象可以减少外部错误发生的可能性。

  • 抽象:一个不完整的实现,它隐藏特定细节,只提供所需信息,但也不与任何特定实例相关联,因为抽象类仅打算成为其他类的基类。它提供了一个模板,用于组织具有所需细节的对象层次结构,例如,聚光灯(对象)是光(抽象类)的一种类型,它需要一个亮度属性。

  • 继承:由于继承,抽象成为可能,因为当从一个基类创建新类时,派生类继承了基类的所有属性和方法,这意味着我们可以重用、扩展和修改基类的行为。在前面的简单示例中,如果点光源和聚光灯都从光抽象基类继承,那么它们将具有相同的亮度属性。

  • 多态性:这是一个希腊词,意思是一个名称具有多种形式具有多种形式,在 C#中我们可以将其理解为具有多个函数的一个名称。多态性允许一个类通过重写它来拥有多个具有相同名称的实现,这要归功于继承。

继续前面的光示例,我们可以通过为ChangeLightRadius()方法提供不同的代码,在不同的聚光灯和点光源抽象中实现从基类继承的方法,聚光灯有一个锥形 光束

相反,点光源是全方向的,因此它的行为不同。多态性有助于代码重用,因为一旦编写、测试和实现,类就可以根据需要重用,节省大量时间,同时使事物更加逻辑化。

额外阅读 | C#

面向对象编程的 Microsoft C# 文档learn.microsoft.com/en-us/dotnet/csharp/fundamentals/object-oriented/

MonoBehaviour

MonoBehaviour 类是每个可以分配给 GameObject 的 Unity 脚本的基类——这是 Unity 的组件化架构。你之前已经看到过“类”这个词,所以为了清晰起见,类是一种对象类型。你将创建自定义对象类型,将变量、方法和事件组合在一起,以给你的脚本提供数据和实现其预期功能。

MonoBehaviour 继承类——由于继承——为添加到 GameObject 的组件提供了一组基础变量、方法和事件;这也是一个要求——除非继承自 MonoBehaviour,否则你不能将脚本添加到 GameObject。在本书的项目中,我们将使用 MonoBehaviour 基类为我们自定义脚本组件提供的功能。

重要提示

由于 MonoBehaviour 类需要它是 场景层次结构中对象的实例,因此你不能使用 new 关键字来创建其新实例,就像你为不继承任何类的类那样做。

我们之前创建的 PlayerController 类的代码,由默认的 Unity 模板生成,已经为我们继承自 MonoBehaviour——正如以下 public class 声明行之后的冒号(:)所示:

using UnityEngine;
public class PlayerController : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
    }
    // Update is called once per frame
    void Update()
    {
    }
}

// (C#) | 代码注释

单行注释以两个反斜杠(//)开始。C# 编译器会在运行程序时移除这些文本并忽略它们。

不要害怕对代码中的任何内容进行注释以阐明意图。在编程时,总会有至少两个人在查看你的代码——你和你自己在 6 个月后(或者有时在任何周一早上)。你忘记你为什么要这样做。

自注释代码/自文档代码是讨论代码注释时可能会听到的一个术语,它依赖于良好的类、方法和变量命名。这当然是重要的。然而,我的观点是,你可以花费大量时间来深思熟虑地命名事物——这可能会打断你的思路——而简单的几句话在注释中可以迅速带来清晰的意图。

Start()Update() 方法也为我们提供了。MonoBehaviour 提供了初始化任何代码的 Message 事件,并在每一帧更新时运行代码的 Update()MonoBehaviour 还提供了其他几个 Message 事件,我们将在接下来的章节中探讨它们。

命名空间(C#)

使用 UnityEngine; 在上一个 PlayerController 代码示例的顶部是赋予包含代码访问 MonoBehaviour 基类权限的关键。命名空间 是一种组织和提供代码中分离层次的方式。它们在为大型项目中的类和方法提供容器以控制作用域时非常有用。你可以,并且应该,为你的类添加自己的命名空间。

在理解了如何创建新的 C# 脚本、在 VS IDE 中打开它以及识别从 MonoBehaviour 派生类的基本部分之后,我们现在可以探索一些最佳实践原则,这些原则是我们编写使用 SOLID 原则的游戏代码时应该遵循的。

SOLID 原则

SOLID 是一个缩写,用于表示罗伯特·C·马丁提出的面向对象设计的五个基本原则,适用于各种编程语言,而不仅仅是 C#。它们是基本的设计原则,旨在保持你编写的代码可维护和可扩展,同时避免在未来不必要地重构(重构现有代码)。生成的代码应该易于其他开发者阅读和遵循——避免编写意大利面代码!

意大利面代码

意大利面代码 是一个用来描述项目中无结构、难以理解和维护的源代码的短语。原因可以归因于几个因素,通常归结为没有遵循最佳实践,如 SOLID 原则、不要重复自己DRY)和清洁代码——格式正确且以易于他人或自己在 6 个月后阅读或修改的方式组织的代码!

SOLID 原则是以下内容:

  • S – 单一职责原则SRP):你的代码中的每个类或函数(对于语言无关的术语,但我会将 C# 中的函数称为方法)应该只有一个职责。

这意味着 只做一件事 而不是所有事情,包括厨房用具!类内的所有内容都应该有助于它存在的单一原因。多个单功能类可以协同工作以完成更大、更复杂的任务。如果出现问题,通常更容易知道错误在哪里,并且它也将与该类隔离。

  • O – 开闭原则OCP):类可以被扩展但不能被修改。

不,这并不是一个矛盾;如何同时既是开放的又是封闭的?嗯,这意味着一个类应该对扩展开放但对修改封闭。通过多态,我们可以通过继承类来改变抽象基类的行为,同时不修改基类。通过设计永远不会改变的基类,你可以防止不希望的变化传播到依赖的类,从而有助于代码的可重用性,并最终使代码更难工作。

  • L – Liskov 替换原则:引用基类的方法必须能够无缝地使用派生类。

任何使用基类引用的函数都应该能够使用该类的派生类,而无需知道它。这听起来可能有点令人困惑,所以让我们使用之前 OOP 概念中的简单例子——一个引用灯的方法不应该关心那盏灯是聚光灯还是点光源。我们可以用一个点光源替换聚光灯,代码应该仍然可以正常工作。

我们可以通过使用一个接口(一种类似于类的类型,但它只代表声明结构——这通常被称为契约,但您也可以将其视为类定义的蓝图)来实现这种替换。

接口还可以通过让基类接受接口作为抽象引用以注入不同功能来实现开闭原则。功能可以扩展,而封闭基类中的引用保持不变。

  • I – 接口分离原则:几个较小范围的接口比单一的大规模接口更受欢迎。

类不应该包含在其单一职责角色中不使用的功能。在马丁的原始介绍中,他将其描述为胖接口缺点,其中大型多用途接口的功能可以分解成成员函数的组,以提供更好的内聚性。这可以防止类使用它们不需要以实现功能的功能。

  • D – 依赖倒置原则:使用抽象而不是直接类引用。

最后这个原则指出,具体类应该依赖于接口——或者抽象函数和类——而不是具体的函数和类。抽象类也不应该依赖于具体类;这里应该使用接口。这里的优势意味着代码需要更少的工作来更改,因为接口抽象解耦了具体类——一个类的更改也不会破坏另一个类。松散耦合的代码更灵活,更容易测试。太棒了!

额外阅读 | SOLID 原则

要阅读由罗伯特·C·马丁提出的 SOLID 原则背后的原始引言和思想,您可以参考这篇文章:learn.microsoft.com/en-us/archive/msdn-magazine/2014/may/csharp-best-practices-dangers-of-violating-solid-principles-in-csharp

或者,只需在主题上做一些网络搜索:www.google.com/search?q=SOLID+programming+principles

在这个简短的介绍之后,你可能不完全理解这些原则,但不用担心——我们将在接下来的章节中实际使用它们,这样你就会了解它们是如何实现的。有了对 SOLID 原则的基本理解,我们现在可以讨论实现 SOLID 原则所提出思想的设计模式。

设计模式

现在你已经知道了 C#语言是什么,以及编写游戏时需要注意的一些原则,让我们看看如何在实践中应用它们。

当你为游戏编写代码时,你可以自己决定如何处理架构。在 Unity 中,没有任何限制或特定的设置方式——它可以像你喜欢的那么混乱或有序。

然而,应该很明显,你的代码越无序、越不结构化,将来就越难与之合作和扩展。我们已经通过 OOP 和 SOLID 原则接触了一些基本原理,但那些只是需要遵循的原则。为了执行这些原则,我们使用设计模式。

游戏开发中的常见设计模式

以下是一个常见设计模式的列表,用于在编写代码时解决常见问题——这绝对不是一个详尽无遗的列表,我们在这里只是简要介绍这些模式的原因:

  • MyClass.Instance.MyMember(其中Instance是静态变量,MyMember是任何公开可访问的变量或方法)。

这种模式因为误用而声名狼藉,但对于小型项目来说却非常有帮助——它快速且易于实现,并且可以在任何地方使用(你好,游戏马拉松!)。这种声誉主要与更广泛的项目相关,在这些项目中,静态变量使得代码更难调试和调试——普遍的观点是,当你使代码的每一部分都易于访问时,你只是在自找麻烦!

  • GameObject 脚本 API 中的GetComponent()方法。本质上,定位器将检索所需类或对象实例的引用,以便在调用方法中使用。

  • 观察者模式(事件):这是最受欢迎的设计模式之一。这种模式允许监听器注册到提供者并接收通知。这通常是通过一对一的关系来执行的,当提供者对象状态改变并调用通知时,任何数量的监听器都会收到通知。在 C#中,这种模式是通过事件实现的。事件是 C#中的一个特殊关键字,它强制执行正确的模式,只有声明类才能调用通知。

  • GameManager类,其中可以包含加载、播放、暂停、游戏结束、胜利、失败等状态。当进入和退出状态时可以触发事件,以便轻松扩展基于状态的功能。实现该模式的对象还可以将状态相关行为委托给其他类,例如在每一帧更新时调用的Tick()方法。

  • 命令模式:这种模式将执行函数的请求转换为一个包含所有请求所需信息的对象。当我们需要延迟或排队请求的执行,或者我们想要跟踪操作序列(如回放系统)时,命令模式在 C#中很受欢迎。

这种模式的另一个好处是它将调用类与执行过程的对象解耦——松散耦合的代码(一个类不会影响另一个类,减少依赖)更容易测试和维护!

  • Update()方法,每个都会添加到需要每帧更新的脚本列表中。

这种模式可以减少每帧调用Update()方法的次数,以减少开销——这些 Unity 消息是从原生 C++调用到托管 C#的,它们是有成本的!例如,管理器将是唯一具有在每帧更新时运行的Update()方法的对象,而许多引用的实例仅有一个管理器调用的Tick()方法。

额外阅读 | 编程模式

游戏编程模式(网络版免费):gameprogrammingpatterns.com/contents.xhtml

优化提示

如果你不需要使用 Unity 消息事件,那么请从你的代码中移除它们——即使是空的Start()Update()方法仍然被缓存并在每个从MonoBehaviour派生的脚本上调用,在Update()的情况下,它将在每一帧更新时被调用!

在本节中,你学习了如何使用 VS2022 IDE 创建和编辑脚本,并了解了 C#语言、SOLID 原则和一些常见的设计模式。

在接下来的章节中,我们将深入探讨具体用例,随着我们在我们的收集游戏中完善功能和功能,从下一节开始,我们将为我们的瓢虫角色组合一个简单的玩家控制器。

使用新的输入系统编写简单的玩家控制器

你可能会惊讶地发现,使我们的玩家角色移动的大部分工作已经被 Unity 的功能为我们完成了。本节中我们将探讨以下功能:

  • 新的输入系统:用于接收来自玩家的键盘设备输入。

  • 2D 物理引擎:用于将输入值转换为运动并提供与环境对象的交互。

新输入系统

新的输入系统是一个提供对输入设备支持的包,它以灵活和可配置的方式控制项目中的对象。它还取代了传统的输入管理器。我们首先需要确保它已安装以便使用:

  1. 通过转到文件菜单中的窗口 | 包管理器来打开包管理器,并按照以下步骤操作。

  2. 确保将下拉列表设置为包:Unity 注册表(位于窗口标签下方)。

  3. 在包列表(对话框的左侧)中找到输入系统并点击以选择它。

  4. 现在,点击窗口右下角的安装按钮。

图 2.15 – 输入系统安装包管理器

图 2.15 – 输入系统安装包管理器

如果安装完成后,你收到一个警告说项目正在使用新的输入系统包,但新的输入系统的本地平台后端在玩家设置中未启用,请点击以启用后端。请注意,这将重新启动编辑器 – 因此,如果你在场景中有未保存的更改,请在提示时点击保存

额外阅读 | Unity 文档

输入 系统docs.unity3d.com/Packages/com.unity.inputsystem%401.3/manual/

我们已经准备好在安装了输入系统后,在下一节中开始编写玩家移动的代码。

玩家控制器脚本

在我们开始编码之前,我们首先需要了解我们可以用两种方式与新的输入系统一起工作:

  • 直接从输入设备接收输入

  • 间接通过输入 动作接收输入

我们将在书中介绍这两种方法,但我们将从直接从输入设备读取开始 – 键盘。在确定了接收玩家输入的方法后,我们现在就开始编码,因为我们已经在之前的一个部分中创建了我们的瓢虫角色的玩家控制器脚本。

接收键盘输入

通过键盘直接从输入设备处理玩家输入是直接的。你只需要获取当前键盘设备的引用并读取适合所需功能的属性。我们在Update()方法中读取输入,因为它每帧都会运行。

让我们看一下以下PlayerController代码中的Update()方法这一部分:

    // Update is called once per frame - process input
    void Update()
    {
        var keyboard = Keyboard.current;
        // Keyboard connected?
        if (keyboard == null)
            return;     // No - stop running code.
        if (keyboard.spaceKey.IsPressed())
        {
            // Move while holding spacebar key down.
            …

Update()方法(位于{}波浪括号之间),我们首先通过var keyboard = Keyboard.current;(在 C#中,每行要执行的代码必须以分号结尾)为当前键盘设备分配一个变量(用于存储数据的容器)。

var(C#)

var关键字是一个隐式类型的变量,它从赋值语句的右侧(等号之后的内容)推断类型。这意味着我们不需要为返回整数值的数学表达式使用int类型 - var将推断它应该是int类型。

Keyboard.current通过输入系统对我们的代码可用,只需在我们的脚本顶部使用using关键字包含InputSystem命名空间即可:

using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerController : MonoBehaviour
{
    …

一旦将当前键盘设备分配给我们的键盘变量,我们可以通过将变量与 null 值进行比较来检查是否已连接键盘,使用 == 操作符(一个 = 是用于赋值,而两个 == 等于 操作)。

在这里,我们可以看到如果键盘值等于 null,我们可以通过使用 return 关键字立即停止在此方法中运行代码:

        // Keyboard connected?
        if (keyboard == null)
            return;     // No - stop running code.
        …

null (C#)

null 值表示没有 对象 被分配。

spaceKey – and evaluating the value returned from the IsPressed() method to see whether it is true:
        if (keyboard.spaceKey.IsPressed())
        {
            // Move while holding spacebar key down.
            …

if (C#)

一个 if 语句测试条件是否评估为 truefalse,如果为 true,则执行直接 preceding 的块或代码行。

在 C# 中,一个存储 truefalse 值的变量类型称为 bool 类型(布尔)。当未指定特定值时,bool 类型有一个默认值 false

使用 2D 物理移动

我们正在接收玩家的输入,并且现在准备好处理它。耶!

从技术上来说,遵循单一责任原则,我们现在将创建第二个脚本来处理玩家移动。目前,由于我们的收集游戏很小,只有一个输入,它不需要更复杂的架构。

当玩家按下 空格键 时,让我们在按键按下时向前移动他们,并在释放时停止移动:

        if (keyboard.spaceKey.IsPressed())
        {
            // Move while holding spacebar key down.
        }
        else if (keyboard.spaceKey.wasReleasedThisFrame)
        {
            // The spacebar key was released – stop moving.
        }

else if (C#)

一个 else if 语句仅在之前的 if 语句评估为 false 条件时测试新的条件,如果为 true,则执行直接 proceeding 的块或代码行。为了评估多个条件,你可以级联多个 else if 语句,直到满足所需次数。

使用 Update() 方法非常适合处理玩家输入。然而,我们想要使用 2D Physics 来移动玩家,为此,我们需要使用 FixedUpdate() – 这会在每次物理 0.02(这个值通常不需要更改)时被调用。

你可能已经想知道我们如何告诉一个不同的 Unity 消息事件,该事件是 自动 调用来执行另一个 Unity 消息事件的代码。我们将简单地使用一个 bool 变量作为 标志 来指示应该执行代码。

让我们创建一个现在:

public class PlayerController : MonoBehaviour
{
    private bool _shouldMoveForward;
    …

公共和私有访问器(C#) | 序列化

访问器,如 publicprivate,定义了变量的作用域,即它如何被类内部和外部访问。将 public 访问器分配给变量使其对其他外部类可访问,而将 private 访问器使其仅对类内部可访问。如果你没有明确声明访问器,默认将是 internal(参考docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/access-modifiers)。

声明一个变量为公共的另一个功能是 Unity 将其标记为序列化。序列化是将对象状态转换为 Unity 可以使用的格式的过程——这对于在检查器窗口中可用字段是必需的。

我们将变量创建为私有字段,以便仅在PlayerController类内部可访问。我们在声明时没有为其赋值,因此它将有一个初始值false——我们的标志在程序启动时不会触发代码执行。

要在FixedUpdate()中执行移动玩家的代码,我们将它设置为true,当按下空格键时,并在释放时将其设置回false

我们玩家的输入代码现在看起来是这样的:

        if (keyboard.spaceKey.IsPressed())
        {
            // Move while holding spacebar key down.
            _shouldMoveForward = true;
        }
        else if (keyboard.spaceKey.wasReleasedThisFrame)
        {
            // The spacebar key was released - stop moving.
            _shouldMoveForward = false;
        }

我们将像这样在FixedUpdate()中检查_shouldMoveForward布尔标志的值:

    // FixedUpdate is called every physics fixed timestep
    private void FixedUpdate()
    {
        if (_shouldMoveForward)
        {
            // Process physics movement.
        }
        else
        {
            // Stop movement.
        }
    }

小贴士

请参阅InputSystem文档,了解键盘设备上所有可能的属性和方法。您还可以在按下.*(点号/句点)字符时,在 VS IDE 中查看可用的内容。IntelliSense 将提供所有可能的完成选项列表(例如,当输入keyboard然后.时,弹出列表将列出IsPressed()方法和wasReleasedThisFrame属性)。

Rigidbody2D

当使用物理系统时,在场景中移动具有与其他对象交互能力的对象非常简单——您可以得到很多现成的价值,因为您不需要自己编程这些交互。例如,移动玩家就像为移动方向设置一个速度值一样简单。

考虑以下对FixedUpdate()方法的补充:

    private void FixedUpdate()
    {
        if (_shouldMoveForward)
        {
            // Process physics movement.
            // Up is the direction the object sprite
            // is currently facing.
            Rb.velocity = transform.up * MoveSpeed;
        }
        else
        {
            // Stop movement.
            Rb.velocity = Vector2.zero;
        }
    }

我们添加了一行代码,将一个值赋给Rb对象的velocity属性,通过将transform.up值与MoveSpeed变量的值相乘。Rb是我们声明的公共字段,代表我们将添加到玩家 GameObject 中的Rigidbody2D组件,作为公共字段,也可以在检查器窗口中进行赋值。

让我们添加RbMoveSpeed变量的声明——默认值为10ff表示这是一个浮点值,一个以浮点表示法存储的数值)——以提供一些移动速度:

public class PlayerController : MonoBehaviour
{
    public Rigidbody2D Rb;
    public float MoveSpeed = 10f;

小贴士 | VS IntelliSense

您可以在Rb.velocity赋值行代码中首先输入MoveSpeed,但 VS 会抱怨它尚未声明,并显示一条红色的波浪线。您可以通过首先单击下划线单词,然后按Alt/Cmd + Enter,并在MoveSpeed下选择生成字段,轻松地将变量声明添加到代码中。

通过设置velocity的值,我们告诉对象向指定方向移动——这个方向由transform.up指示——并且以MoveSpeed乘数值指示的速度移动。玩家精灵将始终沿着transform.up指示的方向移动,无论其旋转如何,如下面的图所示:

图 2.16 –瓢虫精灵的 Transform.up 方向

图 2.16 –瓢虫精灵的 Transform.up 方向

Transform.up(Unity API)

这操作的是对象在Y轴(绿色轴)上的位置(世界空间中的位置)。它与Vector3.up类似,但Transform.up在移动对象时会考虑其旋转。

其他可用的轴有右侧(X轴——红色)和前方(Z轴——蓝色)。

当玩家释放空格键时,_shouldMoveForward被赋值为false,这导致Rb.velocity被设置为Vector2.zero——速度是一个Vector2结构,意味着它可以用来通过XY值表示 2D 位置,正如这种表示法——Vector2(float, float)所示。Vector2.zeroVector2(0, 0)的简写。

额外阅读 | Unity 文档

Vector2: docs.unity3d.com/ScriptReference/Vector2.xhtml

向量数学、Mathf 和 Quaternion

到目前为止,代码已经解决了根据精灵面向的方向向前移动玩家角色的方向问题。然而,它目前只面向一个方向——向上。我们将通过让玩家朝向鼠标指针的方向看,来引导玩家在环境中移动——控制前进运动。

我们可以通过读取另一个输入设备——鼠标——并使用一些向量数学来根据鼠标指针在屏幕上的位置旋转玩家对象来实现这一点。

看看下面的LookAtMousePointer()方法:

    private void LookAtMousePointer()
    {
        var mouse = Mouse.current;
        if (mouse == null)
            return;
        var mousePos = Camera.main.ScreenToWorldPoint(
            mouse.position.ReadValue());
        var direction = (Vector2)mousePos - Rb.position;
        var angle = Mathf.Atan2(
            direction.y, direction.x) * Mathf.Rad2Deg
               + SpriteRotationOffset;
        // Direct rotation.
        Rb.rotation = angle;
    }

让我们逐项分析代码:

  1. 就像我们验证了键盘的有效性一样,mouse被分配并检查是否为null(如果没有分配,则使用return停止代码的执行)

  2. mousePos变量通过mouse.position.ReadValue()方法的返回值赋值,该方法返回一个Vector2位置,并使用主相机引用Scene (Camera.main)将其Vector2屏幕空间位置转换为Vector3世界点(Unity 引擎使用的 3D 坐标系)。

  3. 通过简单的向量数学表达式计算鼠标位置到玩家Rigidbody2D位置的方向——从一个向量减去另一个向量是计算方向的一种快速方法。简单!

  4. 注意,你只能在同一向量类型上使用向量算术运算——这里,我们有 Vector3 表示鼠标位置,Vector2 表示 Rigidbody2D 位置。为了在表达式中将 mousePos 变量作为 Vector2 处理,我们通过在它前面加上 (Vector2) 来进行类型转换(我们只需要 2D 的 xy 值)。

  5. 现在,我们需要将旋转角度应用到玩家的 Rigidbody2D 上。Unity 提供了一个数学库,正好用于这种情况——Mathf。特别是,我们使用的是 Atan2 方法,它从 2D 向量返回一个弧度角度。

  6. 我们实际上希望我们的角度值以旋转度数表示——而不是弧度——所以,再次,Mathf 来拯救我们。Mathf.Rad2Deg 是一个转换常数(等于 360 / (PI x 2)),我们将它与返回的弧度相乘,然后,我们就得到了旋转度数值。SpriteRotationOffset 公共变量提供了一个偏移量,如果精灵的方向不是面向 Transform.right ——方程结果的方向(我们在这里确实需要一个 -90 度的值,因为我们的瓢虫精灵是指向上方的,而不是向右的)。

  7. 最后,我们将得到的旋转角度(以旋转度数表示)设置为 Rb.rotation 属性!

为了确保玩家的旋转每帧都被设置,在 Update() 的末尾添加对 LookAtMousePointer() 方法的调用:

    void Update()
    {
        …
        LookAtMousePointer();
    }

平滑旋转

每帧更新时设置 Rb.rotation 值足以使玩家指向鼠标指针的方向工作,但我们能做得更好!

我们可以使用 Unity 提供的插值方法,称为 Slerp,来实现更平滑的旋转并调整旋转速度以改变游戏玩法。Slerp 将平滑旋转并消除突兀的旋转,同时提供更好的游戏感觉,增强玩家体验。

Slerp (Unity API) | 额外阅读 | Unity 文档

Slerpspherically interpolated 的缩写。它提供了一个方法,通过一个名为 T 的量插值 AB 之间的值。与 线性插值Lerp)的区别在于,AB 向量被视为方向而不是位置点。

Quaternion.Slerp: docs.unity3d.com/ScriptReference/Quaternion.Slerp.xhtml

将直接旋转赋值(Rb.rotation = angle;)替换为以下代码片段:

        // Interpolated rotation – smoothed.
        // Forward (Z-axis) is what we want to rotate on.
        var q = Quaternion.AngleAxis(angle,
            Vector3.forward);
        Rb.transform.rotation = Quaternion.Slerp(
            Rb.transform.rotation, q, Time.deltaTime
               * LookAtSpeed);

让我们分解这段代码:

  1. 为了插值旋转值,我们需要使用一个稍微不同的方法,使用 Quaternion(基于表示旋转的复数,这些旋转可以很容易地进行插值,并且 Unity 内部用于表示所有旋转)。

  2. 我们首先需要使用AngleAxis()方法来计算围绕轴的旋转,以便将我们的角度度数转换为四元数值(var q)。在这里,我们提供Vector3.forward作为我们的旋转轴——参照图 2**.16,我们可以看到它将在XY平面上旋转瓢虫精灵,这正是我们想要的!

  3. 与需要浮点值作为角度度的Rb.rotation不同,我们现在需要分配一个四元数值,因此我们将不得不使用Rb.transform.rotation属性。

  4. 最后,我们将使用Quaternion.Slerp()来插值旋转值,从玩家角色的当前旋转到期望的旋转——指向鼠标指针的方向。

我们可以通过使用LookAtSpeed公共变量值来改变旋转速度。速度值乘以Time.deltaTime以使其成为帧率无关的旋转速度(这意味着它将在所有不同的运行游戏系统中保持一致)。

额外阅读 | Unity 文档

Time.deltaTime: docs.unity3d.com/ScriptReference/Time-deltaTime.xhtml

我们最终的变量声明,包括为SpriteRotationOffsetLookAtSpeed新添加的变量,以及一些默认的起始值,如下所示:

public class PlayerController: MonoBehaviour
{
    public Rigidbody2D Rb;
    public float MoveSpeed = 10f;
    public float SpriteRotationOffset = -90f;
    public float LookAtSpeed = 2f;
    private bool _shouldMoveForward;
    …

PlayerController.cs 代码

要查看PlayerCharacter类的完整代码,请访问以下 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch2/Unity%20Project/Assets/Scripts

本节教我们如何直接使用输入设备读取玩家输入,通过操作速度来移动Rigidbody2D对象,以及使用向量数学和四元数实现平滑旋转。

摘要

本章介绍了许多主题,包括关卡设计概念、将 2D 艺术作品添加到项目中,使用 Unity 的 2D 工具将其作为精灵准备,以及使用准备好的艺术作品使用Tilemap绘制 2D 游戏环境。我们还使用 C#语言和 Visual Studio IDE 编写脚本,为 GameObject 添加功能,用于读取输入和用物理移动玩家角色。

在下一章中,我们将添加一个虚拟相机系统来跟随瓢虫角色在关卡中移动,添加基本 UI,并处理游戏胜负的条件。

第三章:完成收集游戏

第二章 中,你被介绍了关卡设计、添加不同的 2D 资产、使用 Tilemap 创建游戏环境,以及使用 C# 语言和 Visual StudioVS)IDE 创建脚本以添加玩家角色的移动。

当玩家在环境中移动时,我们希望有一种方法可以视觉上跟随他们在关卡中的移动。本章将使用 Unity 的相机系统,称为 CinemachineCM)——这是一个强大的相机控制功能,使得添加和设置流畅的相机移动变得容易。

我们将本章以介绍添加 用户界面UI)结束。你将学习如何将文本添加到屏幕上,以计时器和分数跟踪游戏进度。我们将使用 Unity 的 UI 系统,通常称为 uGUI,来完成这个任务。

在本章中,我们将涵盖以下主要主题:

  • 使用 CM 跟随玩家和进行测试

  • 游戏机制以及如何通过代码(组件)创建

  • uGUI、计时器、计数和获胜的介绍

到本章结束时,你将能够通过向 GameObject 添加功能来使用 C# 脚本编写游戏机制,并熟悉向你的游戏添加基本 UI。

技术要求

你可以在 GitHub 上下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

使用 CM 跟随玩家和进行测试

CM 功能丰富——难怪它是获得艾美奖的代码无工具套件——但我们只关注一个功能:让相机跟随我们的瓢虫在 2D 环境中移动的能力。

首先,我们需要确保我们已安装 CM 包,方法是转到 Windows | 包管理器,选择 Cinemachine,然后点击 安装 按钮。没有玩家跟随,CM 对我们帮助不大,所以让我们将上一章中设计的玩家导入到我们的关卡场景中。

创建玩家 Prefab

Unity 的 Prefab 系统允许你将配置好的 GameObject 存储为项目中的可重用资产(文件),包括所有其组件、分配的值以及任何子 GameObject。你甚至可以将 Prefab 作为另一个 Prefab 的子项;在这种情况下,我们称它们为 嵌套 Prefab

此外,可以从原始 Prefab 派生出新的 Prefab 资产作为 Prefab 变体(相同的基属性但独特的变体——当基 Prefab 被修改时,所有派生的变体也会被修改)。

Prefab 的一项功能是能够在运行时将它们的新实例生成到场景中(例如,敌对 非玩家角色NPC)的群体、投射物、拾取物品、环境重复的部分等等)。

额外阅读 | Unity 文档

预制件docs.unity3d.com/2022.3/Documentation/Manual/Prefabs.xhtml

按照以下步骤创建我们的玩家角色预制件:

  1. 打开你之前保存的包含玩家角色设计的场景。

  2. Assets/Prefabs创建一个Prefabs文件夹并将其设置为当前文件夹。

  3. 点击并拖动Assets/Prefabs文件夹。在图 3.1中,你可以看到我们刚刚创建的瓢虫玩家预制资产。

  4. 现在,回到你的游戏关卡场景,点击并拖动场景中的(0, 0, 0)(在场景中)。

  5. 你可能无法在场景视图中看到玩家角色,因为我们尚未为其精灵分配任何排序层。我们不必单独为所有精灵分配排序层,只需使用sorting中的1000,确保如果我们在该排序层中添加任何额外的精灵,玩家始终作为最顶层的精灵绘制。

图 3.1 – 瓢虫玩家预制件

图 3.1 – 瓢虫玩家预制件

小贴士 | Unity 文档

要将更改应用到场景层次结构中的预制件上——当预制件不在预制件模式(一个用于直接编辑预制件的独立环境)中打开时——使用检查器顶部审查还原应用覆盖下拉菜单。或者,进入预制件模式的最快方法是双击项目窗口中的预制件。

在预制件模式中编辑预制件 | 在隔离中编辑docs.unity3d.com/2022.3/Documentation/Manual/EditingInPrefabMode.xhtml

创建一个 2D 跟随相机

使用 CM,在 2D 环境中创建跟随相机非常简单。现在我们已经将玩家放入场景中,我们可以添加一个 CM 相机,它会跟随玩家移动,按照以下步骤进行:

  1. 层次结构窗口的创建菜单或主文件菜单创建一个新的 CM虚拟相机vcam),方法是转到GameObject | Cinemachine | 2D Camera——对于场景中添加的第一个 vcam,这也会将Cinemachine Brain组件添加到主相机(CM 通过一个或多个 vcam 控制主相机)。

  2. 选择新的 vcam,将Player对象从层次结构拖动到CinemachineVirtualCamera组件中的跟随字段(以分配其引用),如以下截图所示:

图 3.2 – CM 跟随玩家检查器分配

图 3.2 – CM 跟随玩家检查器分配

  1. 身体部分中,如果需要,展开身体左侧的箭头;你可以调整 XY 阻尼、屏幕位置、死区等。尝试这些设置并微调你的相机以符合你的偏好。

额外阅读 | Unity 文档

Cinemachinedocs.unity3d.com/Packages/com.unity.cinemachine%402.3/manual/index.xhtml

Cinemachine 功能unity.com/unity/features/editor/art-and-design/cinemachine

Cinemachine 2D 技巧与技巧blog.unity.com/technology/cinemachine-for-2d-tips-and-tricks

现在我们可以探索整个环境,知道摄像机将跟随我们移动。太棒了!

测试关卡

使我们的关卡可玩性的最后一步是添加必要的组件(即PlayerController和物理),调整组件值,并分配组件引用。

现在我们按照以下步骤来做:

  1. 通过在层次结构中选择或在项目窗口中选择预制资产,然后在检查器顶部点击打开,以预制模式打开玩家预制。

  2. 添加一个collider,并在结果中选择它——这将通过 Unity 内置的 2D 物理引擎(Box2D)与环境中的其他碰撞体提供物理交互。

  3. 使用偏移大小字段调整碰撞体以适应玩家的图形,然后使用编辑碰撞体按钮在场景视图中手动将其移动到适当的位置。

  4. 添加一个rigidbody,并在结果中选择它——这将为我们玩家的角色提供物理属性,例如它如何受到重力的影响、它的质量、阻力、位置/旋转约束以及1001的类型来细化玩家移动(调整到你的喜好)。

  5. 最重要的是,禁用0——在俯视环境中,一切已经坐在地面上,所以我们不分配任何重力(如果我们分配了,由于重力基于y轴,对象会向屏幕底部坠落;这对于 2D 侧面游戏视图来说效果很好)。

  • 通过在检查器中使用添加组件按钮或在项目窗口中拖动脚本到层次结构窗口中的玩家,或者,在玩家对象已经选择的情况下,将其拖入检查器,添加PlayerController脚本。图 3.3 – 玩家预制组件配置

图 3.3 – 玩家预制组件配置

就这样!现在我们可以按下主工具栏上的播放按钮来测试我们的关卡设计。

瓢虫会旋转以面对鼠标指针的位置,并在按下空格键时朝着面对的方向移动。在Tilemap上分配了TilemapCollider2D的地砖将阻止玩家进入这些区域。

现在是调整PlayerController上的值的好时机。在这里,你可以看到我们开始的默认值:

图 3.4 – 玩家控制器检查器字段值

图 3.4 – 玩家控制器检查器字段值

这些调整会影响游戏感觉和整体玩家体验,所以花时间微调这些值,直到它对你这个游戏设计师来说感觉“恰到好处”。祝您玩得开心!

小贴士

当你找到你喜欢的值,并希望在测试期间快速在这些值之间来回切换(而无需在某个地方写下它们),你可以使用 预设组件标题栏右侧的 滑块图标):docs.unity3d.com/2022.3/Documentation/Manual/Presets.xhtml

在本节中,你学习了如何直接从输入设备读取输入来使用自定义 C# 脚本移动玩家角色,同时使用物理,你探索了添加 CM vcam 提供的节省时间和强大的功能,并了解到游戏测试是关于调整数值以获得良好的玩家体验的游戏感觉。

奖励活动

在按住左鼠标按钮的同时移动瓢虫玩家角色。

在下一节中,我们将通过编写收集拾取的代码来实现一些玩家交互。

游戏机制及其代码创建方法(组件)

本节将探讨游戏机制的概念以及这对我们制作的 外部世界 收集游戏中的游戏玩法意味着什么。我们将在对 游戏设计文档 (GDD) 的简要扩展中精确定义我们将使用的机制,并通过添加一个使用 2D 物理交互事件的自定义组件的新 Prefab 来编写该机制的代码。

什么是游戏机制?

游戏机制可以大致定义为规定游戏玩法以及玩家应该如何与机制充分互动以提供愉快和娱乐体验的规则。一些流行的游戏玩法机制类型包括收集、移动、射击和建造东西。

一些规定游戏玩法的游戏机制规则是由游戏而不是玩家遵循的,例如,只有当玩家完成当前关卡时,游戏才会解锁新关卡。

增益 & 削弱 | 游戏平衡

将机制元素改变为玩家的优势称为 增益,而对其不利则称为 削弱,以保持游戏平衡:www.inverse.com/gaming/nerf-buff-meaning-video-games-coined-series

现在让我们来探索如何添加我们游戏的主要机制。

添加到我们的 GDD

我们将要添加到我们的 GDD 并实现的游戏机制是环境中拾取物品的收集。我们可以这样描述它:

游戏名称 外部世界
收集游戏的核心理念是什么 玩家将通过触摸它们直到收集完所有“水钻石”或倒计时计时器到期,在环境中找到并收集“水钻石”。

表 3.1 – GDD 游戏机制新增

阅读更多 | 游戏机制

威尔·赖特(模拟人生的创造者),编写游戏机制 5 个技巧www.masterclass.com/articles/will-wrights-tips-for-writing-game-mechanics#will-wrights-5-tips-for-writing-game-mechanics

现在我们已经定义了核心游戏机制,让我们把代码组合起来使其工作!

奖励活动

通过探索向我们的关卡添加基于物理的额外对象的想法,设计一个涉及玩家推动我们称之为 工具箱 的瓷砖块的游戏机制。工具箱可能会给玩家带来不便并减慢他们收集 水钻石 的速度,或者通过要求它们移动到特定位置来提供额外的游戏玩法。

收集拾取物

到目前为止,我们已经涵盖了在 Unity 中工作以及创建基于组件的脚本的大部分基础知识,所以我们在组装事物时会稍微快一点 – 而不是花太多时间解释细节。

我们核心机制的主要目标是收集物体。我们将通过利用 2D 物理引擎提供的交互事件来实现这一点。让我们开始吧!

创建可收集物品 Prefab

首先,我们需要为我们的收集对象 – 水钻石 – 创建一个精灵。您可以在 Unity 中创建您的精灵艺术(就像我们为瓢虫角色所做的那样)或导入一个(建议的大小是 64 像素的正方形)。按照以下步骤创建一个用于收集对象的新的 Prefab:

  1. 将您的 水钻石 精灵添加到场景中。

  2. Graphics 对象的精灵附加到一个空的 GameObject(容器)上。

  3. 在父 GameObject 上添加一个最适合您的 水钻石 精灵形状的原始碰撞器 – 我选择了 CapsuleCollider2D,如 图 3**.5 所示。

  4. CapsuleCollider2D 组件上启用 IsTrigger 字段,因为我们不希望钻石和玩家之间发生任何碰撞交互;我们只想接收交互事件的提示。

阅读更多 | Unity 文档

简单来说,当 IsTrigger 被启用时,物体之间不会发生物理碰撞 – 物体不会对施加的力做出反应。

Collider.isTrigger: docs.unity3d.com/2022.3/Documentation/ScriptReference/Collider-isTrigger.xhtml

  1. 让我们不要忘记确保精灵在 Sprite Renderer100 中以正确的深度绘制。

  2. 如果你使用 Sprite Creator 工具的形状在 Unity 中创建了自己的精灵,请选择 Graphics 父对象,并添加一个 Sorting Group 组件以分配排序层。

  3. 将精灵的父对象拖动到 Project 窗口中的 Assets/Prefabs 文件夹。

小贴士 | Unity 文档

当 GameObject 被制作成 Prefab 时,Hierarchy 窗口中的对象会变成蓝色,并且在其右侧有正确的光标(箭头)。点击光标将进入场景中的 Prefab 模式(如图 3**.5 所示)。

在 Prefab 模式下编辑 Prefab | 在上下文中编辑docs.unity3d.com/2022.3/Documentation/Manual/EditingInPrefabMode.xhtml

你可以使用以下截图验证你的可收集 Water Diamond Prefab 设置:

图 3.5 – 带有碰撞器的 Water Diamond Prefab

图 3.5 – 带有碰撞器的 Water Diamond Prefab

更多阅读 | Unity 文档

Collider 2D: docs.unity3d.com/2022.3/Documentation/ScriptReference/CapsuleCollider2D.xhtml

优化物理性能docs.unity3d.com/2022.3/Documentation/Manual/iphone-Optimizing-Physics.xhtml

注意,2D 碰撞器的性能顺序(从最快到最慢)是:圆形胶囊盒子复合多边形,然后是 边缘

好的,我们已经有了可收集物品精灵的 Prefab;现在,让我们编写交互代码。

创建 CollectItem 组件

Scripts 文件夹中创建一个新的脚本,并将其命名为 CollectItem。用以下模板替换生成的代码:

using UnityEngine;
public class CollectItem : MonoBehaviour
{
    void Start()
    {
        Debug.Log($"{gameObject.name}'s Start
            called", gameObject);
    }
    private void OnTriggerEnter2D(Collider2D collision)
    {
        Debug.Log("Collision message event triggered!");
        Destroy(gameObject);
    }
    private void OnDestroy()
    {
        Debug.Log("Destroyed");
    }
}

让我们逐行查看代码:

  1. Start() 中的 Debug.Log() 行只会输出一条消息到我们的 Start() 方法被调用。我们可以使用这个方法来验证我们将添加到场景中的所有可收集物品对象都在 注册 自己。当我们添加 UI 时,我们将使用这个方法来增加需要收集的总物品数量(下一节中的 0 of 10 中的 10)。

$ 字符串插值 | C#

Debug.Log(\("{gameObject.name}'s Start called");** 中的美元符号(**\))用于标识一个插值字符串。在字符串字面量(由两个双引号 "" 表示)中的 {}(开放/关闭波浪括号)内的文本是插入代码的字符串结果(通常是变量或表达式)。

参考以下 C# 语言参考以获取更多信息:docs.microsoft.com/en-us/dotnet/csharp/language-reference/tokens/interpolated

因为我们在 Debug.Log() 调用中添加了第二个参数(gameObject),当 gameObject(驼峰式命名)关键字指向当前组件正在运行的 GameObject(帕斯卡式命名)时(也由 MonoBehaviour 基类提供)。

优化提示 | Unity 文档

注意,一旦测试完成并且不再需要,出于性能考虑,您可能需要注释掉 Debug.Log() 语句。

调试: docs.unity3d.com/2022.3/Documentation/Manual/class-Debug.xhtml

  1. OnTriggerEnter2D(Collider2D collision) 是魔法发生的地方。当两个物理对象发生碰撞,其中一个对象启用了碰撞器 IsTrigger 值时,会调用此 Unity 消息。我们通过碰撞参数值接收另一个对象的碰撞器引用(我们将在下一节中使用 collision 来检测另一个对象是否是玩家)。

  2. OnDestroy() 是另一个 Unity 消息,当场景中的 GameObject 被销毁时会被调用 - 如您在之前的 OnTriggerEnter2D() 方法中看到的,当玩家与可收集物品发生碰撞时,它将在 OnTriggerEnter2D() 中处理物品的收集,然后通过调用 Destroy(gameObject) 从场景中移除物品(因此物品不能再次被收集)。

  3. CollectItem 脚本保存并添加到您的 Collider 组件中,这些组件必须位于与碰撞器相同的 GameObject 上 - 您可以在 CollectItem 脚本中看到它就在 CapsuleCollider2D 组件下方。

  4. 查看以下屏幕截图,显示在 Console 中进行游戏测试并运行瓢虫角色进入 钻石精灵时的 Debug.Log() 输出:

图 3.6 – 测试可收集物品

图 3.6 – 测试可收集物品

重要提示

在之前的屏幕截图中,还有两件额外的事情值得提及。

编辑器 被染成橙色,因为我已经分配了一个颜色来显示在进入 Play Mode 时 - 在 Edit | Preferences | Colors 然后 General | Playmode tint 中设置颜色。这种着色 应该 提醒您正在 Play Mode 中,并且您对 Component 值所做的大多数更改在停止时将不会保存(然而,基于文件的资产中的更改将被保存)。

我已经添加了一些空的 GameObject 来组织 场景层次结构 中的对象到逻辑分组中。对于仅用于组织目的添加的对象(即,游戏中没有使用子资产),您可以分配一个 EditorOnly 标签,这样它就不会包含在最终的游戏构建中(在这个过程中节省一些资源)。

您可以继续复制 水钻 预制件,通过在 层次结构 中选择一个并多次按 Ctrl/Cmd + D 来实现,并在关卡中重新定位它。进行测试以了解您在关卡中移动以到达每个物品的能力!

小贴士

您可以在场景中的空根 GameObject 中组织所有的 水钻 可收集物品 – 只需确保在拖入物品之前,根对象位于(000)。

您可以通过点击检查器中 GameObject 的 Transform 组件旁边的垂直省略号按钮(位于标题栏中的 帮助预设 按钮右侧)并选择 重置 来快速重置 GameObject 的 Transform 组件。

这样,我们的游戏正在逐渐形成一个有趣的游戏!但关于增加挑战呢?我们将使用危险来减缓玩家在接触它们时的速度…小心!

触发危险

我们之前讨论了游戏中的危险和关卡设计,现在让我们来看看在章节的最后部分实现它们之前如何实施它们。

首先,让我们通过将其添加到我们的 GDD 文档中来定义我们的 nerf 机制:

游戏名称 外部世界
在收集游戏中,玩家遇到的 nerf 机制是什么? 当玩家触摸环境中的“有毒水坑”时,他们的速度会降低 – 增加在计时器到期前收集所有“水钻”的挑战。

表 3.2 – GDD 中添加的 nerf 机制

好的,我们需要在关卡中添加 有毒水坑!我们可以像创建 水钻 预制件并以此方式填充关卡一样做这些,但让我们采取不同的方法,并重新审视我们的贴图。

通过以下步骤在关卡中的挑战性位置添加有毒水坑:

  1. 创建一个新的 Tilemap - Hazards - Trigger

  2. 打开 贴图调色板窗口 | 2D | 贴图调色板)并确保活动贴图是我们之前步骤中创建的贴图。

  3. 从可用的 调色板 下拉菜单中选择 环境对象

  4. 从可用的贴图中选择 黑色斑点/水坑 精灵,并使用 画笔 工具(快捷键 B)将这些贴图散布在您关卡中的战略位置(我的建议是在水钻附近放置它们!)。

  5. 由于我们希望有毒水坑可交互(通过物理引擎),请向 层次结构 中的 Tilemap GameObject 添加一个 TilemapCollider2D 组件。

  6. 与之前碰撞对象的 TileMapCollider2D.IsTrigger 值进行比较,以便我们可以使用 OnTriggerEnter2D() 事件来响应碰撞。

这是使用新的危险区域贴图、在关卡中绘制的有毒水坑(在贴图调色板中选择)和 TileMapCollider2DIs Trigger 复选框勾选的设置看起来像:

图 3.7 – 危险区域贴图触发碰撞器设置

图 3.7 – 危险区域贴图触发碰撞器设置

图 3.7 中,你可以看到我已经为玩家与危险 tilemap 的瓦片交互添加了一个 TouchHazard 组件。

让我们来看看 TouchHazard 脚本:

using UnityEngine;
public class TouchHazard : MonoBehaviour
{
    [Header("Make sure the 'Player' tag is also
        assigned!")]
    public PlayerController Player;
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag(Player.tag))
        {
            Debug.Log("You touched a toxic puddle!");
            Player.SlowPlayerSpeed();
        }
    }
}

现在,让我们一点一点地分解它:

  1. 你在这里首先会注意到的新行是我们添加了一些 标题信息[Header] 是一个可以用来装饰公共字段的属性,以便在 检查器中向开发者或设计师显示一些信息,与字段一起可见。你可以在 图 3.7 中的 检查器中看到这条信息。

附加阅读 | Unity 文档

属性: docs.unity3d.com/2022.3/Documentation/Manual/Attributes.xhtml

  1. 我们在这里声明的第一个公共变量 – Player – 是用于 PlayerController 引用的。我们不是在这里使用 GameObject 引用 – 需要额外的步骤来访问添加到 GameObject 上的组件 – 我们使用组件的对象类型来直接访问其公共成员。

  2. 我们在这里再次可以看到,我们正在使用当 tilemap 上的 2D 碰撞器报告碰撞时调用的 OnTriggerEnter2D 消息。

  3. 现在我们有一个碰撞发生,我们将使用一个 if 语句进行快速测试,以查看 与危险瓦片发生了碰撞,通过使用 collision.CompareTag() 方法。

附加阅读 | Unity 文档

CompareTag: docs.unity3d.com/2022.3/Documentation/ScriptReference/GameObject.CompareTag.xhtml

我们通过传递玩家的标签分配来指定我们想要检测的标签,使用 Player.tag。所以,让我们确保现在 Player 预制件已经分配了 Player 标签!

  1. 层次结构中选择 Player 对象,转到 检查器的顶部,使用 标签下拉菜单 (A) 从列表中选择 Player 标签,然后通过使用 覆盖下拉菜单并点击 应用所有 (B) 将更改应用到 Player 预制件,如图所示:

图 3.8 – 在检查器中设置玩家标签并应用覆盖

图 3.8 – 在检查器中设置玩家标签并应用覆盖

附加阅读 | Unity 文档

标签: docs.unity3d.com/2022.3/Documentation/Manual/Tags.xhtml

标签和层: docs.unity3d.com/2022.3/Documentation/Manual/class-TagManager.xhtml

  1. 如果我们的 if 语句的结果返回 true(标签匹配),那么 Debug.Log() 语句将在 Player 对象引用的 SlowPlayerSpeed() 中通知我们。访问对象的公共成员是通过 Player.SlowPlayerSpeed() 来执行的。

  2. 让我们看看添加SlowPlayerSpeed()公共方法到我们的PlayerController类所需的添加内容

public class PlayerController : MonoBehaviour
{
    …
    [Header("Hit Hazard Speed")]
    public float SlowedSpeed = 2f;
    public float SlowedTime = 5f;   // Seconds
    private float _speedStartValue;
    private void Start()
    {
        _speedStartValue = MoveSpeed;
    }
    …
    public void SlowPlayerSpeed()
    {
        MoveSpeed = SlowedSpeed;
        Invoke(nameof(RestoreSpeed), SlowedTime);
    }
    private void RestoreSpeed()
    {
        MoveSpeed = _speedStartValue;
    }
}
  1. 我们可以看到需要一些新的变量来支持短时间内减慢玩家角色的速度。在SlowedSpeed中可分配的公共变量:一个float变量,用于指定玩家新的较慢移动速度,默认值为2f

  2. SlowedTime:一个float变量,用于指定保持SlowedSpeed值的时间(以秒为单位),默认值为5f(5 秒)。

  • Start()方法用于缓存(保留)玩家的原始MoveSpeed值。* 我们还添加了SlowPlayerSpeed()方法——从TouchHazard类调用——将玩家的当前MoveSpeed设置为SlowedSpeed值以减慢移动速度。* 在将MoveSpeed赋值之后,使用Invoke()方法(MonoBehaviour)和延迟时间值(第二个参数)来等待,直到SlowedTime变量指定的时刻才调用新的RestoreSpeed()方法。* 我们在这里还使用了 C#的nameof()表达式与RestoreSpeed()方法,因此我们不需要使用字符串字面量作为方法名。

额外阅读 | Unity 文档

Invoke: docs.unity3d.com/ScriptReference/MonoBehaviour.Invoke.xhtml

  1. 最后,我们添加了RestoreSpeed()方法,该方法简单地将MoveSpeed变量重置为其原始值——将玩家移动速度重置为正常。

通过这样,我们现在可以通过参考图 3.7 来启用危险交互。

  1. TouchHazard组件添加到Tilemap - Hazards - Trigger对象。

  2. Hierarchy中的Player对象拖放到TouchHazard组件中的Player字段。

  3. Player上的PlayerController组件中验证SlowedSpeedSlowedTime值。

现在,您可能想要测试您的关卡并调整危险效果的速率和持续时间,直到它符合您的需求(我选择了25,作为我的初始值);将危险和钻石移动到关卡中,使其更具挑战性或更有趣。再次强调,只需享受这个过程即可!

在本节中,我们了解了什么是游戏机制以及如何在 GDD 中定义它,并学习了如何在我们这个级别实现收集机制和影响游戏玩法的影响。接下来,我们将通过添加 UI、倒计时计时器和胜利条件来为收集游戏添加最后的修饰。

uGUI 简介,计时器,计数和胜利

玩家需要了解游戏中的情况,而我们通过 UI 向玩家传达这种关键信息。Unity 提供了易于使用的工具来创建 UI,通常被称为uGUI(或Unity 图形用户界面)——发音为 You-gooey。正式名称为Unity UI(在文档中搜索 ugui 返回 结果找到)。

Unity UI 是一个基于 GameObject 的 UI 系统,因此您已经熟悉如何添加 UI 组件以以不同的方式显示和交互 UI 控件。

额外阅读 | Unity 文档

Unity UI: docs.unity3d.com/Packages/com.unity.ugui%401.0/manual/index.xhtml

UI 如何 docs.unity3d.com/Packages/com.unity.ugui%401.0/manual/UIHowTos.xhtml?q=unity%20ui

玩家在收集游戏中需要看到的三项关键信息如下:

  • 收集的项目数量与需要收集的总数量对比

  • 收集所有项目剩余的时间

  • 游戏结束时获胜(赢/输)的状态

我们首先讨论添加的 UI 对象是 Canvas,因为它负责绘制我们的 UI。

Canvas

Canvas 是一个定义的区域,其中包含所有的 UI 小部件——可以将其想象为一张纸,所有要绘制的内容都将包含在页面上。如前所述,Unity UI 是基于 GameObject 的,因此 Canvas 是一个具有 Canvas 组件的 GameObject。场景中添加的所有 UI 小部件都必须是 Canvas 的子项。

将 UI Canvas 添加到场景中就像在创建菜单中选择UI | Canvas一样简单。第一次将 Canvas 添加到场景时,它还会添加一个EventSystem。我们需要选择EventSystem并将其更新为使用新的输入系统。点击替换为 InputSystemUIInputModule,如图 3.9 所示:

图 3.9 – 更新 EventSystem 以使用新的输入系统

图 3.9 – 更新 EventSystem 以使用新的输入系统

如下图中所示,使用0.5

图 3.10 – Canvas 缩放器

图 3.10 – Canvas 缩放器

Canvas 已经设置好了,现在让我们添加第一个 UI 小部件来显示收集的项目当前数量!

TextMesh Pro

TextMesh ProTMP)是一套用于在项目中创建高度可定制和高质量的 2D 和 3D UI 文本的工具。我们将使用 TMP 文本小部件来显示所有游戏信息,包括收集的项目、倒计时计时器和胜负信息。

额外阅读 | Unity 文档

TextMeshPro: docs.unity3d.com/2022.3/Documentation/Manual/com.unity.textmeshpro.xhtml

TextMesh Pro 文档docs.unity3d.com/Packages/com.unity.textmeshpro%404.0/manual/index.xhtml

层次结构中选择 Canvas 对象后,使用创建菜单将UI | Text – TextMeshPro文本小部件添加到场景中。如果你是第一次将TextMeshPro小部件添加到项目中,系统会提示你安装必需的资源。点击导入 TMP Essentials

图 3.11 – TMP 导入对话框

图 3.11 – TMP 导入对话框

使用以下步骤在屏幕左下角显示收集物品文本:

  1. 将新添加的 TMP 文本对象重命名为Collected Count Text

  2. 要将文本锚定到屏幕的左下角,使用锚点预设 (A),同时按住Shift也设置轴点)和Alt也设置位置)键(B),然后点击左下角的锚点(C):

图 3.12 – TextMeshPro 矩形锚点设置

图 3.12 – TextMeshPro 矩形锚点设置

  1. 使用以下设置来提供默认文本以显示并设置文本样式(参考图 3**.12作为参考):

    1. 0 of 0

    2. 字体样式粗体

    3. 字体大小48

    4. 额外设置 | 边距 | 50

到此为止,我们已准备好在每次收集物品时更新文本。让我们在下一节中看看如何将所有这些结合起来。

更新拾取计数

我们现在要解决的问题是如何在收集物品时更新 UI 文本?我们可以参考设计模式来找到解决方案!我们之前已经介绍了单例和观察者模式,任何一个都适合我们的需求。

为了提供一个实际应用的例子,我们将使用两个,从单例开始。

拾取计数将显示为GameManager,将负责保存收集品的(变量)数据,因此这将成为一个单例实例。

当游戏首次运行时,场景中每个可收集物品的Start()方法将被调用,因此我们将使用此方法将每个可收集物品添加到总物品计数中。

让我们首先创建GameManager脚本,并通过在变量声明部分(靠近类顶部)添加以下行将其设置为单例实例:

    // Singleton instance.
    public static GameManager Instance
        { get; private set; }
    private void Awake() => Instance = this;

GameManager类本身被声明为public static属性 – 我们希望使用Instance变量直接从另一个类型访问该类。我们让get保持公开,而将set声明为私有,这样外部类就只能读取。

Awake()方法中,我们将Instance变量设置为this,即当前类的实例。在这里我们使用表达式体(由=>表示)而不是通常的花括号(块体)来使代码更简洁,因为它只需要一个表达式。

属性(C#)

C#属性是一个使用显式访问器getset方法来读取或写入数据值的类成员。

现在,我们可以添加存储可收集物品总数的变量、公共方法来增加总数,以及通过以下步骤从CollectItem类调用增加方法:

  1. 在变量声明部分添加一个私有_totalCollectibleItems整型变量。它的访问器是私有的,因为它只封装在声明类内部以更改其值(在面向对象封装中,认为在类外部修改变量值是不良做法):

        private int _totalCollectibleItems;
    
  2. 在变量声明部分添加一个公共的AddCollectibleItem()方法,没有返回类型(void),作为一个表达式体,以增加_totalCollectibleItems当前值。++一元算术运算符将操作数增加 1。public访问器意味着我们可以在声明类外部调用此方法,我们将在下一部分从CollectItem类通过单例实例来这样做:

        public void AddCollectibleItem() =>
            _totalCollectibleItems++;
    
  3. 现在,在CollectItem类中,在Start()方法的底部添加以下内容,以调用GameManager单例实例并将可收集物品添加到总物品计数中:

        void Start()
        {
            …
            GameManager.Instance.AddCollectibleItem();
        }
    

这样,我们现在就知道每个场景中对象实例的Start()方法将被调用多少次了。简单易懂!

可收集物品的 UI 显示的第二部分需要显示当前收集的物品数量。我们现在将使用我们在实践中覆盖的第二个设计模式:观察者来实现这一点。

增加收集项目数量

我们已经看到了如何通过静态变量访问GameManager单例实例。我们将对CollectItem类做类似处理,以便知道何时有项目被收集。

通常情况下,场景中每个对象的实例都是相互独立的——每个实例都有自己的独立成员。我们希望GameManager能够注册并监听(即观察)任何被收集的项目,因此我们将通过调用一个静态事件来实现这一点——静态声明将使其在所有类实例中通用,所以我们不需要引用任何单个项目实例。

让我们继续在CollectItem类中添加我们的静态事件,然后从GameManager注册监听器,以便在事件被调用时采取行动:

  1. CollectItem中添加以下公共静态变量声明:

        public static event UnityAction OnItemCollected;
    

event关键字添加到声明中,以强制仅在声明类内部调用——我们不想让外部类能够触发事件!

我们在这里使用UnityAction作为方法委托,因此我们可以通过它来引用调用多个方法,甚至将其作为方法参数传递。在命名事件委托和方法时,通常在单词On之前加前缀(例如OnItemCollected)也是常见做法。

额外阅读 | Unity 文档

UnityAction: docs.unity3d.com/2022.3/Documentation/ScriptReference/Events.UnityAction.xhtml

  1. OnTriggerEnter2D() 方法的顶部添加以下行以调用或 触发 事件(也称为 事件调用),当物品被收集时:

            OnItemCollected?.Invoke();
    

添加到 OnItemCollected 的问号和点 (?.) 是一个空条件运算符 – 它只有在操作数不评估为 null 时才会执行操作。在这种情况下,这意味着如果没有注册监听器,我们不想在调用 Invoke(执行事件)时抛出 NullReferenceException 错误。

NullReferenceException (C#)

NullReferenceExceptionNRE)是在尝试访问一个 null 对象(未分配)的成员时可能发生的错误。

  1. 现在进入 GameManager 类,我们添加一个私有变量来跟踪玩家收集的物品数量:

        private int _collectedItemCount;
    
  2. 该类封装了这个变量 – 因为它的访问器被设置为 private – 因此,让我们添加一个 ItemCollected() 方法来递增计数:

        private void ItemCollected() =>
            _collectedItemCount++;
    

我们只是通过在变量后使用 ++ 算术运算符来递增计数,每次收集到物品时增加 1。

  1. 那么,现在 ItemCollected() 方法是如何被调用的呢?这正是注册 CollectItem.OnItemCollected 事件的地方。我们将使用 OnEnable()OnDisable() Unity 消息事件 – 这些事件分别在行为首次启用并激活时调用,然后变为禁用:

        private void OnEnable()
        {
            CollectItem.OnItemCollected += ItemCollected;
        }
        private void OnDisable()
        {
            CollectItem.OnItemCollected -= ItemCollected;
        }
    

+= 运算符用于在订阅事件时指定添加事件处理方法。我们将注册 ItemCollected 方法,以便在调用 CollectItem.ItemCollected 事件时被调用(即注册监听器)。

重要提示

为了避免资源泄露,应该始终执行取消订阅事件的操作!

-= 运算符用于指定移除事件处理方法以停止订阅事件。我们正在注销 ItemCollected 方法,以便在调用 CollectItem.ItemCollected 事件时它将不再被调用。假设我们没有注销事件处理程序,并且包含处理方法的对象被销毁,那么它将抛出 null) – 我们肯定不会对此感到高兴!

小贴士

不要忘记,如果处理方法尚未创建,您可以通过单击 ItemCollected 文本,然后使用 Alt/Cmd + Enter 并选择 Generate method ItemCollected 来让 VS 为您生成方法。

现在我们已经计算出了总物品和收集物品的值,让我们更新 UI 显示。

更新 UI

显示收集物品进度的文本组件已经作为 Collected Count Text 添加到场景中。现在,我们需要一种方法来引用和更新每次玩家收集到 Water Diamond 时显示的文本。

我们将创建一个新的 UIManager 类,以将 UI 功能与 GameManager 分离 – 遵循单一责任原则。

创建一个新的脚本名为 UIManager 并添加以下代码:

using UnityEngine;
using TMPro;
public class UIManager : MonoBehaviour
{
    public TextMeshProUGUI CollectedItemText;
    void Start()
    {
        CollectedItemText.text = string.Empty;
    }
    public void UpdateCollectedItemsText(
        int currentItemCount, int totalCollectableItems) =>
            CollectedItemText.text =
                $"{currentItemCount} of
                    {totalCollectableItems}";
}

在编写新的 UIManager 脚本时,我们将这样做。

  1. 我们首先要做的是添加一个 using 语句用于 TextMeshPro (TMPro),这样我们就可以引用 TMP 文本组件的属性。

  2. 然后,我们将添加一个公共变量用于 收集计数文本 TMP 对象,在 检查器 中进行分配(用于引用)。

  3. 当游戏开始播放时,我们使用 string.Empty(一个零长度的字符串,相当于 "")重置 CollectedCountText 文本,以不显示任何内容 – 这将清除我们在设计时输入的任何临时显示文本,以调整位置和显示值。

  4. 最后,添加了一个名为 UpdateCollectedItemsText() 的方法,用于设置 CollectedCountText 文本。它有两个方法参数,一个用于当前项目计数,另一个用于场景中的总可收集物品。我们再次使用字符串插值将变量的值与 of 结合起来进行显示。

让我们回到 Unity 编辑器,通过将 UIManager 添加到 CollectedCountText 字段来完成 UIManager,如图中 检查器 所示:

图 3.13 – 管理器检查器分配

图 3.13 – 管理器检查器分配

在收集对象时,我们最后要做的就是在 GameManager.ItemCollected() 中连接 UI.UpdateCollectedItemsText() 调用:

  1. GameManger 中添加一个公共变量来保存对 UIManager 组件的引用:

        public UIManager UI;
    
  2. ItemCollected() 方法更改为现在调用 UI.UpdateCollectedItemsText() 方法,传递增加后的 _collectedItemCount_totalCollectibleItems 值:

        private void ItemCollected() =>
            UI.UpdateCollectedItemsText(
                ++_collectedItemCount,
                    _totalCollectibleItems);
    

_collectedItemCount 前面的 ++ 操作符意味着我们将预增其操作数(前缀),然后读取其值。我们希望传递给方法的已经是增加后的值。

-- (C#)

注意,C# 中也提供了 -- 一元算术操作符,它不是将操作数增加 1,而是将其减 1。相同的规则适用于前缀或后缀递减赋值。

这样,唯一剩下的任务是将 UIManager 拖到 GameManager 组件中,以分配其引用(参考 图 3**.13)。保存您的文件并测试游戏!

现在,游戏正在跟踪收集的水钻数量并向玩家显示进度。这还不完全是一个游戏,所以我们将添加一个简单的倒计时计时器,我们将在下一节将其与胜负条件关联起来。

计时器脚本

参考我们的 GDD 中的表 3.1,我们知道如果玩家在收集所有水钻之前,倒计时计时器达到 0,游戏将失败。为此,我们需要一个简单的计时器脚本,每秒从时间中减去 1 秒。让我们看看我们如何实现这一点,并通知监听器时间变化和过期事件。

创建一个名为Timer的新脚本,并参考以下代码:

using UnityEngine;
using UnityEngine.Events;
public class Timer : MonoBehaviour
{
    public event UnityAction<int> OnTimeUpdate;
    public event UnityAction OnTimeExpired;
    private int _timeSeconds, _timeCurrent;
    public void StartTimer(int time)
    {
        _timeSeconds = time;
        _timeCurrent = 0;
        InvokeRepeating(nameof(UpdateTimer), 1f, 1f);
    }
    public void StopTimer() => CancelInvoke();
    private void UpdateTimer()
    {
        _timeCurrent++;
        OnTimeUpdate?.Invoke(_timeCurrent);
        if (_timeCurrent >= _timeSeconds)
        {
            StopTimer();
            OnTimeExpired?.Invoke();
        }
    }
}

让我们看看代码并回顾一下新内容:

  1. OnTimeUpdate事件增加了一个参数:UnityAction<int>。这允许在事件被调用时向监听器传递一个整数值(你可以通过UnityAction添加最多四种类型的参数)。我们使用传入的int值(秒数)来显示 UI 中的剩余时间。

  2. 我们在一行中声明了两个int类型的变量_timeSeconds_timeCurrent,通过逗号分隔变量名称。

  3. StartTimer()是一个公共方法,GameManager 将调用它来开始计时器的倒计时并开始游戏。

  4. InvokeRepeating()Invoke()类似,除了,你猜对了,有一个重复的时间指定。我们使用这个方法每秒更新计时器值。

额外阅读 | Unity 文档

InvokeRepeating: docs.unity3d.com/2022.3/Documentation/ScriptReference/MonoBehaviour.InvokeRepeating.xhtml

InvokeRepeating()方法接受一个表示方法名称的字符串表示形式作为参数,就像Invoke()一样,并且每次你在代码中输入一个字符串字面量时,都应该触发一个标志——硬编码的字符串或魔法字符串是纯粹的邪恶!因此,我们不会直接输入一个字符串,而是使用nameof表达式来返回方法名称作为字符串。太棒了!

nameof (C#)

nameof表达式从变量、类型或成员中获取一个字符串常量,并在编译时进行评估。这可以用作字符串字面量的替代品。它确保没有拼写错误,并允许 VS CodeLens 生成引用。

  1. StopTimer()方法调用CancelInvoke(),这将停止计时器的倒计时(通过停止InvokeRepeating过程)。

  2. UpdateTimer()方法负责增加计时器并调用OnTimeUpdate事件,传递_timeCurrent值。

  3. 最后,我们使用>=比较运算符测试计时器是否已过期条件:_timeCurrent是否大于或等于_timeSeconds?我们使用>=而不是==,因为在处理时间增加可能跳过一秒的边缘情况时,我们会错过一个相等条件。此外,计时器将被停止,并将触发OnTimeExpired事件。

呼呼!脚本不长,但几个关键部分使得它能够很好地与我们的其他组件协同工作。说到这个,我们现在可以将Timer代码与GameManagerUIManager集成,以开始计时器的运行并更新 UI 中的剩余时间文本。

对 GameManager 的扩展

Timer脚本准备就绪后,让我们在GameManager类中将Timer脚本连接起来,以开始运行计时器并处理时间更新和倒计时结束的事件。

我们首先要解决的是在游戏开始时启动计时器倒计时。计时器倒计时通过在GameManager类的Start()方法中分配的直接引用调用Timer.StartTimer()公共方法来启动。

按照以下步骤查看设置过程:

  1. 添加一个公共变量GameTimer,用于引用添加到层次结构中的GameManagerGameObject 的Timer组件:

        public Timer GameTimer;
    
  2. 添加一个公共整型变量GameplayTime,用于指定游戏倒计时的时间(以秒为单位)- 这将是玩家收集所有水钻所需的总时间。确定要设置什么值的最简单方法是进行游戏测试!

        public int GameplayTime;
    
  3. 当游戏运行时,从Start()方法开始计时器。我们在StartTimer()方法上调用Invoke(),延迟 2 秒 - 给玩家一些时间在游戏开始前稍微定位自己:

        private void Start() =>
            Invoke(nameof(StartTimer), 2f);
    
  4. 添加StartTimer()方法,该方法执行对TimerStartTimer()方法的实际调用,并传入倒计时的时间:

        private void StartTimer() =>
            GameTimer.StartTimer(GameplayTime);
    
  5. 最后,回到Timer组件,在层次结构中的GameManagerGameObject 上,并在GameTimer字段中分配对其的引用(参见图 3.13)。

前面的步骤已经将Timer与我们的GameManager集成,并在游戏开始时开始计时。但我们仍然需要处理计时器的事件,所以让我们首先添加对时间更新的监听器:

  1. OnEnable()方法中,添加对OnTimeUpdate事件的监听器,并指定处理方法:

        private void OnEnable()
        {
            …
            GameTimer.OnTimeUpdate += TimeUpdated;
        }
    
  2. 记住,当我们订阅一个事件时,我们也应该取消订阅它以防止内存泄漏,所以将以下代码添加到OnDisable()中:

        private void OnDisable()
        {
            …
            GameTimer.OnTimeUpdate -= TimeUpdated;
        }
    
  3. 创建TimeUpdated()方法(Alt/Cmd + Enter),其中我们将执行处理计时器更新为新时间的代码:

        private void TimeUpdated(int seconds) =>
            UI.UpdateTimerText(seconds, GameplayTime);
    

和之前一样,我们正在调用 UI 引用上的公共方法来更新倒计时计时器文本,同时添加从Timer和总游戏时间传递进来的秒数值。

关于代码架构的说明

我们可以在UIManager内部直接添加对Timer.OnTimeUpdate事件的监听器来更新倒计时计时器文本,但我决定在项目中只保留一个对Timer的引用,并在GameManager中处理所有计时器事件。这将使事情更简洁,并且由于与计时器相关的代码都在一个类中,因此更容易调试任何问题。

我们将在下一节中添加UpdateTimerText()方法,其中我们将更新UIManager

添加到 UIManager

让我们直接添加额外的变量和方法来支持更新计时器文本和游戏结束消息,我们将在下一节完成这些:

  1. 打开UIManager脚本,并进行以下修改:

        public TextMeshProUGUI TimerText;
        public TextMeshProUGUI GameOverText;
        void Start()
        {
            …
            TimerText.text = string.Empty;
            GameOverText.text = string.Empty;
        }
        public void UpdateTimerText(int currentSeconds,
            int totalSeconds)
        {
            var ts = System.TimeSpan.FromSeconds(
                totalSeconds – currentSeconds);
            TimerText.text = $"{ts.Minutes}:{ts.Seconds:00}";
        }
        public void SetGameOverText(string text) =>
            GameOverText.text = text;
    
  2. 添加两个TextMeshProUGUI公共字段,用于在 UI 中显示计时器和游戏结束文本。

  3. Start()中,我们将清除设计时间期间使用的任何占位文本,这样就不会显示任何文本,直到它被更新。

  4. 添加带有当前和总秒数的UpdateTimerText()方法参数,以计算倒计时剩余时间并显示。

注意,我们使用System.TimeSpan来简化事情。然后我们可以使用ts变量来显示文本,格式化以显示分钟和秒,其中秒总是以两位数字显示(00被指定)。

  1. 最后,添加SetGameOverText()方法,用于更新GameOverText文本以显示游戏结束消息。

为了完成修订,我们将回到编辑器中,在场景层次结构中的Canvas下创建两个新的Text - TextMeshPro UI 小部件(参见图 3.13):

  1. 要在用户界面中显示计时器,将其命名为Countdown Timer Text并将其锚定在屏幕顶部中央。调整文本大小,使其对玩家易于可见。

  2. 要在用户界面中显示游戏结束文本,将其命名为Game Over Text并将其锚定在屏幕中央。给文本一个大号,覆盖大部分屏幕,这样在游戏结束时玩家无论是赢还是输都不会弄错。

当你完成这些文本小部件的 UI 设计后,将它们拖到UIManager组件。

如果不能在游戏中赢或输,那么这根本不是一场游戏!所以,让我们在下一节完成收集游戏的收尾工作。

赢得游戏

我们所需的一切来确定胜负条件都已经就绪。让我们从胜利条件开始:如果玩家在倒计时计时器结束之前收集到所有的水钻石,他们就会获胜。

这是一个简单的相等条件语句:累积的项目数量是否等于可收集的项目总数?如果我们在一个项目被收集时检查这个条件,并且它等于true,我们就知道我们赢得了游戏。

GameManager中,修改ItemCollected()方法以包含此检查,如果为true,我们将调用Win()方法。

你首先必须将表达式体重构为块体,以便在方法中包含额外的代码行(提示:VS 可以为你重构并添加波浪括号)。现在它将看起来如下:

    private void ItemCollected()
    {
        UI.UpdateCollectedItemsText(
            ++_collectedItemCount, _totalCollectibleItems);
        if (_collectedItemCount == _totalCollectibleItems)
            Win();
    }

这很简单!现在,让我们声明失败条件:如果倒计时计时器在玩家收集到所有的水钻石之前耗尽,他们就会失败。这需要做一点更多的工作,但我们只是将使用我们已经实现的内容,具体来说,就是Timer.OnTimeExpired事件。

GameManager中,再次为时间耗尽添加OnEnable()监听器并分配处理方法:

    private void OnEnable()
    {
        …
        GameTimer.OnTimeExpired += TimeExpired;
    }

再次,取消订阅:

    private void OnDisable()
    {
        …
        GameTimer.OnTimeExpired -= TimeExpired;
    }

创建TimeExpired()方法作为表达式体,我们将只调用Lose()——时间已耗尽;如果玩家已经收集了所有物品并获胜——则此方法永远不会被调用:

    private void TimeExpired() => Lose();

最后,作为完成收集游戏的最后几个步骤,参考以下GameManager脚本的添加,我们将添加两个公共字段WinTextLoseText,它们包含游戏结束时显示的消息(如图3**.13所示):

    public string WinText = "You win!";
    public string LoseText = "You lose!";

如果你没有为这里的变量声明分配一些默认的文本值,那么别忘了在检查器中分配它们!

我们还将添加Win()Lose()方法:

    private void Win()
    {
        GameTimer.StopTimer();
        UI.SetGameOverText(WinText);
        Time.timeScale = 0f;
    }
    private void Lose()
    {
        UI.SetGameOverText(LoseText);
        Time.timeScale = 0f;
    }

这两个新方法的简要说明如下:

  1. Win()方法首先通过调用GameTimer.StopTimer()停止计时器。这将取消调用更新计时器方法,因为我们的游戏现在已经结束了。

  2. 这两个方法都调用UI.SetGameOverText(),但带有相应的参数来显示正确的消息,分别是WinTextLoseText

  3. 它们各自将运行中的游戏TimetimeScale变量设置为0,这会暂停游戏(有一些例外;有关Time.timeScale的更多信息,请参阅下面的提示块)。

注意,将timeScale设置为零在技术上会产生与在这里调用GameTimer.StopTimer()相同的结果,因为此时唯一的选择是重新开始游戏。不过,我喜欢在代码中明确一些语句,以便意图清晰可见(当然,只要它不会对其他方面产生负面影响,哈哈)。

额外阅读 | Unity 文档

Time.timeScale: docs.unity3d.com/2022.3/Documentation/ScriptReference/Time-timeScale.xhtml

由于在本节中将代码拆分成了很多小块,你可能需要参考完整的GameManager脚本,它可以在 GitHub 仓库中找到:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch3

这样,我们的游戏就完成了。太好了!然而,在我们可以与世界分享我们的游戏之前,还需要一个关键的步骤……那就是确保其他人觉得游戏具有挑战性和乐趣。

游戏测试

现在是进行游戏测试和更改关卡设计参数的时候了,改变玩家可以走的路径,移动物体和危险,移动可收集物品,并调整游戏时间,使游戏对玩家具有挑战性和乐趣。

让其他人玩你的游戏——如果可能的话,观察他们玩游戏,以了解他们如何玩游戏并获取他们的反馈。根据游戏测试的结果进行改进,并对游戏进行迭代!

下载完整的游戏代码

记住,本书的示例代码可以从以下 GitHub 仓库下载:github.com/PacktPublishing/Unity-2022-by-Example

摘要

本章介绍了 CM 相机系统的强大功能,用于快速添加玩家跟随相机,实现收集物品的游戏机制以吸引玩家,使用 Unity UI 向玩家展示关键游戏进度元素,以及如何通过胜负条件完成和平衡我们的收集游戏。

在下一章中,我们将通过定义《Outer World》2D 冒险游戏的 GDD(游戏设计文档)来介绍本书的下一个项目,学习如何导入原始艺术作品以配合额外的 Unity 关卡构建工具使用,并探索关卡设计和游戏打磨的更多方面。

第三部分:2D 游戏设计继续

在这部分,你将了解 2D 游戏的不同视角——侧视图——对于冒险游戏,玩家在尝试找到“拼图钥匙”碎片以获得进入栖息地站点的权限的同时,被挑战在交互式环境中导航。你将获得设置具有不同动画状态(由新的 Input System 驱动)的玩家角色的知识。你还将学习定义与环境和关卡设计相关的游戏机制,以及编写玩家交互的代码。

射击,作为 GDD 中指定的玩家动词,通过新的 Unity Pool API 实现,该 API 提供了一种高效且原生的代码方法来减少垃圾回收GC)。引入使用 ScriptableObjects 的数据驱动和可扩展的游戏架构方法,将为你提供创建灵活和可定制游戏系统的知识。

这部分内容以编码为主,将指导你实现游戏核心循环的系统,包括生成系统、任务系统和事件系统。事件系统将松散地连接代码,遵循良好的编程实践(SOLID 原则)。你将完成一个任务任务并触发最终事件以产生胜利条件。

这部分包括以下章节:

  • 第四章, 创建 2D 冒险游戏

  • 第五章, 继续冒险游戏

  • 第六章, Unity 2022 中对象池的介绍

  • 第七章, 润色玩家的动作和敌人行为

  • 第八章, 扩展冒险游戏

  • 第九章, 完成冒险游戏

第四章:创建一个 2D 冒险游戏

第三章中,您被介绍了 Cinemachine,用于创建快速但强大的相机跟随系统。我们学习了如何使用 Unity UI 实现收集物品和显示游戏进度的游戏机制,以及如何在考虑胜负条件的同时处理游戏平衡。

通过完成收集游戏,前几章提供了基础知识,我们将在此基础上继续构建,本章我们将开始制作一个 2D 冒险游戏。我们将探索导入艺术作品并使用额外的 Unity 2D 工具,即Sprite Shape,来构建 2D 侧面环境以及级别设计。

我们将通过添加动态移动平台和触发器来结束本章,这些触发器将为玩家提供二级动作,以创造更具吸引力的游戏体验,并采用一些优化技术来保持游戏性能和品质,以增加沉浸感。

本章我们将涵盖以下主要主题。

  • 扩展游戏设计文档GDD) – 介绍 2D 冒险游戏

  • 将资源导入以用于 Sprite Shape – 一种不同类型的 2D 环境构建器

  • 级别和环境设计 – 引导玩家

  • 移动平台和触发器 – 创建一个动态可交互的环境

  • 为我们的环境添加细节以增强沉浸感和优化

到本章结束时,您将拥有另一个可用于您项目的 GDD 示例,并能够导入和使用原始艺术作品来创建一个使用 Sprite Shape 设计的引导玩家环境。您还将能够创建一个交互性和动态的移动环境,该环境经过优化且视觉效果精美。

技术要求

要跟随本章中为书中项目创建的相同艺术作品,请从本节提供的 GitHub 链接下载资源。要使用您自己的艺术作品跟随,您需要使用 Adobe Photoshop 或能够导出分层 Photoshop PSD/PSB 文件的图形程序(例如,Gimp、MediBang Paint 或 Krita)创建类似的艺术作品。

此外,为了跟随玩家输入部分,您可能需要一个与您的系统兼容的游戏控制器(尽管这是可选的,因为也会提供键盘输入)。

您可以在 GitHub 上下载完整的项目github.com/PacktPublishing/Unity-2022-by-Example

扩展 GDD – 介绍 2D 冒险游戏

我们现在继续下一个游戏概念,同时继续书中项目的整体主题,因此让我们继续更新 GDD 并在需要的地方扩展它。

让我们先更新以下之前覆盖的概述部分;这还将作为您对游戏的介绍:

游戏名称 外部世界
描述游戏玩法、核心循环和进度。 在寻找进入入口所需的关键部件的同时,找到通往栖息地站的道路,并中和试图阻止你任务的感染机器人。
冒险游戏的核心游戏机制是什么? 玩家将反复与阻碍前往栖息地站的感染机器人战斗。
冒险游戏的次要游戏机制是什么? 玩家将在环境中寻找隐藏的关键部件。这些部件需要正确组合作为输入,才能获得进入栖息地站入口的权限。
需要实现哪些系统来支持游戏机制? 玩家移动、装备带有弹药装填和射击能力的武器、带有库存的拾取、谜题解决者、生命值和伤害。

表 4.1 – 冒险游戏的 GDD

我们在前面表格的末尾添加了一个新章节,将游戏机制细节进一步扩展到 开发需求 领域。在定义了一些系统之后,我们可以开始考虑代码架构以及需要解决的一些 问题 – 这是我们应用一些设计模式的地方。

现在,我们不再添加削弱效果,而是这次为玩家添加一个增益效果:

在冒险游戏中,增益机制对玩家意味着什么? 玩家将能够收集散布在环境中的能量碎片(水钻石),当收集到一定数量时,将为所有武器提供升级状态(增加造成的伤害)。

表 4.2 – 为 GDD 添加增益效果

我们的游戏玩家角色和敌人将比收集游戏更加完善。让我们在 GDD 中为这些角色的背景和玩家挑战结构添加章节。

主要角色:****描述游戏中的主要角色以及他们如何推动故事。这位玩家角色是谁?** 类型:Kryk’zylx 人形种族。背景:Kryk’zylx 的人民已经超越了他们的家园星球,正在寻找适合殖民的星球。侦察兵被派往星球表面建立可能维持生命的栖息地站。目标:建立并维护一个栖息地站,配备自动化的建筑和维护机器人。技能:动力服跳跃和充电。弱点:大气不可呼吸。

Kryk’zylx 侦察兵必须在栖息地站外的星球敌对大气中穿着动力服才能生存。以下是一个动力服头盔的示例:

图 4.1 – 动力服头盔

图 4.1 – 动力服头盔

现在,我们将继续添加关于玩家挑战结构的详细章节:

主角的挑战结构是什么? 在平台上导航,避开感染的机器人,并解决关键谜题。
敌人 A: 描述游戏中的第一个敌人以及他们如何推动故事发展。这个敌人是谁? 类型:建筑机器人,两足背景:部署在为栖息地站建设和维护的预殖民任务中的机器人。目标:建设和维护。技能:高机动性,包括在崎岖地形上。弱点:充电时间长。
敌人 B: 描述游戏中的第二个敌人以及他们如何推动故事发展。这个敌人是谁? 类型:维护机器人,轮式背景:部署在为栖息地维护和支持的预殖民任务中的机器人。目标:维护和人员支持。技能:快速充电。弱点:机动性有限。

表 4.3 – 添加角色和敌人生物

我们将进一步扩展文档,并引入一个关注环境的章节。这里的描述将有助于在设计和创建环境及关卡艺术资源时保持游戏的视觉方向:

描述游戏发生的环境。它看起来如何,谁居住在那里,以及有哪些 兴趣点? 游戏发生在一个有殖民潜力的行星表面,尽管这个特定的行星没有可呼吸的大气。这个行星由紫色-红色岩石和茂密的植被组成(其移动方式暗示它可能具有思考的能力)。
描述游戏关卡。 游戏关卡是由静态和动态平台以及玩家在前往栖息地站的过程中需要克服或避免的障碍物组成的组合。

表 4.4 – 环境和关卡

让我们添加一个关于输入控制的章节。之前,我们使用键盘和鼠标进行收集游戏,但这次,我们还将添加对游戏手柄输入的支持:

定义输入/控制方法动作。 键盘WASD 键用于移动;空格键用于跳跃;鼠标用于瞄准,左鼠标按钮用于射击主要武器,右鼠标按钮用于在瞄准时发射/释放次要武器(左鼠标按钮用于取消);E 键用于与物品交互。游戏手柄:左摇杆/D 按钮用于移动;X 键用于跳跃;右摇杆用于瞄准,右扳机或 Y 键用于射击;右肩部用于在瞄准时发射/释放次要武器(右扳机用于取消);按钮 A 用于交互。

表 4.5 – 输入/控制方法动作

最后,让我们定义所有部件如何相互作用,为玩家提供一个完整的游戏体验:

所有这些部件是如何相互作用的? 玩家通过与环境的互动来探索,发现使用整个关卡中找到的谜题钥匙的各个部分来达到并进入栖息地站,同时抵御被覆盖整个星球表面的奇异植物感染的机器人。

表 4.6 – 将所有部件组合在一起

2D 冒险游戏的完整 GDD

要查看 2D 冒险游戏的完整 GDD 文档,请访问以下项目 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch4/GDD

在你不确定下一步该做什么的时候,请参考 GDD。只要你在遵循你所写的内容,下一步应该是一个自然的进展,你可以在此基础上迭代。然而,不要觉得你被你的 GDD 初稿所束缚——作为一个活文档,随着你处理它们,想法会自然地变化,新的想法也会浮现(就像我在写这本书的过程中所做的那样!)。

在本节中,你学习了如何扩展我们的游戏设计文档(GDD),包括游戏主要角色的额外细节,并描述游戏世界以及如何让一切协同工作,为玩家创造沉浸式的体验。我们将在下一节继续导入原始艺术作品,开始使用 Sprite Shape 构建游戏关卡。

将资产导入以用于 Sprite Shape – 一种不同的 2D 环境构建器

我们将为 2D 冒险游戏使用的艺术作品是原创艺术作品,是艺术家 Nica Monami 合作的结果。艺术资产都是为这本书中的这个项目专门创建的。Nica 借用了她创作奇幻绘画风格艺术的天赋,为游戏创造了一个独特的环境,我很高兴能与这些资产合作。

图 4.2 – 原始游戏艺术作品

图 4.2 – 原始游戏艺术作品

冒险游戏 2D 艺术资产

要跟随本章内容,请从以下项目 GitHub 下载艺术作品:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch4/Art-Assets

Nica Monami 允许仅用于学习目的使用提供的游戏艺术作品;商业用途是严格禁止的。Nica 的精选作品可以在 ArtStation 上查看,网址为www.artstation.com/dnanica213

除了 Sprite Shape——我们将使用它来构建大多数冒险游戏关卡——我们还将介绍2D 动画包提供的工具中的大部分。我们首先将介绍导入和为艺术作品进行任何必要的准备工作以开始。

导入和准备艺术作品

让我们从创建一个新的 Unity 项目开始,用于冒险游戏,再次使用 2D 通用渲染管线URP)核心模板。我们将通过将 2D 冒险游戏制作成侧视正交游戏视图(类似于许多其他马里奥兄弟或受《超级马里奥兄弟》启发的平台游戏)来继续探索不同的游戏玩法风格。

项目打开后,将艺术品导入到新的Assets/Sprites文件夹下。

忠实的组织提醒

维护一个合理命名的文件夹结构可以帮助保持事物井然有序,便于以后查找和使用。

在项目窗口的搜索工具中的按类型搜索选项中查看所有导入的精灵资源——以防你丢失了某个资源或同时处理多个资源——可以轻松完成,如下图所示:

图 4.3 – 在项目窗口中过滤精灵

图 4.3 – 在项目窗口中过滤精灵

导入的图像资源将用于不同的目的,并使用额外的精灵工具。以下是本章剩余部分我们将如何按文件夹处理导入资源的快速概述:

  • Assets/Sprites/Sprite Shapes: 此文件夹中的图像将与 Unity 的 2D Sprite Shape工具一起使用。Sprite Shape 是一个基于样条的工具体,它提供了创建开放路径或封闭形状的能力,这些形状可以用作玩家可以行走的关卡部分,添加背景元素或快速装饰环境。

额外阅读 | Unity 文档

2D 精灵 形状: docs.unity3d.com/Packages/com.unity.2d.spriteshape%409.0/manual/index.xhtml

  • Assets/Sprites/Sprite Skins: 此文件夹中的图像将与 Unity 的 2D SpriteSkin组件一起使用。

额外阅读 | Unity 文档

精灵 皮肤: docs.unity3d.com/Packages/com.unity.2d.animation%409.0/manual/SpriteSkin.xhtml

  • Assets/Sprites/Tilemap: 此文件夹中的图像将被用于创建瓦片地图;我们已经在第一章中构建收集游戏时熟悉了这一 2D 功能。

  • Assets/Sprites/Background: 此文件夹中的图像将被用于创建游戏环境的分层背景。这次我们不会使用任何特定的 Unity 功能,但我们将使用脚本添加视差移动。

背景视差

垂直透视是一种应用于背景图像的技术,它使图像在帧中相对于前景图像以更慢的速度远离相机移动,从而在 2D 场景中创造深度感。这种技术在 20 世纪 80 年代初期的 2D 视频游戏中变得流行(尽管由于当时硬件的限制,背景平面的数量有限 – 幸运的是,我们没有受到前辈的限制,因为我们今天没有这样的限制)。

  • Assets/Sprites/Background/Clouds: 该文件夹中的图像将用于创建云朵,这些云朵将在游戏环境的背景中连续滚动并循环,进一步增强深度和沉浸感。我们将使用 Unity 的Spline包和相应的Spline Animate组件来使这项工作变得快速且简单(类似于之前提到的移动平台)。

  • Assets/Sprites/Object Elements: 该文件夹中的图像将用于详细描述环境并提供一些可能的特殊兴趣区域。这里没有特别之处,只是包含 Sprite Renderer 和 2D 碰撞体,以方便与玩家角色进行交互。

在完成对导入资产的基本审查后,让我们在使用之前为需要准备的精灵做一些准备工作。

为 Sprite Shape 准备艺术作品

Sprite Shape 功能创建动态且灵活的形状,可以是开放路径或封闭并填充的区域。为 Sprite Shape 的样条路径分配的精灵将被沿定义的轮廓平铺和变形。由于平铺,我们需要采取一些特殊步骤来准备这里使用的精灵,即在它将无缝平铺的左右边缘位置添加边框。

参考图4**.4,按照以下步骤操作,以确保精灵将与 Sprite Shape 兼容并良好工作:

  1. 在项目文件夹中选择精灵。

  2. 导入设置(检查器)中,点击精灵 编辑器按钮。

  3. 点击图像,并使用绿色框或输入边框对话框中的LR字段中的值,在精灵的平铺位置上向左和右两侧拖动。完成后点击应用

  4. 网格类型设置为全矩形(这是使用 Sprite Shape 所必需的,如果未相应设置,将在 Sprite Shape 资产上产生警告)。

图 4.4 – Sprite Shape 艺术精灵编辑器

图 4.4 – Sprite Shape 艺术精灵编辑器

包管理器示例

Unity 包管理器中的大多数包在其各自的包管理器页面上都有额外的内容作为附加安装。在 Sprite Shape 的情况下,可以导入展示其许多功能的示例和额外内容。我鼓励您查看包示例以进行额外学习。

为 Sprite Skin 准备艺术作品

Sprite Skin 功能通常用于创建有骨架的肢体角色,并且是从分层 Photoshop PSB/PSD 文件(使用PSD Importer包)导入的。我们将在第五章中使用这个 2D 动画包功能,当我们创建冒险游戏的角色时。不过,现在在这个章节中,我们只是给一些环境元素添加一些动作。

我们将动画导入到Sprites/Sprite Skins文件夹中的单个 PNG 图像。除了将这些图像作为单独的图像保留(为了简单起见),我们不需要对导入设置做任何额外操作。

准备 Tilemap 的艺术作品

我们在第二章中广泛使用了 Tilemap;现在你应该已经非常熟练了!让我们通过创建一个新的 Tile Palette 来快速回顾图像准备过程,这个 Palette 是为导入到Sprites/TilemapTilemap-01sprite sheet 图像准备的,如图所示:

图 4.5 – 为 Tilemap 瓦片切片的 Sprite Sheet

图 4.5 – 为 Tilemap 瓦片切片的 Sprite Sheet

使用以下步骤准备用于 Tilemap 的艺术作品:

  1. 在项目文件夹中选择 sprite sheet 图像。

  2. 导入设置(检查器)中,将Sprite 模式设置为多个

  3. 应用更改并点击Sprite 编辑器按钮。

  4. 选择切片下拉菜单,然后选择按单元格大小作为切片的类型

  5. 供应的 Tilemap 图像使用 64x64 像素大小的网格(与集合游戏相同),因此请验证正确的大小并点击切片

  6. 点击应用,或者关闭Sprite 编辑器并保存,这就是 Tilemap 准备工作完成!

正常贴图

正常贴图是一种特殊类型的图像,其中 RGB 通道的编码表示像素的方向。使用精灵的像素和 2D 灯光,很容易添加虚假体积和细节,以实现模拟的 3D 效果。

由于 Unity 不支持创建正常贴图,您需要使用特殊的第三方软件在 Unity 之外创建正常贴图,如图中所示,我们将使用环境中的岩石:

图 4.6 – Laigter 中一块岩石的正常贴图

图 4.6 – Laigter 中一块岩石的正常贴图

正常贴图生成软件

创建 2D 冒险游戏艺术作品的全部 2D 精灵正常贴图的程序是 Laigter(可在azagaya.itch.io/laigter免费获取)。其他可以导出 2D 正常贴图的程序包括 Photoshop、SpriteIlluminator、Sprite Dlight 和 Sprite Lamp。

注意,当导入 2D 精灵正常贴图时,图像导入设置的纹理类型将是Sprite(2D 和 UI),而不是正常贴图——因为受 2D 灯光影响的精灵需要在 Sprite 编辑器中分配正常贴图纹理(2D 灯光也需要启用正常贴图以影响精灵)。

示例 2D 冒险游戏项目中的所有图像都附有相应的正常图。对于您自己的图像,请按照以下步骤生成正常图,然后将它们分配给精灵:

  1. 在项目文件夹中选择精灵。

  2. 点击精灵编辑器按钮进入导入 设置(检查器)

  3. 精灵编辑器下拉菜单中选择次级纹理

  4. 在打开的次级纹理对话框中,从名称下拉菜单中选择_NormalMap

  5. 通过将精灵的正常图图像从项目窗口拖动到纹理字段中,分配精灵的正常图图像。

参考前面的图 4.4,了解次级纹理对话框的示例。

这样,我们就成功地为项目导入的艺术资产做好了准备,以便在接下来的章节中使用 2D 工具。在下一节中,我们将在使用艺术来创建不同类型的平台之前,再次审视关卡设计。

关卡和环境设计 – 引导玩家

在继续介绍我们将用于构建游戏关卡的功能之前,让我们暂时从项目的技术方面抽身,再次讨论一些游戏设计概念。

拥有 GDD 很好,但它不提供任何具体的视觉来传达游戏的主题和风格。艺术可以迅速引发情感反应,并建立难以与 GDD 的书面文字相比的兴奋感。例如,看看为Outer World游戏创建的概念艺术:

图 4.7 – 原始“外部世界”环境概念艺术

图 4.7 – 原始“外部世界”环境概念艺术

您应该立刻感受到在这个环境中作为玩家的感觉!游戏通常在早期通过艺术家的渲染来营销,以可视化概念,以激发对项目的兴趣和兴奋。它们也被内部使用,以激发制作团队构建产品。

在兴奋的氛围中,让我们介绍一个新的游戏设计原则来引导玩家,并让他们探索这个独特的环境。

指引

我们在第二章中讨论了引导玩家,通过引入形状来引导玩家走向期望的方向。我们在这里将提出的游戏设计原则是环境中的焦点,这被称为指引。指引帮助玩家知道他们应该做什么,或者告诉他们他们的目的地。

我们希望玩家在玩游戏时有明确的目标——玩家几乎不会在游戏中感到迷失(只有当他们长时间没有进步时,引导他们走向正确的方向,以避免玩家感到沮丧或更糟,离开游戏)。这种类型的指引也被称为旅程

在冒险游戏中,我们为玩家设定的旅程可以在以下图中看到,其中在背景的远处可以看到一个栖息地站:

图 4.8 – 为玩家指示栖息地站

图 4.8 – 为玩家指示栖息地站

现在玩家有一个目标了。始终考虑以目的来设计水平…我们希望玩家尝试做什么,玩家应该完成什么,或者玩家应该到达哪里?

在使用以下章节中我们将介绍的工具设计你的水平时,请记住这些事情,首先是添加玩家将行走的平台。

创建平台

没有平台就无法玩平台游戏!Unity 的 2D 工具集提供了不同的工具来构建 2D 游戏的平台。我们已经使用 Tilemap 为收集游戏创建了一个刚性和基于网格的水平设计。虽然我们仍将使用 Tilemap 为冒险游戏水平的一部分,但我们首先将使用 Sprite Shape 创建没有相同约束的平台。

创建封闭 Sprite Shape 配置文件

在使用 Sprite Shape 创建时,我们可以使用两种类型的形状:开放形状和封闭形状。开放形状提供了样条路径的精灵轮廓,而封闭形状包括填充纹理,用于创建一个封闭的形状,其中也可以定义两侧和底部精灵。

让我们从创建一个封闭的 Sprite Shape 平台开始,玩家将在其中出生。为此,我们首先在项目中创建 Sprite Shape 配置文件资产,然后按照以下步骤将平台添加到场景中:

  1. 在项目窗口中,在Assets/Sprites/Sprite Shapes文件夹内,选择创建 | 2D | Sprite Shape Profile,并将其命名为Platform Closed 1

  2. 通过首先点击定义角度范围的圆形的蓝色边框,为平台的顶部边缘分配精灵。

  3. 调整45-45

  4. 现在已经定义了顶部边缘的区域,通过从项目窗口拖动并将其拖入SpriteShapeEdge占位符,分配sprite_strip_rock精灵。

  5. 对 Sprite Shape 的左侧和右侧都做同样的处理,分别使用45135作为角度,并将sprite_side_rock作为精灵,如图所示:

图 4.9 – 关闭的 Sprite Shape 资产属性(顶部、左侧和右侧)

图 4.9 – 关闭的 Sprite Shape 资产属性(顶部、左侧和右侧)

  1. 创建封闭 Sprite Shape 的最后一步是为它分配填充纹理,因此将sprite_fill_rock精灵分配给填充 | 纹理字段(替换Sprite Shape填充占位符)。

如果分配的精灵大小不符合你的喜好,你可以重新访问精灵导入设置,并将每单位像素的值更改为看起来更好的值(这可以在任何时候进行调整)。用于精灵形状的图像是高分辨率的,因此你可以灵活地更改大小而不会损失显示质量。

  1. 同样,我们也可以调整填充纹理的缩放比例,但我们将在这个场景中添加到SpriteShapeController组件时进行操作——使用填充 | 每单位像素字段来调整填充纹理的缩放比例。

  2. 创建了配置文件资产后,我们现在可以通过点击GameObject | 2D Object | Sprite Shape | Closed Shape来为我们的玩家制作第一个平台。

  3. 分配精灵形状资产SpriteShapeController组件。通过操纵和添加或删除节点来调整样条形状,如图下所示:

图 4.10 – 场景中的封闭精灵形状平台

图 4.10 – 场景中的封闭精灵形状平台

要使这个封闭的精灵形状成为玩家角色的可通行平台,最后一步是添加一个碰撞器。对于精灵形状平台,我们使用edge

  1. 添加后,选择精灵形状控制器 | 碰撞器 | 更新碰撞器来启用它。然后,调整偏移值以与顶部边缘精灵对齐——这也可以在玩家角色添加到场景后进行调整。

优化注意事项

默认情况下,精灵形状 API 允许你在运行时更改样条节点。如果你不需要运行时更改,你可以烘焙或缓存样条的几何形状以提高性能。所以,如果你不需要在运行时修改样条,选择精灵形状控制器,启用编辑样条,然后点击缓存几何形状来烘焙网格。否则,从包管理器安装 Burst 包(版本 1.3 或更高)以提高在运行时修改样条时的性能。

另一点需要注意的是,精灵形状的渲染器就像任何其他精灵渲染器组件一样,并使用排序层。我们将在整个项目中使用排序层来正确分层构成关卡平台和整体环境的各种艺术作品。我们将把所有关卡的平台都放在默认层上,因此我们不需要做任何更改。

奖励活动

精灵形状还可以用于在可更新的样条路径上分布各种精灵。探索精灵形状的功能,了解你如何使用提供的藤蔓毒素资产来装饰平台。

现在你已经学会了如何使用精灵形状创建灵活形状的平台,在继续创建关卡中的动态和交互式功能之前,我们将快速回顾设置 Tile Palette 以用于使用地图块绘制平台。

地图块

本节将快速回顾使用 Tilemap 在我们的关卡设计中创建平台,因为我们已经对这个主题进行了广泛的介绍第二章

要使用 Tile Palette 绘制地块,请按照以下步骤操作:

  1. 通过转到窗口 | 2D | Tile Palette打开 Tile Palette。

  2. 使用默认的矩形网格属性时,选择Environment

  3. 点击Assets/Sprites/Tilemap/Tile Palettes

  4. 当提示时,从Asset/Sprites/Tilemap文件夹中,点击并拖动Tilemap-01图像到Assets/Sprites/Tilemap/Tiles文件夹。

现在我们已经创建了用于 Tilemap 部分关卡的地块,让我们继续使用以下步骤制作位于 Sprite Shape 平台右侧的平台:

  1. 层次结构窗口中,通过使用创建GameObject菜单并选择2D Object | Tilemap | Rectangular创建一个新的 Tilemap。

  2. 我们将在Tilemap Renderer上使用与 Sprite Shape Renderer 相同的默认排序层。

  3. 使用刷子和填充工具绘制平台,同时使用正确的地块来绘制顶部、侧面、底部和角落,如图下所示:

图 4.11 – 添加到场景中的 Tilemap 平台

图 4.11 – 添加到场景中的 Tilemap 平台

  1. 作为最后一步,将Tilemap Collider 2D组件添加到 Tilemap 对象上,以便玩家角色可以行走。

我们现在有两种不同的平台样式可以使用,以在关卡中创建视觉上不同的区域。在下一节中,我们将通过引入运动和用 C#脚本在环境中触发交互,将平台提升到新的水平。

移动平台和触发器 – 创建动态交互式环境

将移动平台添加到我们的游戏关卡中增加了视觉兴趣,并为玩家提供了额外的挑战。Unity 再次提供了工具,使得在场景视图中创建移动平台变得简单直接,而且无需编写任何代码。

我们之前使用 Sprite Shape 制作了一个封闭的平台,但 Sprite Shape 还允许创建开放形状,这对于制作一个可以移动的小平台将非常完美。

使用 Splines 移动 Sprite Shape 平台

Unity 2022 技术流引入了新的 2D 包,名为Spline组件。Sprite Animate组件用于沿 spline 路径移动平台,并且无需使用任何代码。太棒了!

Splines

通过让生成的线条通过任意数量的控制点,可以使用样条曲线创建平滑形状。提供硬边和圆角之间调整的插值线条的不同方法可用,例如 Catmull-Rom(有助于计算通过所有控制点的曲线)、贝塞尔曲线(提供手柄以调整线条相对于点的切线)和 B-Splines(类似于 Catmull-Rom 样条曲线,但生成的线条不一定通过控制点)。

Unity 2022 的Splines包专门支持线性、Catmull-Rom 和 Bézier 类型。

要在我们的项目中使用样条曲线,我们需要验证该包是否已安装。打开splines或找到Splines列表中,选择并安装。

创建移动平台的第一步是创建平台。

创建开放的精灵形状配置文件

当我们在创建一个封闭的精灵形状配置文件部分创建封闭平台时,我们已经对精灵形状进行了介绍。创建开放的精灵形状甚至更容易!

使用以下几个步骤创建开放的精灵形状配置文件资产:

  1. 在项目窗口中,在Assets/Sprites/Sprite Shapes文件夹内,选择创建 | 2D | Sprite Shape Profile并将其命名为Platform Open 1

  2. 通过从项目窗口拖动sprite_strip_rock精灵并将其拖入SpriteShapeEdge占位符来分配sprite_strip_rock精灵)。

图 4.12 – 开放的精灵形状资产属性

图 4.12 – 开放的精灵形状资产属性

  1. 创建了配置文件资产后,现在我们可以通过点击GameObject | 2D Object | Sprite Shape | Open Shape将第一个开放的精灵形状移动平台添加到场景中。

  2. 分配SpriteShapeController组件的配置文件字段,然后使用场景工具栏覆盖中的形状编辑工具制作一个小直平台(如图*图 4.13 所示)。提示:使用只有两个节点,并将切线模式设置为线性**。

与之前一样,使这个开放的精灵形状成为玩家角色可走的平台所需的最后一步是添加一个碰撞器——我们同样使用edge

  1. 一旦添加,请确保精灵形状控制器 | 碰撞器 | 更新碰撞器字段已启用,然后调整偏移值以与顶部边缘精灵对齐(这可以在任何时候进行调整)。

最后,让我们将移动平台保持在与其他平台相同的排序层上:默认

创建移动平台的下一步是设置它将采取的路径。

样条路径

与我们使用样条曲线创建精灵形状平台的方式类似,我们可以使用 Unity 的Splines功能工具来创建通用的样条路径,用于任何数量的游戏玩法原因。在这里,我们只是为平台在两点之间移动创建一个路径(可能更多,但我们只创建一个简单的垂直或水平移动平台)。

层级 窗口中,使用 创建 菜单或单击 游戏对象 | 3D 对象 | 样条 | 绘制样条工具… 来向场景添加一个新的样条(重命名它或按 Enter 键以接受默认值)。是的,样条被认为是 3D 对象,因为样条路径的节点是 Vector3 位置(相对于游戏对象的变换位置,因此它们位于 局部空间)。我们将仅使用它们在 2D(XY 轴)上为此项目,所以请注意 Z 轴的值始终应为零。

使用 (0, 0, 0)样条 对象的位置将被设置为点击的位置)。

点击在第一个节点下方一定距离处添加第二个节点,并使用 精灵检查器 叠加层将它的 X 位置值设置为零,以便节点垂直对齐,如图所示:

图 4.13 – 使用贝塞尔样条为开放精灵形状平台工作

图 4.13 – 使用贝塞尔样条为开放精灵形状平台工作

注意,当选择样条工具时,样条检查器叠加层可能会停靠在场景视图的侧面,如图 4.13* 所示。您可以在叠加工具栏中单击图标或将它拖入场景视图窗口来操作所选样条节点的属性。

如果需要编辑位置或节点的 切线模式,请使用 样条变换 工具,并为添加到样条中的节点使用 绘制样条 工具(这两个工具都在选择层级中的样条对象时在 工具栏 叠加层中可用)。

额外阅读 | Unity 文档 | Unity 2022 新特性

样条docs.unity3d.com/Packages/com.unity.splines%401.0/manual/getting-started-with-splines.xhtml

现在是时候让事物动起来了!

Spline Animate

Splines 包提供了覆盖一些常见样条路径用例的附加组件。我们将在这里使用其中之一 – 样条 动画 组件。

为了使我们的移动平台不仅易于动画化和操作,而且可以快速轻松地创建预制件,以便在整个关卡中添加额外的移动平台,我们需要做一些整理工作。

目前,我们有一个开放的精灵形状平台对象和一个样条路径对象位于层级结构的根目录。为了使其成为一个可重用的预制件,我们可以在其中对样条路径和平台的位置有最大控制,我们希望最终得到以下对象层级:

图 4.14 – 层级关系

图 4.14 – 层级关系

使用 层级 窗口 创建 菜单中可用的 创建空对象创建空父对象 选项来完成此操作 – 空游戏对象是组织或提供操纵对象额外方式的好方法!

只确保样条线和平台对象的变换位置在 (0, 0, 0),因为我们应该使用根对象(Moving Platform 1)的变换位置来放置平台在关卡中。

创建这种对象层次结构的一部分原因是为了添加我们将要使用 Sprite Animate 组件移动的对象 – Platform Mover 对象。现在通过选择 Platform Mover 并在 Inspector 窗口中点击 Add Component 来添加 Sprite Animate 组件。将 Spline 对象分配给 Spline 字段,在 Align To 字段中选择 Spline Object,然后将 Loop Mode 设置为 Ping Pong(这样平台会在样条线节点之间来回移动)。

使用以下图作为参考,调整 DurationEasing 到你喜欢的程度。

图 4.15 – 使用样条线动画移动平台

图 4.15 – 使用样条线动画移动平台

就这样!我说过让我们的平台移动起来会很容易……只需要一点设置。你可以在场景视图中预览平台的移动,而无需进入 Play Mode。使用 PlayPauseReset 控制来调整持续时间、缓动类型、样条线节点的位置以及 Platform Open 1 的偏移位置,直到你对定位和移动满意为止。

最后,将 Moving Platform 1 对象拖到项目窗口中的 Assets/Prefabs 文件夹以创建预制资产。

在关卡中触发动作

游戏中经常有当玩家移动到特定区域或触摸某物时在环境中触发的交互 – 想想岩石掉落、门打开、警报敌人、开关灯、过渡到场景或任何你的游戏需要的。如果我们利用 Unity 物理引擎,就像我们之前在收集游戏中执行收集物品一样,这很容易添加。

流程如下:

  1. 我们首先将对象的精灵添加到游戏关卡中。

  2. 在对象周围创建一个碰撞体体积来定义触发区域 – 使用最经济的 2D 碰撞体类型,例如 CircleCollider2D

  3. 在碰撞体上启用 IsTrigger,这样它就不会与场景中的任何其他对象进行物理交互。

  4. 添加一个组件,当玩家进入碰撞体时,当调用 Unity 物理消息 OnTriggerEnter2D() 时将触发事件。

我们还将采取一个对设计师友好的方法,在 Inspector 窗口中使动作可分配,这样我们就不必为每种触发事件创建自定义脚本。这将使触发事件组件可以在游戏中任何我们希望玩家触发交互的地方重复使用。太好了!

UnityEvent

我们在这里将使用的委托事件类型是 UnityEvent – 我们之前使用过 UnityAction。区别在于 UnityEvent 是序列化的,并可以在 Inspector 中使用来分配公共方法。

额外阅读 | Unity 文档

UnityEvent: docs.unity3d.com/2022.3/Documentation/ScriptReference/Events.UnityEvent.xhtml

在 Unity 2022 中,Inspector中的UnityEvent列表现在可以重新排序了!

让我们通过创建一个名为TriggeredEvent的新脚本,并使用以下代码来查看它是如何工作的:

[RequireComponent(typeof(Collider2D))]
public class TriggeredEvent : MonoBehaviour
{
    [Tooltip("Requires the player character
        to have the 'Player' tag assigned.")]
    public bool IsTriggeredByPlayer = true;
    public UnityEvent OnTriggered;
    private void OnTriggerEnter2D(Collider2D collision)
    {
      if (IsTriggeredByPlayer &&
          !collision.CompareTag(Tags.Player))
              return;
      OnTriggered?.Invoke();
    }
}

让我们分解一下,特别是因为有几个值得注意的新项目:

  1. 类声明被RequireComponent属性装饰。这意味着当TriggeredEvent脚本作为组件添加到 GameObject 时,它将要求在 GameObject 上存在指定的类型作为同级组件。如果可能,将添加所需组件。

  2. 在我们的情况下,我们只指定了所有 2D 碰撞体继承的基础Collider2D类型——这允许我们添加任何类型的碰撞体作为触发事件的触发体积。如果我们尝试将脚本添加到尚未添加 2D 碰撞体的 GameObject,我们将收到以下错误:

Figure 4.16 – Can’t add script for the required component

Figure 4.16 – Can’t add script for the required component

解决方案是在添加TriggeredEvent脚本之前,将 2D 碰撞体(适用于玩家交互场景)添加到 GameObject 中。对于这个例子,我们添加了一个CircleCollider2D,将其放置并调整大小在奇特岩石形成之前,并触发一个粒子系统开始播放(如图 4**.16所示)。

  1. 接下来是我们在Inspector中设置的IsTriggeredByPlayer布尔值。在其下方几行将用于确定是否只有玩家可以触发交互。这个字段是可选的,但快速且容易添加,如果,比如说,敌人或其他对象进入碰撞体(在这里考虑所有可能触发环境中的不同事情)。

  2. 正如刚才讨论的,UnityEvent声明允许我们,作为开发者和设计师,在Inspector中添加特定的交互——一个或多个——当触发时会被调用(如图 4.16Inspector下较暗灰色On Triggered ()部分所示)。16*).

  3. if块决定了如果只需要玩家触发事件,我们是否会继续运行触发代码。所以如果IsTriggeredByPlayer设置为true,条件 AND 运算符(&&)将评估碰撞的对象是否被标记为Player。如果不是,它将停止执行此方法代码(通过使用return语句)。

&& (C#)

条件 AND(&&)运算符意味着两个语句在逻辑上都必须等于true才能执行块中的代码。请注意,这具有“短路”逻辑,意味着如果第一个表达式评估为false,则第二个表达式将不会评估。

这里要注意的另一件事是,Tags.Player 将导致编译错误,因为 Tags 类型尚未定义(通过红色波浪线下的标识)。现在让我们修复这个问题。我们将通过在 IDE 中右键单击单词 Tags(或单击 Tags 上的任何位置并按 Alt + Enter,或 Ctrl + .,具体取决于您的 IDE)来使用 IDE 内置的重构工具。

在打开的对话框中,选择重构... | 生成类型‘标签’ | 新文件中生成类‘标签’。

优化说明

为了性能原因,你应该始终使用 CompareTag() 而不是使用 == 操作符来评估 GameObject 的 .tag 属性。

创建新文件后,添加一个用于 Player 标签字符串的常量,如下所示:

internal class Tags
{
    // Ensure all tags are spelled correctly!
    public const string Player = "Player";
}

注意,这个类不会从 MonoBehaviour 继承,因为我们不会将其添加到场景中的 GameObject。它仅在我们需要在代码的任何地方指定对象的标签时使用 - 最小化代码中字符串字面量的使用,避免简单的拼写错误,并提供 CodeLens 引用,以便在代码中使用的地方都能找到。任何需要引用游戏中其他对象标签的时候,只需在这里添加即可。

常量(C#)

声明为常量的变量是一个不可变的值,在整个程序的生命周期中都不会改变(即在编译时已知)。使用公共访问器声明的 conststringintfloat 等)对于其他类来说是可用的,无需实例引用 - 类似于静态变量的使用方式。

  1. 最后一行调用了在 OnTriggered?.Invoke(); 中分配的事件(s)- 注意,这里的 ?.(空条件)操作符是可选的,但我总是包括它以保持代码一致性。

现在,按照以下图所示的设置,当玩家进入碰撞体时,粒子将从岩石中产生!

图 4.17 – 带场景视图图标的 TriggerEvent 碰撞体

图 4.17 – 带场景视图图标的 TriggerEvent 碰撞体

使用这种可重复使用的触发任何类型事件的组件,现在对于项目中的开发者和设计师来说,通过添加交互变得非常简单!易如反掌!

在本节中,我们现在已经完成了所有用于创建所有可通行平台(包括移动平台)以及关卡中交互性的 2D 功能工具。在下一节中,我们将查看最终完成游戏环境、添加细节和优化绘制调用以获得更好的性能。

为我们的环境添加细节以沉浸玩家并优化

使用前几节中介绍的工具和技术,你现在应该探索构建你的关卡设计。为主要的平台使用封闭的精灵形状,为既是静态又是移动的平台使用开放的精灵形状,并在关卡特定区域混合使用瓦片地图平台——所有这些都被用来给玩家在前往我们背景中提供的栖息站点的旅程中提供体验和挑战。

在空白屏幕上的平台相当单调。让我们通过添加背景和一些运动来为关卡添加一些润色。

环境润色

现在您的游戏关卡已经很好地定义了(当然,一旦我们添加了玩家角色并开始测试关卡,我们将会进行大量的调整),让我们看看如何完成和润色环境,从添加背景和前景元素开始,以更充分地完善游戏设计——提供沉浸感和为玩家体验设定基调。

背景视差效果

如前所述,我们将应用一个相对较旧的 2D 游戏设计技术,即视差,将背景中的分层图像应用于创建深度和沉浸感。我们将使用自定义的 C#脚本来实现这一点,因为 Unity 没有提供专门针对处理此技术的 2D 功能。

让我们从在Assets/Scripts文件夹中创建一个新的 C#脚本开始,并将其命名为ParallaxLayers。这个脚本将是一个可重用的组件,我们可以用它为任意数量的分层图像添加视差移动。在 2D 冒险游戏中,我们将使用它来处理背景和一些前景元素。

让我们看一下以下代码:

public class ParallaxLayers : MonoBehaviour
{
    public List<Layer> Layers;
    [System.Serializable]
    public class Layer    // Nested class.
    {
      public Renderer Image;
      [Tooltip("How far away is the image from the
          camera?"), Range(0, 10000)]
      public int Zdepth;
    }
    private Camera _camera;
    private Vector3 _cameraLastScreenPosition;
    private void Awake()
    {
      _camera = Camera.main;
      _cameraLastScreenPosition =
          _camera.transform.position;
    }
    private void LateUpdate()
    {
      if (_camera.transform.position.x ==
          _cameraLastScreenPosition.x)
              return;
      foreach (var item in Layers)
      {
        float parallaxSpeed = 1 – Mathf.Clamp01(
            Mathf.Abs(_camera.transform.position.z
                / item.Zdepth));
        float difference = _camera.transform.position.x
            - _cameraLastScreenPosition.x;
        item.Image.transform.Translate(
            difference * parallaxSpeed * Vector3.right);
      }
      _cameraLastScreenPosition =
          _camera.transform.position;
    }
}

让我们逐项分析代码:

  1. 声明的第一个公共变量是我们将用于视差层的图像列表,这些图像将根据它们的深度以不同的速度移动(这就是如何实现视差效果;远处的对象看起来比近处的对象移动得慢)。我们使用 C# List<T>指定Layer作为类型,其中Layer是我们声明的自定义类[对象],用于保存特定图像及其分配给它的深度值,以计算其相对于相机移动的速度。

列表(C#)

List 是一个强类型对象的集合,可以通过它提供的方法添加、按索引访问或返回。它还提供了排序和以不同方式操作对象的方法。

附加阅读:https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1

  1. 接下来是Layer嵌套类声明,其中包含公共字段Image,其类型为Renderer(所有精灵、网格、线条、尾迹和粒子都是通过从Renderer类派生的组件绘制的),以及ZDepth,这是一个表示图像距离相机距离的int值。

  2. ZDepth 变量声明也装饰了 TooltipRange 属性(这些可以在单个 [] 语句中以逗号分隔)。Tooltip 属性在鼠标悬停在 Range 属性的字段标签上时显示指定的消息文本;Range 属性将限制字段的有效值在指定的范围内。

额外阅读 | Unity 文档

属性docs.unity3d.com/2022.3/Documentation/Manual/Attributes.xhtml

  1. Layer 类声明本身也装饰了 System.Serializable 属性。在 Layers 访问器中,类声明不可用,仅此不足以序列化类。

  2. 私有的 _camera 成员用于缓存主相机引用,因为我们多次从它获取计算值,并且希望这具有高性能。

  3. LateUpdate()Update() 之后以及内部动画更新处理之后执行。在游戏循环中的所有更新完成后,我们可以使用 LateUpdate() 进一步影响这些更新。在这种情况下,我们希望在相机移动之后才移动背景层。

额外阅读 | Unity 文档

LateUpdatedocs.unity3d.com/2022.3/Documentation/ScriptReference/MonoBehaviour.LateUpdate.xhtml

事件函数执行顺序docs.unity3d.com/2022.3/Documentation/Manual/ExecutionOrder.xhtml

  1. 相机是否移动了?通过快速评估 _cameraLastScreenPosition 布尔值即可得知——如果没有移动,则不要执行后续操作(使用 return 语句)。不要运行不必要的代码!

  2. 这就是魔法发生的地方……使用 foreach 迭代器,我们将遍历在 检查器 中添加的每个 Layer 项目,并按照相机移动的距离计算相对距离来移动它:

    foreach (var item in Layers)
    {
    
    • parallaxSpeed:我们首先从相机位置获取图像深度的比例(数学运算的顺序),返回绝对值(一个正数),将值限制在 01 的范围内,最后从 1 减去限制后的值,以获取相机移动的百分比来移动图像:

      float parallaxSpeed = 1 - Mathf.Clamp01(
          Mathf.Abs(_camera.transform.position.z /
              item.ZDepth));
      
    • difference:相机从当前位置移动到上次位置的移动距离:

      float difference = _camera.transform.position.x
          - _cameraLastScreenPosition.x;
      
    • transform.Translate():沿 X 轴(由于 Vector3.right)移动图像,移动距离是相机移动距离乘以速度因子(实际上,接近 1 的速度值移动相对相同的距离):

      item.Image.transform.Translate(
          difference * parallaxSpeed * Vector3.right);
      

foreach (C#)

foreach 语句遍历一个项目集合,并对每个项目执行其代码块。列表中的每个元素都是声明列表时指定的类型。

关于 C#迭代语句的附加阅读:docs.microsoft.com/en-us/dotnet/csharp/language-reference/statements/iteration-statements

  1. 在移动后更新保存摄像机最后位置的变量。

整数与浮点除以零

你可能会想,“嘿,等等,Scott,使用Range属性,你允许距离为零的值,而为了计算视差速度,我们正在除以距离。所以,如果距离为零,那么不会抛出除以零异常错误吗?”你提出了一个敏锐的观察点;感谢你的提问!如果摄像机的transform.position.z值是整数类型,你会是对的。它确实会抛出异常,但它是一个浮点值,所以除以零永远不会抛出异常,因为在 C#中,浮点类型基于 IEEE 754 标准,它允许表示无穷大和非数字NaN)的数字。

附加阅读docs.microsoft.com/en-us/dotnet/api/system.dividebyzeroexception?view=net-6.0

现在我们已经完成了视差效果脚本,让我们将背景层添加到场景中,并分配字段值。

以下ZDepth值为 10,000——这意味着它们看起来是静止的——以及靠近摄像机的图像值小于 1,000——这意味着它们会更靠近摄像机的移动:

图 4.18 – 分配给检查器的视差背景层

图 4.18 – 分配给检查器的视差背景层

上述图还显示了我们可以用来组织背景层图像(以及使用相同技术的前景图像)的对象层次结构。将Assets/Sprites/Background中的背景图像拖到场景中,并将层次结构中的对象设置为背景空 GameObject 的父对象。

在场景中的对象,你可以定位它们,并使用排序层和层顺序(Sprite Renderer)来设置它们的显示。务必添加一个新的背景排序层,并将其放置在层 0,这样我们的平台默认层就会在背景图像之前绘制。

这更多的是艺术而不是技术,所以可以尝试调整位置和缩放、背景图像顺序等,直到看起来不错!

现在,为每个图像添加ZDepth值——为距离更远的图像分配更高的值。

你可以通过输入ZDepth值来测试视差效果,以确保它在你的关卡设计范围内工作。

让我们继续用一些在天空中移动的动画云来为环境增添光泽。这再次利用Spline Animate组件将会非常简单。

动画云

在本章的早期部分,你学习了如何使用简单的样条路径和动画组件轻松创建移动平台。我们将采用相同的方法创建两层不同风格的云层,以不同的速度在天空移动。与平台相比,这里唯一的区别是云层将从屏幕外开始,并持续循环,而不是像乒乓球一样来回移动(毕竟,云层通常只在一个方向上穿越天空)。

使用以下步骤创建两层云层在天空中移动:

  1. 使用(90, 0, 0)将 Spline 对象添加到场景中。
  • 将云精灵添加到场景中,并将其与样条一起设置为名为Clouds 1的新空 GameObject(你可以添加任意数量的云层,只需按图 4.18 所示增加计数即可)。我们有两个云层,并为Clouds 2对象添加了多个云精灵。* 将Spline Animate组件添加到云层图像 GameObject 中,并设置以下属性:

    1. 层次结构中的 Spline 对象分配到样条字段。

    2. 对齐到设置为样条对象

    3. 持续时间字段设置为使云层在天空中缓慢移动的值 – 确保将此值设置为不同的时间,以便多个云层彼此偏移!

    4. 循环模式设置为循环连续,因为我们希望云层在达到左侧的终点节点后,在右侧重新开始动画。图 4.19 – 动画背景云层设置

图 4.19 – 动画背景云层设置

这是一种快速简单的方法,可以让环境更加生动。对于接下来我们将介绍的下一个 2D 工具功能,也是如此,用于添加更多细节。

带有精灵皮肤的动画环境艺术

现在,让我们设置一些植物实体的藤蔓,使其在背景中以不同的方式摇摆和移动,通过这里不自然的力给玩家一种存在感。

我们已经导入并准备了与Assets/Sprites/Sprite Skins文件夹一起使用的艺术作品,并打开精灵编辑器

使用以下步骤创建一个可由一组加权骨骼变形的网格几何体:

  1. 精灵编辑器下拉菜单中选择皮肤编辑器,并启用可见性工具(在还原应用旁边的窗口右上角)。

  2. 骨骼部分(窗口左侧),选择创建****骨骼按钮。

  3. 下一个步骤是至关重要的,但在皮肤编辑器的工作流程中并不明显 – 双击精灵!

你现在应该有一个尖端带有红色点的光标。通过将骨骼拖动到所需长度来创建骨骼,点击添加新的骨骼,完成时按Esc键。参考图 4.19了解如何布局骨骼(请注意,使用较少的骨骼同时允许所需的变形会更高效)。

  1. 当对骨骼满意时,在 Geometry 部分下选择 Auto Geometry,然后点击 Generate For Selected 按钮:

图 4.20 – 使用 Sprite Skinning 动画藤蔓

图 4.20 – 使用 Sprite Skinning 动画藤蔓

  1. 最后,在 Weights 部分下选择 Auto Weights 并点击窗口右下角的 Generate 按钮。

在这一点上,点击 Apply 按钮,我们就创建了我们的第一个 Sprite Skin。耶,准备进行动画!您可以通过在 Pose 部分下点击 Preview Pose 按钮来在 Skinning Editor 中测试变形。

现在我们已经将藤蔓 绑定,它就准备好进行动画制作了!

动画 Sprite Skin

现在我们将藤蔓精灵添加到游戏环境中作为背景元素,并准备它进行动画:

  1. 将藤蔓精灵拖动到 Scene Hierarchy 中,作为您背景图像的一个子项(这样它就会随着背景的视差移动),定位,并相应地设置排序层和图层顺序。

  2. 当精灵被选中时,在 skin 中。

  3. Sprite Skin 组件中,点击 Create Bones 按钮。

现在我们需要给藤蔓添加动画。当藤蔓被选中时,打开名为 Vine 1 Idle.anim 文件并将其保存到 Assets/Animation 文件夹中。现在,请戴上你的动画师帽子……因为现在是时候进行动画制作了!

图 4.20 为参考,通过点击红色记录按钮(Animation 窗口),每当您旋转或定位骨骼(使用我们操纵任何对象的相同变换工具)时,都会在 Animation 时间轴上记录一个关键帧。在时间轴上刮擦并重复此过程以获得藤蔓所需的运动。

再次强调,这更多的是艺术而非技术,需要一些尝试和错误。随着练习,这会变得更加直观,并且更快地实现良好的效果。使用回放控件并相应调整——你做到了!

图 4.21 – 使用关键帧骨骼旋转动画藤蔓

图 4.21 – 使用关键帧骨骼旋转动画藤蔓

额外阅读 | Unity 文档

动画docs.unity3d.com/2022.3/Documentation/Manual/AnimationSection.xhtml

从这本书的静态图片中,您无法真正感受到这些藤蔓在动画中的恐怖样子!进入 Play Mode 亲自体验您自己的关卡设计,或者确保检查完成的项目代码或从 GitHub 仓库在线玩游戏。

让我们看看如何保持藤蔓动画在游戏中的性能。

Sprite Skin 性能

作为使用 Sprite Skin 提高动画性能的最后一步,安装 BurstCollections 包以启用 变形批处理

图 4.22 – 为优化安装 Burst 和 Collections

图 4.22 – 为优化安装 Burst 和 Collections

在继续性能优化主题之前,在我们开始导入并设置游戏中的玩家角色和敌人之前,让我们看看如何优化精灵绘制调用,以帮助保持我们的帧率不会下降到不可接受的水平。

优化绘制调用

我们可以做出的最具影响力的性能优化是解决绘制调用。这在第二章的“导入精灵”部分中简要讨论过,当时我们创建了一个精灵图集。不出所料,我们现在将做同样的事情。

精灵图集

使用Assets/Sprites文件夹,然后选择Sprite Atlas。请注意,我们不会添加任何 2D 精灵法线贴图,因为它们将以与精灵编辑器相同的方式在精灵图集中内部处理。

Assets/Sprites文件夹中的单个精灵图像分配到Assets/Sprites/Background Normal Maps)。

当图像太多无法放入单个纹理中时,精灵图集将创建额外的纹理,并在预览窗口的标题栏中使用带有#0标签的下拉指示器来指示。在某些情况下(例如,较大的游戏),可能需要使用多个精灵图集,您将在运行时确定绑定哪个。

注意:出于兼容性原因,在使用精灵形状时,我们必须确保禁用允许旋转紧密包装

额外阅读 | Unity 文档

精灵 图集docs.unity3d.com/2022.3/Documentation/Manual/class-SpriteAtlas.xhtml

在本节中,我们通过添加透视背景效果和动画元素来润色了外世界 2D 冒险游戏的外观。然后,我们通过减少精灵绘制调用来保持游戏性能。

下载完成的游戏代码

记住,本书的示例代码可以从 GitHub 仓库在此处下载:github.com/PacktPublishing/Unity-2022-by-Example

摘要

本章首先介绍了游戏、关卡和环境设计,通过引入新的元素来扩展 GDD 的范围,以涵盖更大范围的冒险游戏,导入并准备用于 Unity 2D 功能的艺术作品,并引入一个新的关卡设计原则来引导玩家达到目标。

我们通过创建静态和移动平台来继续导入的艺术作品,以挑战玩家在他们的旅程中的表现。然后,我们通过设置透视背景和动画环境元素,让玩家沉浸在游戏世界中。

最后,我们使用精灵图集优化了定义所有关卡和环境元素的精灵的绘制调用。

在下一章中,我们将使用 PSD 导入器导入一个角色绑定,从而快速设置玩家角色以进行动画制作。我们还将学习如何通过实现 Unity 2022 新增的功能,以优化的方式添加一个能够发射弹体的玩家武器,并开始介绍玩家在前往栖息地站点的旅途中需要抵御的敌人角色。

第五章:继续冒险游戏

第四章中,我们通过扩展游戏的设计文档(GDD)来覆盖冒险游戏更广泛的范围,同时探索关卡和环境设计,并考虑新的原则来引导玩家。我们还导入并准备了艺术作品,以便与将游戏带入生命的额外 2D 工具一起使用。

我们还在关卡设计中探索并添加了移动平台和交互元素,通过在分层背景上的视差效果沉浸玩家,并优化精灵绘制调用以保持性能。

在游戏关卡和环境建立之后,我们现在可以继续使用 2D 动画包创建我们的玩家角色。

在本章中,我们将涵盖以下主要主题:

  • 使用 PSD 导入器设置玩家角色

  • 使用输入动作图

  • 使用玩家控制器脚本移动玩家

  • 使用 Mecanim 进行角色动画

到本章结束时,您将能够设置一个 2D 基于精灵的、为动画而设置的、由玩家输入驱动的角色。您还将能够为玩家的当前状态分配和切换所需的动画。

技术要求

要跟随本章内容并使用本书项目中创建的相同艺术作品,请从以下 GitHub 链接下载冒险游戏 2D 艺术资源:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch5/Art-Assets

要使用自己的艺术作品进行跟随,您需要使用 Adobe Photoshop 创建类似的艺术作品,或者使用可以导出分层 PSD/PSB 文件的图形程序(例如 Gimp、MediBang Paint、Krita 和 Affinity Photo)。

您可以从 GitHub 下载完整的项目,链接为github.com/PacktPublishing/Unity-2022-by-Example

使用 PSD 导入器设置玩家角色

为 2D 冒险游戏创建我们的玩家角色将是一个多步骤的过程。在本节中,我们将介绍制作由玩家控制的动画 2D 角色所需的全部步骤。

我们将从导入艺术作品的设置开始,并设置允许我们进行动画的玩家角色骨骼。在导入艺术作品和资产的过程中,您将多次重复这些操作。PSD 导入器是一个资产导入器,它可以与多层的 PSB/PSD 文件一起工作,根据源图层创建基于源图层的精灵 Prefab。

导入选项允许 Unity 生成精灵图集和角色骨架,根据精灵的原始位置和图层顺序排列精灵,显著简化了基于精灵的动画角色的创建。

让我们继续导入玩家角色的艺术作品。在这里,我们将设置源文件,使其使用 PSD 导入器创建演员(基于多层 Photoshop 文件创建的预制件称为演员):

  1. Assets/Sprites/Character目录中创建一个新的文件夹。

  2. PlayerCharacter1.psd导入到新创建的文件夹中。

  3. 选择导入的文件,在检查器窗口中,在下拉列表中将导入器更改为UnityEditor.U2D.PSD.PSDImporter

PSD 导入器提供了两个新选项,一旦选择为导入器,就会作为标签出现:

  • 设置:这是您将设置文件导入属性的地方。设置下的字段与默认纹理导入器类似,增加了层导入(当纹理类型设置为多个)和角色****绑定部分

  • 层管理:这是您可以自定义从 Photoshop 文件中导入哪些层的部分

使用默认的导入设置,我们已经处于良好的状态,可以继续进行玩家角色设置,因为我们将会使用所有层。导入器将保留 Photoshop 中的层位置和排序顺序,以便我们的角色精灵能够正确地排列在我们的演员上。

我们不需要在 Unity 中从构成角色的单个精灵中重新创建玩家角色——手臂、躯干、腿部、头部等等。因此,我们准备好进行下一步——通过添加骨骼来绑定角色。

额外阅读 | Unity 文档

准备和导入艺术作品:docs.unity3d.com/Packages/com.unity.2d.animation@9.0/manual/PreparingArtwork.xhtml.

PSD 导入器检查器属性:docs.unity3d.com/Packages/com.unity.2d.psdimporter@8.0/manual/PSD-importer-properties.xhtml.

绑定演员

项目窗口中仍然选中演员(玩家角色 PSD 文件)的情况下,在检查器窗口中点击打开精灵编辑器按钮。默认视图是代表我们的 Photoshop 层的切片精灵形状。

别担心——正如我说的,我们不需要以分解的方式处理我们的角色,就像图 5**.1所示。:

图 5.1 – 演员精灵图

图 5.1 – 演员精灵图

如果艺术作品以代表 Photoshop 层的单独精灵显示,那么我们需要切换到精灵皮肤编辑器(2D 动画包的一部分),在那里我们将继续设置演员的动画绑定。

精灵编辑器下拉菜单中选择皮肤编辑器——精灵现在应该代表角色,正如原始 Photoshop 源文件中看到的那样。太棒了!

设置演员以进行动画的整个工作流程看起来像这样:

  1. 创建一个骨架:由单个骨骼组成的骨骼结构。

  2. 生成网格几何形状:这将影响精灵的位置、旋转,以及可选的变形。

  3. 调整骨骼影响:更改精灵的属性以指定哪些骨骼或骨骼影响它。

  4. 调整权重:骨骼或骨骼对给定精灵(整个精灵或只是部分)的影响程度。

  5. 预览姿势:测试,测试,再测试关于骨架和精灵骨骼、几何形状和权重分配,直到在移动或旋转骨骼时一切看起来和工作正常;这是一个循环过程。

  6. 反向运动学IK):您可以将 IK 应用于一系列骨骼,以自动计算位置和旋转,这使得动画演员的肢体变得容易得多。

附加阅读 | 反向运动学(IK)

IK 是一种在计算机动画和机器人技术中使用的技巧,用于控制肢体位置和方向。它计算放置末端执行器在给定位置和方向所需的关节角度——例如手和脚这样的末端。这对于创建自然和逼真的人类动作非常有用,尤其是对于复杂的姿势。

您可以在docs.unity3d.com/Manual/InverseKinematics.xhtml了解更多信息。

  1. 动画:为演员的不同游戏状态创建动画,例如空闲、行走、跳跃和攻击。

游戏开发是一种多学科工艺,结合了技术和艺术技能。通过创建具有单个骨骼的骨架来影响单个精灵,结合了这两种技能集。幸运的是,绑定过程是非破坏性的,因此我们可以在任何时间返回并调整任何看起来工作不正常或看起来不正确的部分。

我们在创建骨架时将遵循的一般创建过程是从骨盆骨开始——这个骨骼将代表角色的真实中心。移动或旋转这个骨骼将影响骨架中的所有子骨骼,移动整个演员。

我们将要创建的第一个骨骼可以在图 5.2*中看到,它是位于角色骨盆区域向上指的小红色骨骼。在骨骼层次结构中(如图bone_1所示),它是根骨骼。

让我们创建我们的骨骼!按照以下步骤开发整个角色骨架:

  1. 我们应该已经在皮肤编辑器中;如果不是,从精灵****编辑器下拉菜单中选择它。

  2. 要查看正在创建的骨骼的层次结构(或管理您正在使用的精灵的可见性),请切换到可见性面板(窗口右上角,紧邻还原应用)。

  3. 现在,在骨骼部分(窗口的左侧),选择创建骨骼按钮——您的光标现在应该在尖端有一个红色圆点。

  4. 从角色的骨盆开始,点击以开始创建根骨骼。在第一次点击点稍上方再次点击以创建一个小红色根骨骼——参见图 5.2.2 以了解大致大小和方向。

  5. 通过再次点击来创建一个代表躯干下半部分的骨骼(黄色骨骼)和再次创建一个代表躯干上半部分的骨骼(绿色骨骼);这将使我们能够弯曲角色的躯干(例如,向前或向后弯曲)。

  6. 沿着脊柱向上移动,创建两个额外的骨骼——一个用于脖子的细小骨骼和另一个用于头部的骨骼(参见图 5.2.2)。

  7. 右键点击或按Esc键停止创建骨骼。

我们的角色现在有了脊柱(如果你能原谅这个双关语)。创建肢体将遵循类似的过程,但创建肢体骨骼的关键区别是首先选择将作为肢体骨骼父级的骨骼。按照以下步骤操作:

  1. 在仍然选择创建骨骼的情况下,点击上胸骨(绿色)。你现在应该看到一个半透明的骨骼从刚刚点击的骨骼延伸出来(这表示父级关系;你也可以在可见性窗口的骨骼 层次结构视图中看到这一点)。

  2. 我们将首先为角色的左臂(躯干后面的手臂)创建一个肢体,因此点击左肩关节应该放置骨骼的位置开始创建骨骼。

  3. 注意,为了使放置在其他精灵后面的精灵更容易放置骨骼,你可以使用可见性面板关闭前面精灵的可见性。

  4. 继续点击以创建上臂、下臂和手的骨骼(参见图 5.2.2)。

  5. 右键点击或按Esc键停止创建骨骼。

  6. 通过再次选择父骨骼来继续创建右臂和其他两条腿的其他肢体骨骼——对于腿,将父级设置为骨盆骨(我们从它开始创建绑定的红色根骨骼)。

你的角色的整个骨骼绑定应该看起来与图 5.2.2 中显示的绑定相似:

图 5.2 – 角色绑定和带权重的自动几何

图 5.2 – 角色绑定和带权重的自动几何

如果你希望将骨骼命名得更有意义——以便于你更容易知道你正在处理哪块骨骼——你可以在使用 bone_14foot_right 时轻松地重命名它们(注意,这个动作并不明显)。

创建绑定只是制作一个完全可动画角色的第一步。让我们通过将精灵的网格几何形状分配给绑定的骨骼来继续下一步——随着你继续与绑定工作,了解骨骼影响的精灵网格几何形状将与骨骼颜色相同可能会有所帮助。

生成精灵网格几何形状

执行以下步骤以创建精灵网格几何形状并应用默认骨骼权重,这将影响我们将要开始的精灵:

  1. 首先,在棋盘格区域双击以取消选择所有精灵(请注意,这个动作并不明显)。

  2. 几何形状部分下选择自动几何按钮(窗口的左侧)。

  3. 在窗口底部右侧的几何面板中,确保权重已启用;这将自动生成骨骼权重,与网格几何形状一起,节省了一步。

  4. 最后,点击为所有可见对象生成按钮。

额外阅读 | Unity 文档

2D 动画 | 编辑工具和快捷键:docs.unity3d.com/Packages/com.unity.2d.animation@9.0/manual/SkinEdToolsShortcuts.xhtml

在几何图形上点击为所有可见对象生成按钮后,所有精灵都将从关联的骨骼中获取颜色。你会注意到它看起来不像图 5.2中看到的实色,因为生成的权重分布在相邻骨骼上;相反,着色将是相邻骨骼颜色的渐变。

这是因为重叠的精灵和 Unity 希望在骨骼之间混合精灵变形,这在某些情况下是期望的。我们将在编辑精灵骨骼权重部分中稍后解决此问题,针对胸和颈的精灵。对于其余的精灵,我们只想让一个骨骼影响一个精灵(用实色表示)来表示这个特定的角色。

我们将通过调整每个精灵的骨骼影响来解决这个问题。

调整骨骼影响

我们将要遵循的过程是在精灵的骨影响属性中移除或设置骨骼。这将确保我们的角色精灵在仅通过分配单个骨骼定位或旋转时不会变形或扭曲。

对于不同风格的字符,我们可能非常希望单个精灵被多个骨骼变形(正如我们将在胸和颈上做的那样),但这里构建的风格角色不是这样。

执行以下步骤以为所有精灵(再次,除了胸和颈)分配正确的骨骼影响:

  1. 权重部分下选择骨影响按钮(窗口的左侧)。

  2. 双击一个精灵。例如,在图 5.3中,我们将双击右脚的翅膀

    图 5.3 – 骨影响编辑

  3. 由于我们只想让一个骨骼影响精灵,我们将在列表中选择bone_13(黄色骨骼),然后点击列表下方的小标签上的减号(-)按钮来删除它。

    精灵将变成与剩余分配的骨骼相同的颜色,在这个例子中是绿色,如图 5.3所示:

窗口右下角的面板现在将显示所选精灵及其影响的骨骼。

图 5.3 – 骨影响编辑

注意,如果有多个骨骼需要删除,你可以在点击的同时按住Ctrl/Cmd键,然后点击减号(-)按钮来选择多个骨骼。

这个工作流程是分配单个骨骼到精灵。接下来,我们将学习如何使用多个骨骼来影响精灵的变形。

编辑精灵骨骼权重

我们将使用两个权重工具来分配骨骼并调整每个骨骼对精灵的权重影响:权重滑块权重笔刷

我们将从权重滑块开始调整骨骼的整体影响,然后使用权重笔刷进行任何精细调整。

自动权重生成可能已经做得很好,但让我们检查结果并进行一些调整,从颈部精灵开始:

  1. 双击颈部精灵(请注意,您可以使用鼠标滚轮放大精灵以进行操作)。

  2. 自动权重为具有对颈部精灵影响的三个骨骼分配了权重,因此这个精灵应该有一个颜色渐变来表示三个骨骼的颜色:上躯干(绿色)、颈部(青色)和头部骨骼(蓝色)(见图 5.4)。

  3. 权重部分下选择权重滑块按钮(窗口的左侧)。

  4. 在窗口右下角的权重滑块面板中,您现在可以调整所选骨骼对精灵的影响量;将数量滑块向左或向右拖动以增加或减少影响。

  5. 通过旋转骨骼来预览权重变化的效果,以查看网格如何通过旋转骨骼变形:

    您可以通过简单地点击并拖动骨骼来旋转和移动它们(鼠标光标将显示旋转或移动图标,具体取决于您在骨骼上的悬停位置)。调整权重滑块的数值,直到得到整体令人满意的结果——这个操作更多的是艺术性的,而不是技术性的:

图 5.4 – 使用权重笔刷编辑权重

图 5.4 – 使用权重笔刷编辑权重

您可能会发现仅使用权重滑块难以获得良好的结果。在这种情况下,继续使用权重笔刷对任何问题区域进行精细调整,以调整骨骼的影响。

  1. 在精灵仍然被选中的情况下,在窗口左侧的权重部分下选择权重笔刷按钮。

  2. 点击您想要绘制权重的骨骼。

  3. 您现在可以在窗口右下角的权重笔刷面板中调整权重笔刷的属性,或者开始绘制权重。当您绘制时,精灵网格几何形状将实时更新。

  4. 通过将鼠标悬停在骨骼上以显示旋转图标来继续旋转骨骼。然后,点击并拖动以旋转骨骼以测试权重绘制并调整它,直到您达到期望的结果。

注意,您可以使用Ctrl/Cmd + Z撤销骨骼旋转。如果您已经进行了许多骨骼旋转并且不确定应用了多少影响,那么您可以使用姿态部分下的重置姿态按钮(窗口的左上角)重新开始。

继续重复此过程为演员的躯干。一旦完成躯干,这可能是考虑保存您的工作的绝佳时机。使用右侧工具栏上的应用按钮。

当你对骨骼权重相对满意并想看看事情进展如何时,你可以进行一些姿势测试!进行一些姿势测试可以让你测试运动范围并查看精灵是否仅与影响精灵权重的正确骨骼相关联,而不产生不希望的扭曲。这为你提供了关于角色在动画中可能看起来如何的第一个提示。

图 5**.5表示演员的一个测试姿势示例:

图 5.5 – 演员的姿势测试

图 5.5 – 演员的姿势测试

能够摆出我们的角色姿势是很好的,但对于肢体来说,特别是通过单独旋转所有骨骼来获得良好的结果可能既耗时又具有挑战性。幸运的是,有一种更好的方法——使用IK

设置反向运动学(IK)

我们现在已经完成了与皮肤编辑器的工作,并将继续在场景中与演员一起工作以添加 IK。因此,让我们从演员创建一个 Prefab 作为我们的玩家角色:

  1. PlayerCharacter1资产从项目窗口拖动到层次结构窗口或场景视图(可选,您可以创建一个新的空场景进行工作)。

  2. 将演员附加到一个新的空 GameObject 上,并将其命名为Player

  3. 然后,将 GameObject 拖动到Assets/Prefabs文件夹中创建 Prefab。

由于我们将使用 IK 设置来定位演员进行动画,因此我们希望更改演员的 rig,以便更好地摆姿势——特别是腿部。我们可以在最初创建演员的 rig 时这样做,但在实现 IK 时展示这种更改的原因更容易,并且这表明我们可以在任何时间返回并修改骨骼结构,并在必要时进行纠正。

接下来,我们将使用与创建演员完整骨骼 rig 相同的创建骨骼工作流程在 rig 中添加一个新的根骨骼:

  1. 在演员的脚部开始一个新的骨骼,并将其命名为root_bone

  2. 将颜色更改为灰色(仅作为提示,表示没有精灵受到其影响)。

  3. 现在,点击并拖动现有的 rig(bone_1)以将其父级设置为新的根骨骼,如图图 5**.6所示:

图 5.6 – 为 IK 添加 root_bone 以及父级现有 rig

图 5.6 – 为 IK 添加 root_bone 以及父级现有 rig

对我耐心一点——当我们为腿部添加IK 肢体求解器时,所有这些都会变得有道理。

添加 IK 求解器

我们将为手臂和腿部创建 IK 约束肢体,从演员前面的右臂(演员前面的手臂)开始。

首先,让我们添加充当我们的 IK 效应器(IK 求解器解决的变换)的 GameObject,并与肢体求解器的目标协同工作:

  1. 选择角色躯干前手臂的尺骨——在我的例子中是bone_7

  2. 检查器窗口中右键单击它,然后单击创建空对象以添加子 GameObject——这将使新对象成为手骨的兄弟对象。

  3. 将其重命名为IK Effector,然后将其放置在前臂骨的尖端。

  4. 使用位置工具 gizmo 的红箭头,将其向下拖动,稍微超出手部。将效应器放置在精灵之外将使它们更明显,更容易点击来操作(这一步完全是个人偏好;你也可以将其留在前臂骨的尖端)。

  5. 对其他手臂(演员后面的手臂)和两条腿重复这些步骤。

  6. 在我们的效应器就位后,将IK Manager 2D组件添加到根骨上,并通过在选项卡中单击加号(+)按钮并选择肢体来开始为肢体添加IK 求解器

我们进行这个选择是因为它专门用于摆姿势的关节,特别是手臂和腿部的关节(也称为双骨求解器)。

额外阅读 | Unity 文档

IK 求解器:docs.unity3d.com/Packages/com.unity.2d.animation@9.0/manual/2DIK.xhtml#ik-solvers

添加了LimbSolver2D组件。

执行以下步骤以完成前手臂的 IK 肢体设置:

  1. New LimbSovler2D重命名为Front Arm LimbSolver2D并保持选中状态,以便在检查器窗口中可见LimbSolver2D组件。

  2. 点击并拖动前臂的IK Effector对象到LimbSolver2D组件。

  3. 一旦这个用于肢体求解器的_Target子 GameObject 现在可以在场景视图中操纵其位置来摆姿势肢体。

  4. 点击并拖动现在出现在场景视图目标变换上的圆形图标来测试 IK 肢体。

  5. 注意,翻转的默认值是禁用的,这可能对目标的创建方式工作得很好,但如果你在拖动目标时肢体向后弯曲,则启用翻转以解决这个问题。

图 5**.7展示了这些步骤的结果:

图 5.7 – IK 效应器和肢体求解器设置

图 5.7 – IK 效应器和肢体求解器设置

在动画过程中,Front Arm LimbSolver2D_Target对象的变换位置是关键帧。

重要提示

一旦设置了 IK 求解器,我们就不能再使用PSD 导入器设置中的每单位像素值来缩放玩家角色——这是由于 IK 目标基于变换局部位置,当精灵的 PPU 缩放时,它们不会更新。相反,更改父对象PlayerCharacter1上的变换缩放。

现在你可以在需要为蒙皮演员骨架创建 IK 肢体时遵循这些步骤。现在就为后手臂和两条腿创建 IK 肢体。有了这些,我们就准备好动画了!

提醒

不要忘记将更改应用到Player Prefab!

其他资源 | 2D 角色

另一个在创建有骨架和动画的 2D 角色时取得先机的选项是使用 Unity 在 Asset Store 上提供的现有示例角色,这些角色来自 Dagon Crashers 或 Lost Crypt 样本项目:

Dragon Crashers:assetstore.unity.com/packages/essentials/tutorial-projects/dragon-crashers-2d-sample-project-190721.

失落的密室:assetstore.unity.com/packages/essentials/tutorial-projects/lost-crypt-2d-sample-project-158673.

创建演员动画

演员动画是将演员的骨骼在不同时间在动画窗口的时间轴上重新定位或旋转的过程,这被记录为关键帧,并包含变换数据。在第四章中,当我们对藤蔓进行动画制作时,我们执行了这个过程的简化版本。

让我们开始对演员进行动画制作:

  1. 首先,我们将通过转到窗口 | 动画 | 动画来打开动画窗口,制作一个空闲动画。

  2. 在层次结构中选择PlayerCharacter1(不是根Player对象;我们想要动画化的是图形,这可以在以后用可能没有相同骨骼结构的不同演员替换)并点击创建按钮。

  3. 这将创建一个动画资产文件(.anim),我们将命名为Player Idle并将其保存到Assets/Animation文件夹中。

现在,再次戴上你的动画师帽子,因为现在是时候进行动画制作了!

在使用 IK 肢体进行动画制作时,首先要注意的是为什么我们在设置逆运动学(IK)部分添加了新的根骨骼。选择bone_1,我们的原始根骨骼)。

你会注意到在骨骼(基座)的较大端出现了一个方形轮廓,将鼠标指针悬停在方形内部将显示移动图标。将鼠标指针悬停在方形之外的骨骼部分将显示旋转图标。在方形内点击并按住左键,然后慢慢向下拖动。

整个演员将开始向下移动,除了脚部——它们将坚定地保持在原地。没有 IK 和新增的根骨骼,这是不可能的。太好了!

在你开始创建空闲动画之前,还有一点需要注意关于 IK 肢体:你可以通过两种方式定位肢体:

  • 通过在场景视图中点击并拖动 IK 圆形目标

  • 通过在层次结构窗口中选择… LimbSolver2D_Target对象,并在场景视图中使用移动工具(或在检查器窗口中输入变换值)。

不要尝试在 IK 链中的单个骨骼、IK 效应对象或具有 LimbSolver2D 组件的对象上创建关键帧!通过以下步骤创建你的站立动画:

  1. 点击红色记录按钮(动画窗口;关键帧记录模式)以开始记录关键帧(每当骨骼旋转或重新定位时,都会创建一个关键点)。

  2. 将你的演员摆放到一个简单站立不动的起始位置。

  3. 将时间轴向前拖动约 1.5 秒,然后通过降低躯干弯曲到膝盖、弯曲手臂和轻微倾斜头部来制作第二个姿势——类似这样。

  4. 现在,为了在这两个姿势之间进行动画,点击并拖动一个矩形围绕起始帧的关键点——按 Ctrl/Cmd + C 复制这些关键点。

  5. 将时间轴拖到 3 秒处,然后按 Ctrl/Cmd + V 粘贴这些关键点。

  6. 再次点击红色记录按钮以停止记录,然后使用 播放 按钮检查结果。

你知道该怎么做——动画更多的是艺术而不是技术,所以调整姿势和时机,直到你得到满意的结果。图 5.8* 展示了一个完成的演员站立动画:

图 5.8 – 演员关键帧站立动画

图 5.8 – 演员关键帧站立动画

注意,在 图 5.8* 中,我已经将 IK 求解器(root_bone 对象上的 IKManager2D 组件)的颜色更改,以给肢体骨骼赋予自己的颜色——这是组织构成演员的不同组件的另一种视觉方式,使其更容易处理。

在将基本站立动画添加到角色后,让我们看看如何通过玩家输入来驱动动画并将动作应用到我们的角色上。

使用输入动作图

我们将继续使用新安装的 Input System 包:

  1. 通过访问 窗口 | 包管理器 来打开 包管理器

  2. 默认的 位置是 项目内,所以如果你在 包 – Unity 部分的列表中看到 输入系统,那么我们就准备好了!

  3. 如果找不到 输入系统,则将 下拉菜单切换到 Unity 注册表,从列表中选择 输入系统,然后点击 安装(窗口的右下角)。参见 第二章新输入系统 节以获取复习。

对于收集游戏,我们直接从 输入设备 接收输入;这次,我们将使用 输入动作 方法。为此,我们需要一个 动作 映射 资产。

幸运的是,为玩家角色创建默认动作图相当简单——我们需要的键盘和游戏控制器的大部分设备输入已经准备好了!

按照以下步骤完成此操作:

  1. 魔法是通过 Player Input 组件实现的。因此,通过在 检查器 窗口中使用 添加组件 按钮将其添加到 Player 预制件的根对象。

重要提示

您可以通过将其添加到场景或通过在 项目 窗口中双击 Prefab 直接在它上工作来使用 玩家 Prefab。

  1. 一旦添加了 Player Input 组件,点击下面的截图所示的 创建动作… 按钮。这将创建一组默认的 输入动作映射输入动作输入绑定

图 5.9 – 玩家输入 | 创建动作

图 5.9 – 玩家输入 | 创建动作

  1. 您将被提示保存新的 .inputactions 资产。选择 Assets/Settings 文件夹,并将其命名为 Player Input。然后,该资产将与 Player Input 组件连接,并打开 输入 动作编辑器

在这里,我们可以看到为玩家动作映射预先填充的所有动作,如图 图 5.10 所示 – 我们所需的一切以及更多!

图 5.10 – 玩家输入动作映射(输入动作)

图 5.10 – 玩家输入动作映射(输入动作)

阅读更多 | Unity 文档

输入系统:docs.unity3d.com/Packages/com.unity.inputsystem%401.4/manual/QuickStartGuide.xhtml.

编辑输入动作资产:docs.unity3d.com/Packages/com.unity.inputsystem%401.4/manual/ActionAssets.xhtml#editing-input-actionassets.

Player Input 组件也为我们提供了响应玩家输入所需的一切。图 5.10SendMessage() 设置在 Player Input 组件所属的 GameObject 上,调用添加到 GameObject 上的每个组件 (MonoBehaviour) 的名称。如图 图 5.10 所示,相关的方法名称将按照在 行为 字段下方方框中列出的顺序被调用。

阅读更多 | Unity 文档

通知行为:docs.unity3d.com/Packages/com.unity.inputsystem%401.4/manual/Components.xhtml#notification-behaviors.

为了简化起见,我们将坚持使用 SendMessage(),因为它需要使用 反射,所以速度较慢。

反射 | C#

SendMessage() 严重依赖 反射 来在运行时找到要调用的方法。反射 慢(据说比直接调用方法慢 3 倍),因为它需要托管代码读取(搜索)其元数据以找到程序集。

在设置好输入并知道如何响应我们定义的输入后,我们就可以编写玩家控制器代码了!

使用玩家控制器脚本移动玩家

我们需要的不只是输入来移动玩家角色在关卡中的位置。我们还需要配置Player对象与物理系统协同工作,以便它与我们的关卡地面、平台和交互触发体积交互,并应用移动力。

在参考图 5.11的同时,配置Player预制件的根 GameObject 以下组件:

  1. 通过按1添加Rigidbody2D组件。我们将为玩家的移动添加一些阻力,以提供额外的移动约束,帮助玩家感觉更加脚踏实地,而不是太飘。

  2. 约束 | 冻结旋转 Z已启用:我们希望防止玩家旋转,并使他们始终垂直站立(垂直于地面平面)。

  • 通过按添加组件按钮添加CapsuleCollider2D。使用编辑边界体积按钮并修改碰撞器的形状,直到它包围了演员。

    这代表玩家的击中区域,随着你进行游戏测试,可能需要调整:

    图 5.11 – 玩家物理设置

图 5.11 – 玩家物理设置

仅负责读取设备输入并调用分配的动作的Player Input组件。

处理玩家输入

尽管我们可以在玩家控制器脚本部分的第二章中使用设备输入,就像我们做的那样,但理解使用输入动作映射的价值是至关重要的。以下是一些示例:

  • 可以同时配置多个设备输入,而无需更改输入处理代码。

  • 可以根据当前所需的输入操作切换到不同的动作映射。

  • 可以在不更改输入处理代码的情况下实现按键重绑定。

带着这些知识,你可以确定针对不同用例的最佳方法(你是在原型设计吗?你是在为商业游戏设计一个灵活的解决方案吗?)。

之前,我们在Update()循环中直接对输入设备上的isPressed键状态进行连续轮询,以控制玩家移动,如下所示:

void Update()
{
    if (keyboard.aKey.isPressed
        || keyboard.leftArrowKey.isPressed)
            _moveHorizontal = -1.0f;
    …
}

由于我们现在从SendMessage()接收输入处理,我们需要做一些更改。

任何有输入时都会调用SendMessage()。输入不是连续轮询的!它每次输入操作发生时都表现得像事件,这意味着对于移动操作,当按键按下和释放时,都会调用OnMove()

调用用于定义输入操作的SendMessage()方法名称。

移动的情况下,SendMessage()还将传递一个表示Vector2InputValue参数,它包含水平和垂直输入值。

我们将使用水平(X 轴)值来确定是否按下了移动左右按钮,因为我们希望在按钮被按下时(或握住游戏控制器棒子朝某个方向时)移动我们的玩家。

我们的move方法(OnMove())将在按下按钮时,使用Vector2 X 轴值来表示水平输入,当输入为-1时表示向左,输入为1时表示向右,而当按钮释放时,值为0

现在我们有了所有这些信息,我们可以开始编写我们的输入处理代码了!

创建 PlayerController 脚本

让我们从在Assets/Scripts文件夹中创建一个名为PlayerController的新 C#脚本开始。

我们现在可以添加我们的移动动作输入处理方法OnMove(),并将InputValue参数的Vector2值分配给名为_movementInput的私有成员变量。我们将在稍后使用它来计算应用于玩家Rigidbody2D速度的移动。

这是我们初始代码的样子:

public class PlayerController : MonoBehaviour
{
    private Vector2 _movementInput;
    void OnMove(InputValue value)
    {
        var move = value.Get<Vector2>();
        _movementInput = (move.x != 0f)
            ? new Vector2(move.x, 0f) : Vector2.zero;
    }
}

我们使用value参数变量的Get()方法读取输入值。我们在这里只对水平移动感兴趣,并将_movementInput值根据输入状态设置:按下或释放。

因此,如果move.x等于0,那么按钮键被释放(可以将其视为IsPressed == false)。然后,使用三元运算符(?:),我们将Vector2.zero赋值以确保忽略任何/所有输入。

move.x不等于零时,我们有一个按钮按键输入值,它表示方向(-11),并将其分配给_movementInput,使用一个新的Vector2表示方向,没有垂直值:new Vector2(move.x, 0f)

使用这种方法,我们仍然可以稍后响应垂直输入,例如喷射背包的输入!现在我们有了方向,我们可以应用力(以速度的形式)来移动玩家角色。

让我们添加以下代码来处理这种情况:

private Rigidbody2D _rb;
void Awake() => _rb = GetComponent<Rigidbody2D>();
void FixedUpdate() => UpdateVelocity();
private void UpdateVelocity()
{
    var velocity = _rb.velocity;
    velocity += Time.fixedDeltaTime * _movementInput;
    _rb.velocity = velocity;
}

让我们逐项分析这段代码:

  • _rb变量:这个私有成员变量将保存我们之前添加的Rigidbody2D组件的引用,并且我们将在这里设置速度以移动玩家。我们曾经将其设置为public字段,可以在检查器窗口中分配,但这次我们将保持它为私有以封装它。

  • Awake()方法(表达式体):由于我们的Rigidbody2D变量是私有的,我们只使用这个 Unity 消息事件在运行时使用GetComponent()获取这个对象上的Rigidbody2D组件的引用。

额外阅读 | Unity 文档

GameObject.GetComponent: docs.unity3d.com/2022.3/Documentation/ScriptReference/GameObject.GetComponent.xhtml.

  • FixedUpdate()方法(表达式体):这是 Unity 消息事件,在每次UpdateVelocity()方法调用时都会被调用。

  • UpdateVelocity()方法:此方法将计算并应用速度向量到玩家的Rigidbody2D组件。

    • var velocity:这保存了我们将要修改并重新分配的Rigidbody2D组件当前速度的值。

    • 速度计算:这会将移动输入向量乘以fixedDeltaTime后添加到速度变量中。乘以fixedDeltaTime确保帧率无关性。

    • _rb.velocity赋值:在这里,我们只是将计算出的速度重新赋值给Rigidbody2D组件,从而移动玩家。记住,FixedUpdate应该始终用于执行与物理相关的代码,特别是对Rigidbody组件应用力时!

作者注记

我阅读了关于是否需要在FixedUpdate中应用deltaTime的讨论,因为它的运行帧率是恒定的。无论如何,我倾向于知道FixedUpdate的间隔始终相对于游戏时间。此外,Unity 提供的示例代码总是包含deltaTime,保持一致地包含它将确保我们在所有设备上实现帧率无关性。

额外阅读 | Unity 文档

Time.fixedDeltaTimedocs.unity3d.com/2022.3/Documentation/ScriptReference/Time-fixedDeltaTime.xhtml

时间和帧率管理:docs.unity3d.com/2022.3/Documentation/Manual/TimeFrameManagement.xhtml

乘以零总是得到零,所以Vector2.zero_movementInput一起将速度值赋为零,停止玩家的移动。

我们已经给玩家应用了速度,但不要过于兴奋。这很可能会对移动玩家没有净效果,因为速度值太小!让我们通过给速度应用一个加速度乘数来解决这个问题。

UpdateVelocity()方法中添加以下成员变量,并按照以下所示进行更改:

[Header("Movement")]
[SerializeField] private float _acceleration = 0.0f;
[SerializeField] private float _speedMax = 0.0f;
private void UpdateVelocity()
{
    var velocity = _rb.velocity;
    velocity += _acceleration * Time.fixedDeltaTime
        * _movementInput;
    velocity.x = Mathf.Clamp(velocity.x,
        -_speedMax, _speedMax);
    _rb.velocity = velocity;
}

让我们分解这些更改:

  • 我们添加了两个新变量_acceleration_speedMax,我们将它们设置为私有,但通过[SerializeField]属性将其公开,我们告诉 Unity 序列化它并使其可用于赋值,_acceleration:这应用于我们的整体速度量(力),以确定我们达到最大速度值所需的时间。

  • _speedMax:玩家角色移动的最大速度。

  • UpdateVelocity()方法中,我们进行了以下更改:

    • 我们通过乘以_acceleration值修改了增加速度向量的行。

    • 我们已经确保使用Mathf.Clamp()方法将velocity.x限制在最大速度值。

额外阅读 | Unity 文档

SerializeFielddocs.unity3d.com/2022.3/Documentation/ScriptReference/SerializeField.xhtml

现在,您可以将PlayerController脚本添加到Player预制件的根目录,将玩家添加到一个带有地面的场景中(别忘了添加碰撞器),并进行游戏测试。您可以使用AD键、左右箭头键或游戏控制器的左摇杆来移动玩家。您可以调整加速度、最大速度以及Rigidbody2D属性的质量和线性阻力值。

PlayerController.cs 代码

要查看PlayerController类的完整代码,请访问以下 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch5/Unity%20Project/Assets/Scripts

游戏测试意味着反复调整这些移动变量,直到您觉得玩家控制感觉正确。我目前将加速度设置为30,最大速度设置为8。随着您构建游戏,您将发现自己需要多次返回并调整这些值 – 游戏感觉对玩家满意度至关重要。

物理材质

在进行游戏测试时,您可能会注意到,当您释放按键(或控制器摇杆)时,玩家可能会滑动一小段时间。这可以通过在释放按键时将玩家冻结在原地来纠正,但我认为更好的方法是改变玩家当前站立地面的属性。通过调整地面的摩擦力,我们可以提供不同类型的地面,包括冰面,当玩家释放输入时,玩家会在冰面上滑动。

我们将在地面上添加一种物理材质以提供更高的默认摩擦力。这样,我们就可以在游戏后期拥有摩擦力较小的区域,以改变游戏玩法等。

按照以下步骤创建一个新的物理材质并将其应用于您关卡中的地面对象:

  1. Assets/Settings文件夹中创建一个新的文件夹,并将其命名为Physics Materials

  2. 在新文件夹内右键单击,选择Default Friction

  3. 选择新创建的物理材质,在10(这,像所有其他游戏变量一样,可能会在游戏测试中改变)。

  4. 现在,通过拖动它到Collider组件中,将物理材质分配给您的地面对象,如图所示:

图 5.12 – 将物理材质分配给地面碰撞器

图 5.12 – 将物理材质分配给地面碰撞器

继续进行游戏测试并调整这些值。

接下来,我们将通过添加跑步动画并使玩家始终面向移动方向来完成初始玩家控制器的设置。

使用 Mecanim 动画角色

创建演员动画部分,当我们为演员创建空闲动画时,我们添加了PlayerCharacter1对象。Animator组件允许我们将动画分配给场景视图中的 GameObject – 它是负责控制 Unity 的Mecanim动画系统(在演员或任何其他你想要动画化的 GameObject 上)的接口。

一个Animator组件。这个控制器资产定义了使用哪些动画以及何时以及如何过渡和混合它们。

额外阅读 | Unity 文档

Animator: docs.unity3d.com/2022.3/Documentation/Manual/class-Animator.xhtml.

要在Animator组件中继续设置,我们需要第二个动画,当我们的玩家角色移动或,比如说,跑步时过渡到。

按照以下步骤为演员创建额外的动画:

  1. 场景视图中打开PlayerCharacter1对象。

  2. 现在,在动画窗口中,点击动画剪辑列表下拉菜单(窗口左上角,位于播放控制下方),然后点击创建新剪辑…,如图所示:

图 5.13 – 创建新剪辑…

图 5.13 – 创建新剪辑…

  1. Assets/Animation文件夹中将新剪辑保存为Player Run

  2. 动画跑步周期。你做到了!

说真的,动画是一项你需要逐渐掌握的技能。虽然我可以创建动画,但我仍然需要在这个领域进行大量练习才能变得更好(我可能会在本书的最终项目文件中使用熟练的角色动画师,这些动画将可供你使用和学习)。

额外资源 | 2D 角色动画

另一个快速入门角色动画的选项是与 Unity 在 Asset Store 上提供的现有示例角色一起工作,这些角色来自龙之冲击者或失落的密室示例项目。

龙之冲击者: assetstore.unity.com/packages/essentials/tutorial-projects/dragon-crashers-2d-sample-project-190721.

失落的密室: assetstore.unity.com/packages/essentials/tutorial-projects/lost-crypt-2d-sample-project-158673.

现在,我们可以继续设置从空闲到 跑步动画的过渡。

动画状态过渡

要在PlayerCharacter1对象上打开Animator组件或双击项目窗口中Assets/Animation文件夹中的PlayerCharacter1资产。

在打开Animator窗口(你可能希望将其停靠以便仍然可以清楚地看到所有编辑器窗口)时,你应该看到默认状态(任何状态进入退出)以及当前在演员上的动画剪辑状态(玩家空闲玩家跑步):

图 5.14 – 使用 Mecanim 的角色动画过渡

图 5.14 – 使用 Mecanim 的角色动画过渡

注意,你可以使用鼠标滚轮在Animator窗口中导航以放大/缩小视图,并使用Alt + 左键按钮/Option + 左键按钮快捷键来平移视图。点击并拖动任何状态节点以有序地重新定位它。

额外阅读 | Unity 文档

Animator窗口:docs.unity3d.com/2022.3/Documentation/Manual/AnimatorWindow.xhtml

按照以下步骤从玩家空闲状态转换为玩家跑步状态创建一个过渡

  1. 首先,通过执行以下步骤创建一个布尔参数来表示我们是否在跑步:

    1. 首先,选择Running – 我们需要一个布尔参数来保存玩家是否跑步的状态(true表示跑步,false表示不跑步)。
  2. 右键单击玩家空闲节点,从弹出菜单中选择创建过渡

  3. 过渡箭头附加到鼠标指针上,然后单击玩家跑步节点。

  4. 现在,单击新创建的过渡线(图 5.14中的B)以在检查器窗口中查看其属性。

  5. true;意味着,当true时,过渡到指定的节点。

  6. 返回到步骤 2并重复指令以将过渡回false

  7. 最后一步是禁用两个过渡的Has Exit Time图 5.14中的D)。我们希望状态在条件满足时立即退出(动画停止),而不是等待动画播放完毕。

由于我们还没有将动画状态连接到玩家输入,你可以通过进入播放模式并同时在游戏视图Animator窗口中切换Running参数来手动测试以确保过渡工作良好。每次切换参数时,玩家应该从空闲动画切换到跑步动画,然后再切换回来。太棒了!

现在,让我们根据玩家的输入来创建这个过渡。

使用代码更改动画状态

我们首先需要一个变量来保存对Animator组件的引用(在Running参数中可分配,当玩家停止移动角色时,我们将Running参数设置为false。我们将迅速完成这项工作,只用一行代码就能完成!)

将以下代码添加到PlayerController脚本中:

[Header("Actor")]
[SerializeField] private Animator _animator;
private void UpdateVelocity()
{
    …
    // Update animator.
    _animator.SetBool("Running", _movementInput.x != 0f);
}

_animator变量声明是我们对Player对象中Animator组件的引用。我们将其标记为private,以便没有其他类可以修改它,但用[SerializeField]属性装饰,以便在Inspector窗口中进行序列化和赋值。

UpdateVelocity()方法中,我们添加了对_animator.SetBool()的调用,并传入Running字符串以标识我们想要设置的布尔参数。我们将传入_movementInput.x != 0f表达式的评估结果作为布尔值。

这意味着如果我们的玩家正在接收移动输入(值不为零),那么我们正在移动(Running等于true);否则,(即if _movementInput.x == 0f),我们正静止不动(Running等于false)。

一旦添加并保存此代码,请使用Editor视图中的HierarchyInspector窗口将子对象PlayerCharacter1拖动到PlayerController组件的Animator字段。

进入Play Mode,左右移动玩家以测试动画是否从空闲状态过渡到奔跑状态并返回。你可能会注意到,尽管动画正在过渡,但在向左移动时,演员的朝向是错误的!

我们将在下一部分处理完玩家移动。

翻转玩家角色

PlayerController脚本中确保玩家始终面向移动方向。

打开PlayerController脚本,在OnMove()方法的末尾添加对新方法UpdateDirection的调用。使用以下代码创建UpdateDirection()方法:

void OnMove(InputValue value)
{
    …
    UpdateDirection();
}
private void UpdateDirection()
{
    if (_movementInput.x != 0f)
    {
        transform.localScale = Vector3.one;
        if (_movementInput.x < 0f)
            transform.localScale = new
                Vector3(-1f, 1f, 1f);
    }
}

我们用来翻转玩家角色朝向的简单技巧是将玩家对象的TransformlocalScale X值设置为-1,如果移动输入值小于零(即表示向左移动的输入)。

UpdateDirection()中的第一个if语句检查是否有移动玩家的输入。记住,零值表示玩家释放了方向键(或游戏控制器摇杆)。

如果移动的输入水平值不为零,我们首先为面向右侧设置一个默认缩放(X值为1)。如果移动输入是面向左侧(X值为-1),则将localScale设置为具有 X 轴值为-1Vector3。简单易行。

附加活动

基于本章学到的处理玩家输入的技术,通过Rigidbody2D组件移动玩家,并使用AnimatorPlayerController脚本中为玩家动画添加功能,使玩家角色能够跳跃。不妨跳起来,大胆地跳吧!

我并不是真的让你在这里卡住。如果你在寻找解决问题的救命稻草,你可以通过访问以下 GitHub 链接查看完成这个跳跃奖励活动的说明:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch5/Unity%20Project/Assets/Scripts/Jumping

在本节中,你学习了如何创建动画并将其应用到玩家身上,以及如何使用代码进行动画转换和状态改变,所有这些都是在使用 Mecanim 的过程中完成的。我们最后学习了如何翻转玩家角色的面向方向。

摘要

本章带我们了解了如何完整设置一个动画 2D 玩家角色,包括通过 PSD 导入器导入艺术资源,设置可动画的绑定,配置 IK 求解器,以及使用 Mecanim 为玩家创建和应用动画。

我们继续通过使用新的输入系统中的 Input Action Map 资产添加玩家输入的移动能力,编写一个简单的玩家控制器脚本,处理输入,并根据当前玩家动作更改动画,同样使用 Mecanim。

在下一章中,我们将为玩家添加武器,以便他们能够高效地射击投射物。

第六章:Unity 2022 中对象池的介绍

第五章中,我们导入并准备了艺术作品以用于额外的 2D 动画工具,这使游戏栩栩如生。我们还使用新的输入系统通过输入动作映射处理玩家输入——而不是直接读取设备输入——并创建了一个PlayerController脚本来移动玩家。

我们深入研究了 Mecanim,学习了如何在动画之间进行转换,并从代码中驱动动画状态变化。

在本章中,我们将介绍对象池,同时我们将使用这种优化模式为玩家的射击机制,我们将使用 Unity 的对象池 API 来实现这一点。对象池软件设计将基于池化玩家射击模型 UML 图。

在本章中,我们将涵盖以下主要主题:

  • 对象池模式

  • 池化玩家射击模型

到本章结束时,你将能够为远程武器创建一个优化的射击机制。

技术要求

为了与书中为项目创建的相同艺术作品一起学习本章,请从以下 GitHub 链接下载资源:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch6

为了使用你自己的艺术作品进行学习,你需要使用 Adobe Photoshop 或能够导出分层 Photoshop PSD/PSB 文件的图形程序(例如 Gimp、MediBang Paint、Krita 和 Affinity Photo)创建类似的艺术作品。

你可以在 GitHub 上下载完整的项目github.com/PacktPublishing/Unity-2022-by-Example

对象池模式

对象池设计模式是一种创建型抽象工厂设计模式,它使用栈来存储初始化的对象实例集合。它非常适合在需要大量需要生成或快速创建和销毁的对象的情况下使用。

由于我们将从武器中射击投射物——玩家可以以高频率执行此操作——因此这是一个应用对象池的绝佳地方,因为反复实例化和销毁对象会带来高昂的成本。在这种情况下,对象池提供了一种优化 CPU、内存和垃圾回收GC)的方法。

而不是每次玩家需要射击时都直接创建一个新的投射物对象,我们将通过从池中的对象请求来重用已经实例化的投射物对象。因此,对象池提供了请求(获取)和返回(释放)对象的方法。例如,对于 10 个投射物对象的池,当玩家射击时,我们将一次从池中获取一个,并在它过期时(例如,击中某个物体)返回被射击的投射物。

如果您还记得我们游戏设计文档GDD)中的第四章,特别是表 4.1,我们定义了射击能力,因此我们将使用对象池以高效和优化的方式实现这一机制,使用 Unity 的新对象 API

Unity 对象池 API

Unity 为引擎添加了一个新的命名空间 – UnityEngine.Pool – 其中包含几个新类以实现对象池模式。对于我们需要射击子弹的武器,我们将使用ObjectPool<T0>类。

额外阅读 | Unity 文档

ObjectPool: docs.unity3d.com/2022.3/Documentation/ScriptReference/Pool.ObjectPool_1.xhtml

以下是我们工作时需要执行的一系列必要操作:

  • Creating(实例化):在池中创建一个新的对象实例使其可用。

  • Getting(请求):从池中检索一个可用的对象实例(如果需要更多,则创建并返回一个新的实例)。

  • Releasing(归还):将一个活动的对象实例放回池中以供重用时使用。

  • Destroying(移除):如果实例化对象的数量超过其大小限制,则完全从池中移除。

幸运的是,或者说是设计上的考虑,ObjectPool<T0>类提供了我们所需要的一切,例如创建池以及从池中取回和归还项目。现在,让我们为我们的弹道创建一个新的对象池。

创建新的对象池

让我们看看以下代码,它创建了一个新的BulletPrefab弹道对象池(类型为ProjectileBase;关于这一点,我们将在创建池化玩家射击 模型部分稍后详细说明):

private void Start()
{
    _poolProjectiles = new ObjectPool<ProjectileBase>(
        CreatePooledItem, OnGetFromPool,
        OnReturnToPool, OnDestroyPoolItem,
        collectionCheck: false,
        defaultCapacity: 10,
        maxSize: 25);
    ProjectileBase CreatePooledItem() =>
        Instantiate(_weapon1.BulletPrefab);
    void OnGetFromPool(ProjectileBase projectile) =>
        projectile.gameObject.SetActive(true);
    void OnReturnToPool (ProjectileBase projectile) =>
        projectile.gameObject.SetActive(false);
    void OnDestroyPoolItem(ProjectileBase projectile) =>
        Destroy(projectile.gameObject);
}

在前面的代码中,我们可以看到一个方法(在这种情况下是一个本地函数)被声明,对应于我们之前列出的每个必要的ObjectPool参数。

本地函数(C#)

新的ObjectPool创建代码使用本地函数而不是常见的使用 lambda(匿名委托)的方法,这样我们就可以避免不必要的内存分配。我们通过在已存在的方法体内声明一个方法来创建一个本地函数;这也限制了本地函数的作用域,使其只能在该方法内部被调用,这促进了封装,而不是使用私有成员方法(我们不需要在设置对象池的作用域之外使用这些方法,并且它们只需要被调用一次)。

当使用 lambda 时,必须创建一个委托,如果使用本地函数,这将是一个不必要的分配。避免捕获局部变量的分配,因为本地函数实际上只是函数;不需要委托。此外,调用本地函数的成本也更低,如果由编译器内联(消除调用链接开销),性能还可以进一步提高。

此外,局部函数看起来更好!它们提供了更好的代码可读性和详尽的参数名称——一个 lambda 匿名委托会隐藏每个参数类型!(你能看出我有点偏心吗?)

这里有一些关于此主题的额外阅读材料:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/local-functions

这里有一些关于在 C# 7 中添加局部函数时的有趣设计注释:github.com/dotnet/roslyn/issues/3911

我在 Start() 方法中声明了以下局部函数:

  • CreatePooledItem: 当需要新项目时,这将实例化一个类型为 ProjectileBase 的对象。这是一个我们存储在玩家武器上的子弹 Prefab。

  • OnGetFromPool: 我们将使用 _poolProjectiles.Get() 来返回一个 ProjectileBase 对象实例,而此方法将调用 gameObject.SetActive(true) 来启用对象以供使用。

  • OnReturnToPool: 调用 _poolProjectiles.Release(projectile) 将在传入的对象实例上执行 projectile.gameObject.SetActive(false),确保它在池中等待被检索时处于非活动(禁用)状态。

  • OnDestroyPoolItem: 当从池中移除项目时调用 Destroy(projectile.gameObject) 意味着该对象将不再存在于场景中。

为了阐明前面的某些操作,实例化 意味着一个对象被创建并存在于场景中。当实例化对象的活动状态为 SetActive(true) 时,它在场景中可见,并且代码将被执行。

将 GameObject 设置为 SetActive(false) 将确保它不在场景中显示,并且对于每个组件,Update() 方法将不再被调用。

影响对象池的额外参数

除了前面的操作方法之外,我们还有三个影响池功能的额外参数。它们如下:

  • collectionCheck: 如果我们将此参数设置为 false,我们可以节省一些 CPU 循环,因为它不会检查对象是否已经返回到池中(对此值要小心,因为它会在尝试释放池中已有的项目时引发错误)。

  • defaultCapacity: 您应该将此值设置为我们将同时在屏幕上需要显示的弹幕数量(您可以通过测试射击速率来确定此数值)。

  • maxSize: 此值将防止池变得过大而失控。任何超过此数值的实例都将被销毁而不是返回到池中(经常超过最大大小会触发不希望的垃圾回收,并且调整大小是一个昂贵的操作——更多的 CPU 循环——因此您也需要通过测试来微调此值)。

现在让我们通过实现射击机制,使用子弹弹幕池来充分利用我们的新对象池。

池化玩家射击模型

我们将对玩家射击设置使用 OOP面向对象编程)设计方法,以便于未来轻松扩展新的武器和弹丸类型。

创建池化玩家射击模型

让我们考虑以下类图:

图 6.1 – 池化玩家射击 UML 类图

图 6.1 – 池化玩家射击 UML 类图

图 6**.1 展示了一个 UML 图。UML 代表 统一建模语言,它是一种在软件项目中指定和可视化工件关系的标准化方法。有几种类型的 UML 图,每种图都有其特定的用途。我们使用的类图显示了系统的静态结构,包括类、属性、方法和它们之间的关系。它是软件架构中最广泛使用的图之一。

额外阅读 | UML 图

UML: www.uml.org/what-is-uml.htm

PlantUML 语言参考指南:plantuml.com/guide

PlantText UML 编辑器:www.planttext.com/

好的,我们已经为游戏代码中池化玩家射击部分建模了系统,但这意味着什么呢?参考 图 6**.1 中的图,让我们用以下这些点来分解结构:

  • PlayerShootingPooled 类(C) – 对玩家输入的 OnFire() 事件的 SendMessage() 响应以射击武器的弹丸:

    • 这使用了 _poolProjectiles 对象,它代表实例化的 ProjectileBase 对象的堆栈(即 Bullet

    • 它获取 Bullet (C) 对象预制件(从 ProjectileBase (A) 类类型派生而来)用于在 _poolProjectilesObjectPool<ProjectileBase> 类型)堆栈中:

    • 它有一个指向当前配备给玩家的远程武器(WeaponRanged (C) 类类型)的引用

  • WeaponRanged 类(C) – 表示我们可以为玩家配备的任何数量的远程武器类型:

    • 它实现了 IWeapon (I) 接口,这意味着我们必须声明相同的成员(就像一个合同)。因此,任何实现该接口的类都将具有相同的成员可用(这允许我们在不更改消耗接口成员的代码的情况下交换对象类型)。
  • Bullet 类(C)是我们添加到子弹预制件中的组件,并在检查器中分配给 WeaponRanged 序列化的 _bulletPrefab 字段。子弹预制件通过公共 BulletPrefab 属性(封装私有变量)检索,用于在 PlayerShootingPooled 类中消费:

    • 这扩展了继承的抽象类 ProjectileBase(使 Bullet 成为子类);我们不能实例化声明为 abstract 的类,而必须使用派生类。然而,在 base 类中声明的成员在派生类中也是可用的。

重要提示

当我们说 serializable 时,我们是指我们将在检查器中做出分配——在大多数情况下是一个私有字段(在 C# 脚本中,私有字段用 [****SerializeField] 属性装饰)。

在阅读了前面的细节并审查了类图之后,你可能已经开始想象我们的代码应该是什么样子 … … …

好吧,你现在可以停止做白日梦了,因为代码如下。

Assets/Scripts 中创建一个新的 C# 脚本,命名为 PlayerShootingPooled.cs

using UnityEngine.Pool;
public class PlayerShootingPooled : MonoBehaviour
{
    [SerializeField] private WeaponRanged _weapon1;
    public int PoolDefaultCapacity = 10;
    public int PoolMaxSize = 25;
    private ObjectPool<ProjectileBase> _poolProjectiles;
    private void Start() {}    // new ObjectPool<>();
    private void OnFire() =>
        _weapon1.Shoot(_poolProjectiles.Get(),
            ReturnProjectile);
    private void ReturnProjectile(
        ProjectileBase projectile) =>
            _poolProjectiles.Release(projectile);
}

我们已声明 _weapon1 变量,用于分配对玩家手中在编辑器(设计时)附加的 WeaponRanged 预制对象引用。我们还声明了公共变量 PoolDefaultCapacityPoolMaxSize,分别具有默认值 1025,用于我们私有 ObjectPool 的默认和最大大小,该 ObjectPool 声明为 _poolProjectiles

然后,我们将使用上一节 The Unity object pooling API 中的 Start() 代码(不包括在之前的代码中)并声明一个 OnFire() 方法,当玩家按下 fire 按钮时,将通过 PlayerInput SendMessage() 调用它。在 OnFire() 方法中,我们将提供一个 Bullet 实例,该实例是通过调用 _poolProjectiles.Get() 返回的,用于射击。

最后,我们将声明 ReturnProjectile() 方法,因为它将在 _weapon1.Shoot() 回调中调用,当子弹完成…执行子弹应该做的事情时。

关于代码架构的说明

当我们创建池项目时,我们可以传递对 _poolProjectiles 的引用并直接对其调用 Release(),但如果我们将其作为一个 事件,我们可以提供 ReturnProjectile() 作为回调。此外,我们还有添加任何其他回调的选项。我目前没有立即的计划。不过,考虑这些选项以创建一个灵活的方法,而不必在以后重构代码以引入功能,并可能在这个过程中破坏功能/测试过的代码,总是好的。

现在,我们将在 Assets/Scripts 中创建一个新的 C# 脚本,命名为 WeaponRanged.cs

using UnityEngine.Events;
public class WeaponRanged : MonoBehaviour, IWeapon
{
    [SerializeField] private ProjectileBase _bulletPrefab;
    public ProjectileBase BulletPrefab => _bulletPrefab;
    [SerializeField] private Transform _projectileSpawn;
    public void Shoot(ProjectileBase projectile,
        UnityAction<ProjectileBase> poolingReturnCallback)
    {
        projectile.transform.position =
            _projectileSpawn.position;
       projectile.Init(_projectileSpawn.right
            * transform.root.localScale.x,
                poolingReturnCallback);
    }
}

WeaponRanged 脚本是我们将在检查器中使用序列化的私有字段 _bulletPrefab 分配我们的子弹预制件的引用的地方——毕竟,射击武器需要射击的东西。

我们已将 _bulletPrefab 封装起来,然后只允许通过 BulletPrefab 公共属性进行读取(getter)访问引用。因此,这里的 封装 意味着我们不希望其他类访问设置子弹引用。武器将管理自己的弹丸(尽管我们还可以稍后添加功能,通过公共设置器方法,如 WeaponRanged.SetBulletPrefab(GameObject) 或类似的方法,来分配新的子弹预制件)。

Transform 变量 _projectileSpawn 提供了一个位置,我们将在这里生成从武器射出的子弹预制件——我们将在稍后的 WeaponRanged 1 预制件中设置它。

最后,Shoot() 方法将弹池提供的弹丸的位置设置为弹丸生成位置,然后调用其上的 Init() 方法(可能是为了通过对其施加一些力来正确发射)。

我们还将提供对 poolingReturnCallback 的引用,以便当子弹 Prefab 与另一个对象碰撞或其生命周期结束时,可以将其释放回池中。

现在,我们将在 Assets/Scripts/Interfaces 目录下创建一个新的 C# 脚本,命名为 IWeapon.cs

using UnityEngine.Events;
internal interface IWeapon
{
    ProjectileBase BulletPrefab { get; }
    void Shoot(ProjectileBase projectile,
        UnityAction<ProjectileBase>
            poolingReleaseCallback);
}

WeaponRanged 类实现了 IWeapon 接口以满足契约,这意味着 WeaponRanged 必须实现 IWeapon 接口中声明的 BulletPrefab 属性和 Shoot() 方法。请注意,C# 中的接口成员默认是公共的!

类图中的武器和对象池部分现在已满足。让我们通过从武器发射的弹丸来完成类图实现。

按照以下方式在 Assets/Scripts 目录下创建一个新的 C# 脚本,命名为 Bullet.cs

public class Bullet : ProjectileBase
{
    [SerializeField] private LayerMask CollideWith;
    protected override void
        OnTriggerEnter2D(Collider2D collision)
    {
        if ((CollideWith
            & (1 << collision.gameObject.layer)) != 0)
                base.Collided();
    }
    protected override void LifetimeExpired()
        => base.Collided();
}

Bullet 类扩展了 ProjectileBase 类,这意味着它将继承所有成员并/或需要 重写 成员。你可以在派生类中声明独特的属性,以区分它与其他派生类(面向对象设计中的 继承 原则之一)。

我们正在重写 OnTriggerEnter2D() 方法 – 我们必须这样做,因为它在继承的 ProjectileBase 类中被声明为 abstract – 以执行特定的子弹碰撞动作。

注意,我们还在继承的类中通过使用 base 关键字调用了 Collided() 方法。Collided() 被声明为 virtual,这意味着我们可以在派生类中重新定义它,同时也可以使用它来实现相同的基本/默认功能。

这段代码 – ((CollideWith & (1 << collision.gameObject.layer)) != 0) – 在 OnTriggerEnter2D() 方法中评估了 Bullet 碰撞的 GameObject 是否包含在 CollideWith LayerMask 中选定的层中。例如,我们将选择 EnvironmentWallGroundEnemy 等等,但不包括 Player 以便 Bullet 能够与之碰撞。

额外阅读 | Unity 文档

层掩码:docs.unity3d.com/2022.3/Documentation/ScriptReference/LayerMask.xhtml

位运算符和移位运算符:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/bitwise-and-shift-operators#left-shift-operator-

我们已经看到了 Bullet 如何通过面向对象继承扩展了 ProjectileBase 类,现在让我们看看 ProjectileBase 类。

Assets/Scripts 目录下创建一个新的 C# 脚本,命名为 ProjectileBase.cs

using UnityEngine.Events;
public abstract class ProjectileBase : MonoBehaviour
{
    [SerializeField] private Rigidbody2D _rb;
    [SerializeField] private float _velocity = 30f;
    [SerializeField] private float _lifetime = 2f;
    private event
        UnityAction<ProjectileBase> _onCollisionAction;
    public virtual void Init(Vector2 direction,
        UnityAction<ProjectileBase> collisionCallback)
    {
        _onCollisionAction = collisionCallback;
        _rb.velocity = direction * _velocity;
        Invoke(nameof(LifetimeExpired), _lifetime);
    }
    protected abstract void
        OnTriggerEnter2D(Collider2D collision);
    protected abstract void LifetimeExpired();
    protected virtual void Collided()
    {
        CancelInvoke();
        _onCollisionAction?.Invoke(this);
    }
}

在这里,我们可以看到我们的子弹投射物的默认属性和行为。通过将 ProjectileBase 声明为抽象基类,它不能被直接实例化(创建新实例),因此我们必须声明一个新的类,该类扩展或继承自它。

注意,任何派生类仍然可以通过它扩展的基类被引用(在面向对象编程中,这是 多态 原则)。派生类必须重写任何声明为抽象的成员,但可以选择重写声明为虚拟的成员(同时也能够调用基虚拟方法以实现默认行为)。

让我们分解代码的相关项:

  • _rb, _velocity, 和 _lifetime 变量的声明为投射物 Prefab 的 RigidBody2D 组件提供了引用,同时也为对象的速率和寿命提供了可配置的值。

  • 当调用 Init() 时,传入的回调动作被分配给 _onCollisionAction 以供稍后调用,然后设置 _rg.velocitydirection_velocity 的速率,将其发射到武器指向的方向。

  • 然后,我们有两个抽象方法必须在派生类(例如,Bullet)中重写:

    • OnTriggerEnter2D(): 当另一个对象与该对象发生碰撞时,Unity 的消息事件会被调用。

    • LifetimeExpired(): 在 Init() 中,我们使用延迟调用此方法,使得投射物对象仅在场景中存在一定时间(将其释放回对象池以供重用)。在游戏测试期间,应调整 _lifetime 值,以确保武器的射程在游戏中表现良好。

  • 最后,我们有 Collided() 方法,它首先取消在 _lifetime 值处调用 LifetimeExpired() 方法(例如,因为我们不希望在 Collided() 已经被碰撞事件调用后再次调用 LifetimeExpired()),然后调用 _onCollisionAction 回调(将对象释放回对象池)。

池化玩家射击模型代码

完整的上述池化玩家射击代码可以从以下 GitHub 仓库下载:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch6/Unity%20Project/Assets/Scripts

按照来自 图 6**.1 的 UML 类图的设计模型,我们现在已经完成了代码部分的编写,并准备好将其与玩家实现。

实现池化射击模型

现在,让我们看看我们如何通过创建所需的 Prefab(武器和投射物)来实现池化玩家射击模型,以便与玩家集成。我们需要以下 Prefab,以及创建它们的步骤如下:

  1. 子弹 1:我们将从远程武器发射的第一个投射物 Prefab 资产:

    1. 从项目文件中导入 bullet1 美术资源到 Assets/Sprites 文件夹,并将 PPU 设置为 1280(为了将较大的子弹精灵设置为合适的游戏内大小,这可能会在稍后根据发射子弹的武器大小进行调整)。同时,为了优化,将其设置为 64,因为它是一个相对较小且移动速度较快的精灵。

    2. Bullet 1 中创建一个空 GameObject。请记住,您可以将 Rigidbody2DCircleCollider2D 组件轻松地作为父对象的孩子来启用 Rigidbody2D 组件,设置为 0,并启用 CircleCollider2D 组件,设置为 0.13),如图 图 6**.2 所示:

图 6.2 – 子弹 Prefab 设置

图 6.2 – 子弹 Prefab 设置

  1. 现在,将 Bullet 脚本添加到父对象中,并通过点击并拖动 Rigidbody2D 部分标题到该字段来分配 Rigidbody2D 字段。

  2. 速度生命周期 设置为一些初始起始值,然后使用 CollideWith 字段分配子弹应与之碰撞的层(注意,在分配之前您可能需要添加一个新层)。

  3. 最后,从 Assets/Prefabs 文件夹中拖动父 Bullet 1 对象。我们的子弹 Prefab 现在可以与我们将要创建的武器 Prefab 一起使用了。

  4. WeaponRanged 1:玩家将持有并从其中发射子弹弹射物的远程武器:

    1. 导入 gun1 武器艺术品,并在精灵编辑器中,在把手处设置一个自定义的枢轴(见 图 6**.3 中的 A),这样当它附加(或生成)到玩家时,它处于正确的位置并且可以在枢轴点上旋转(正如您可能期望的那样)。

枢轴 | Unity 文档

当与 GameObject 一起工作时,枢轴作为定位、旋转和缩放它的参考点。在 Unity 中,当使用 Transform 工具时,您可以在工具设置覆盖层之间切换 GameObject 的枢轴或中心。

定位 GameObjects | Gizmo 处理器位置切换:docs.unity3d.com/Manual/PositioningGameObjects.xhtml

  1. 将枪精灵拖动到 WeaponRanged 1

  2. 现在,添加一个新的空 GameObject,命名为 ProjectileSpawnPoint,作为枪精灵对象的同级对象,我们将使用它作为 Transform 位置来生成弹射物。将此 GameObject 定位于枪口前端(见 图 6**.3 中的 B):

图 6.3 – 武器 Prefab 精灵和弹射点设置

图 6.3 – 武器 Prefab 精灵和弹射点设置

  1. 现在,将 WeaponRanged 作为组件添加到父 GameObject 中。

  2. WeaponRanged 组件上,我们只需要进行两项分配:将 ProjectileSpawnPoint 对象中的 Bullet 1 分配到 Assets/Prefabs 文件夹中的 WeaponRanged 1 对象。

我们的可回收玩家射击模型现在可以与玩家角色一起使用了。耶!

将池化射击添加到玩家角色中

我们将直接开始将武器添加到我们的玩家中。您要么确保玩家处于当前场景中,要么在项目窗口中双击Player预制体以将其打开到预制体模式。

我们将使用玩家的(演员的)骨骼来确保武器在动画时跟随角色的手。执行以下步骤将武器锚定到玩家的手中:

  1. root_bone层次结构下找到手骨。在我们的例子中,它是bone_8,如图图 6**.4所示。

  2. 将一个空 GameObject 作为bone_8的子对象添加,并命名为Weapon_Attachment;这将成为武器的附件点。将其与骨骼分开提供额外的定位/旋转选项。此外,使用_Attachment后缀命名意味着我们可以轻松搜索层次结构中作为附件点的任何/所有对象。

  3. 现在,您可以继续将WeaponRanged 1预制体从Weapon_Attachment对象拖动出来(这变成了一个嵌套预制体,这意味着我们可以在任何时间配置其属性,而无需依赖于Player预制体)。

以下截图显示了我们的玩家设置,手持武器。在接下来的图 6**.4中,您可以看到我还临时拖入了一个Bullet 1预制体,以检查其与武器的比例(与玩家角色一起显示):

图 6.4 – 添加到玩家中的武器预制体

图 6.4 – 添加到玩家中的武器预制体

作为最后一步,我们需要将PlayerShootingPooled脚本作为组件添加到我们的Player预制体的根对象中。然后,我们将从层次结构中将WeaponRanged 1对象拖动到组件上的武器 1字段(如图图 6**.4所示)。

这是我们的 Prefab 组件的外观,基于 UML 图中的类,在检查器中,所有相应的字段分配如下:

图 6.5 – 池化玩家射击设置的预制体配置

图 6.5 – 池化玩家射击设置的预制体配置

新增于 Unity 2022

这可能并不是 2022 年技术流特有的,但我相信这是一个值得提及的工作流程改进。您可以同时打开一个针对不同资产或 GameObject 的专注检查器窗口,而无需不断更改选择并使用检查器。首先,选择对象,然后右键单击并选择属性…(在底部)或按Alt/Cmd + P

让我们继续测试我们努力的成果!

参考图 6.4,进入PlayerCharacter1对象,将带有武器附件的演员手臂摆放在射击位置 – 使用 IK LimbSolver2D目标使这变得轻而易举。

我们将为玩家添加一个适当的射击动画,使武器指向第八章中的方向,这样我们就可以针对那些讨厌的机器人敌人(不是他们的错!)进行瞄准。

本节教会了我们如何通过将武器附加到角色的肢体上,使用之前创建的预制件来为玩家添加远程武器。我无法强调理解和使用良好的预制件工作流程在项目中的重要性!

摘要

本章介绍了对象池的概念,并实现了玩家射击机制的对象池,使用了 Unity 的对象池 API,同时基于我们池化玩家射击模型的 UML 图进行软件设计。

我们通过将配置好的预制组件附加到玩家身上,完成了游戏。

在下一章中,我们将通过添加一些视觉效果来增强玩家角色的表现,创建一些敌人 NPC(非玩家角色),并通过状态模式介绍敌人的行为。

第七章:抛光玩家的动作和敌人行为

第六章 中,我们以优化的方式使用 Unity 的新 对象 池化 API 添加了远程武器射击机制。

在本章中,我们将继续为玩家角色添加一些必要的视觉抛光,并使用视觉效果!我们还将创建一些敌人 非玩家角色 (NPC) 变体,并通过状态模式介绍敌人行为,以结束本章。

在本章中,我们将涵盖以下主要主题:

  • 使用 Shader GraphTrail Renderer 进行抛光

  • 敌人预制件和变体 – 使用 可脚本化 对象 (SOs) 进行配置

  • 使用 有限状态 (FSM) 实现基本敌人行为

到本章结束时,你将能够快速使用自定义着色器和后处理效果为游戏精灵添加一些视觉抛光,并制作具有不同属性和简单基于状态行为的几个敌人变体。

技术要求

从以下 GitHub 链接下载资产,以跟随本章中的相同艺术品,该艺术品是为书中项目创建的。

要跟随自己的艺术品,你需要使用 Adobe Photoshop 或能够导出分层 Photoshop PSD/PSB 文件的图形程序(例如 Gimp、MediBang Paint、Krita 和 Affinity Photo)创建类似的艺术品。

你可以在 GitHub 上下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

使用 Shader Graph 和 Trail Renderer 进行抛光

为了真正向玩家推销游戏体验,我们可以采用一些易于执行的视觉抛光。幸运的是,Unity 通过一些内置组件提供视觉效果功能,并将其作为其渲染管道的一部分包含在内。

在这里,我们将探讨为玩家、子弹弹道和整体视觉外观添加一些简单效果。

启用后处理

为了利用本节中我们将要实现的效果,我们首先需要启用 后处理,因为它适用于 通用渲染管线 (URP)(提醒:这是我们正在工作的渲染管道)。

具体来说,我们将制作的效果将使用 高动态范围 (HDR) 颜色值,这些值将与后处理泛光效果一起工作,使我们的 2D 灯光和精灵资产的部分发光。

HDR 颜色 | Unity 文档

HDR 颜色值提供了比标准颜色更宽的亮度范围,从而实现了对颜色和亮度的更准确描述,更鲜艳的颜色,改进了对泛光和发光效果的支持,以及减少了带状效应。

高动态范围:docs.unity3d.com/Manual/HDR.xhtml

要查看接下来章节中将要展示的内容,如图 7.1 所示,我们将对屏幕应用渐晕,并添加辉光以使子弹精灵的白色部分发光(为了清晰起见,进行了夸张处理)。

注意,我们无法仅使用辉光使子弹发光,因此我们将介绍如何实现这一点,以及渐晕,在接下来的章节中:

图 7.1 – 应用到场景的后期处理效果

图 7.1 – 应用到场景的后期处理效果

注意事项

一些后期处理效果性能密集,并不适合所有分发平台(尤其是移动平台),因此在添加新效果时请注意您的每秒帧数FPS)统计数据!

要在我们的游戏中启用后期处理,请按照以下步骤操作:

  1. 对于当前场景中的相机,在层次结构中选择主相机

  2. 渲染部分(参见图 7.1),启用后期处理

  3. 对于Assets/Settings文件夹,选择UniversalRP资产。

  4. 质量部分下,启用HDR

现在我们已经启用了后期处理,我们可以开始添加体积覆盖,这些覆盖将与我们将添加到对象中的效果一起工作。为此,我们需要在我们的场景中添加一个体积,并按照以下步骤添加体积覆盖:

  1. 场景层次结构中,使用创建菜单并选择体积 | 全局体积

  2. 在选择Global Volume对象的情况下,在检查器中,在Volume组件的Volume Profile资产上。这将在一个与场景同名的子文件夹中创建一个名为Global Volume Profile的资产文件。

  3. 分配了配置文件后,我们现在可以看到一个新的添加覆盖按钮(参见图 7.1)。点击它并选择后期处理 | 渐晕

  4. 点击强度左侧的复选框以启用它,然后提高数值以在场景游戏视图中看到屏幕边缘变暗效果。

  5. 现在,重复添加另一个覆盖,但这次选择1 – 场景中亮度低于此值的像素将不会应用 URP 的效果。默认值是0.9(在大多数情况下都很棒),但我决定几乎让任何对象都有可能为整体外观贡献发光效果,所以我稍微提高了它。

  6. 启用1.15以使事物看起来发光,作为一个初始的视觉检查,但我肯定会在稍后降低这个值。

额外阅读 | Unity 文档

后期处理和全屏效果:docs.unity3d.com/2022.3/Documentation/Manual/PostProcessingOverview.xhtml

在通用渲染管线中的后期处理:docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4015.0/manual/integration-with-post-processing.xhtml

现在事情看起来更电影化,并且有可能发出一些酷炫的光效,让我们最终设置我们的子弹 Prefab 精灵!

使用 Shader Graph 为子弹应用发光

您之前看到我们如何在第四章中为精灵添加二级纹理,当时我们添加了精灵法线图(为了给精灵提供伪 3D 效果)。

我们现在将使用一个1,这将通过使用 HDR 颜色值来实现)。

在*图 7.2 中,您可以看到子弹的艺术作品以及子弹的发射图**——一个黑白图像,其中白色区域定义了将发出光的部分:

图 7.2 – 子弹精灵的发射图

图 7.2 – 子弹精灵的发射图

如截图所示,我们创建了一个名为bullet1_emission的发射图,它仅代表子弹精灵的螺旋线条。回到bullet 1精灵,选择它,以便我们可以通过以下步骤将其作为二级纹理添加:

  1. 在检查器中精灵的导入选项部分,点击精灵编辑器按钮。

  2. 精灵编辑器下拉菜单中选择二级纹理(窗口的左上角)。

  3. 二级纹理对话框中,点击加号(+)按钮以添加新的纹理。

  4. 名称字段中输入_Emission

  5. bullet1_emission精灵从项目窗口拖到纹理字段。

  6. 点击应用按钮以保存更改(或者简单地关闭窗口并选择保存)。

作为复习,您可以参考第四章图 4.2,以查看二级***纹理**对话框的示例。

当我们的精灵准备就绪后,我们可以继续创建一个新的着色器来应用我们的效果。

创建新的 Shader Graph 2D 材质

创建自定义着色器过去通常是通过手动在特殊的着色器语言中编码来变得复杂,但Shader Graph允许通过基于节点的系统实时以可视化的方式创建自定义着色器,使这个过程对艺术家和开发者都更加易于访问。

我们将使用Shader Graph来创建和连接节点,以可视化的方式构建我们的发射着色器。请注意,这个着色器可以成为我们制作不同带有发射图的精灵所需的任何数量材料的基石!

附加阅读 | Unity 文档

Shader Graph 入门:docs.unity3d.com/Packages/com.unity.shadergraph%4014.0/manual/Getting-Started.xhtml

让我们按照以下步骤创建这个发光着色器:

  1. 首先,在Assets/Shaders中创建一个新的root文件夹。

  2. 在新文件夹中使用SpriteEmission_Unlit创建一个着色器图。

  3. 现在,通过在检查器中点击打开着色器编辑器按钮或双击资产来打开着色器编辑器

我们将要创建的自定义精灵着色器实际上非常简单,只需要几个节点,如以下截图中的完成着色器所示:

图 7.3 – 着色器图精灵发射着色器

图 7.3 – 着色器图精灵发射着色器

让我们按照以下步骤构建这个着色器:

首先,从黑板(图 7.3 中的A)使用加号(+)按钮添加以下属性:

  • 主纹理_MainTex(这些字段在图检器中分配,如图 7.3 中的B所示),然后拖入子弹精灵作为默认纹理

  • 注意,参考的拼写必须正确,因为这是着色器内部用于纹理引用的部分

  • 发射_Emission(注意这是我们之前在精灵编辑器中用作发射图次级纹理名称的部分),然后拖入子弹发射图作为默认纹理

  • 颜色_Color

这些属性将在检查器中公开,以便我们可以分配值。_MainTex_Emission将从精灵中获取。

接下来,创建并连接构成这个简单着色器的节点:

  1. 右键单击任何位置,从弹出菜单中选择创建节点(或者直接按空格键)。

  2. 开始键入以搜索要添加的节点。在我们的例子中,开始键入sample texture 2d,并从输入|纹理下的列表中选择它。让我们添加两个这样的纹理节点——我们需要一个用于主纹理,一个用于发射(参考图 7.3)。

  3. 让我们先完成主纹理路径。创建一个节点,然后执行以下连接:

主纹理(T2) à [采样纹理 2D] 纹理(T2) | RGBA(4) à [加] A(4) | 输出(4) à [片段] 精灵颜色(4)

重要提示

节点表示为[节点],连接线通过à(在节点上的小圆圈上点击和拖动)显示,输入/输出通过|(输入在左侧,输出在右侧)显示。

  1. 接下来,通过使用我们之前添加的第二个采样纹理 2D节点来完成发射路径。创建一个节点,并执行以下连接:

发射(T2) à [采样纹理 2D] 纹理(T2) | R(1) à [乘] A(4) | 输出(4) à [加] A(4)

  1. 最后,我们将通过连接颜色属性来完成着色器:

颜色(4) à [乘] B(4) | 输出(4) à [加] B(4)

下载完成的着色器图

要查看完成的精灵发射着色器,请访问项目 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch5/Unity%20Project/Assets/Shaders

在我们能够将我们花哨的新发射着色器应用到子弹预制件之前,我们必须基于这个着色器创建一个新的材质 – 这就是它的运作方式。在 Unity(以及大多数数字内容创建软件)中,渲染是通过材质、着色器和纹理来完成的,每个都为最终用户在屏幕上看到的图像贡献了它们的部分。

额外阅读 | Unity 文档

图形:docs.unity3d.com/2022.3/Documentation/Manual/Graphics.xhtml

通过在项目窗口的Assets/Shaders文件夹中名为SpriteEmission_Unlit的着色器资产上右键单击,创建一个基于我们刚刚创建的新着色器图的材质,然后按照以下步骤操作:

  1. 前往Bullet 1

  2. 然后,在Assets/Materials中创建一个新的文件夹,并将材质移动到该文件夹中。

  3. 通过首先打开Bullet 1预制件(无论是在场景中还是在预制件模式中)来将材质分配给子弹精灵。

  4. 在场景视图中,当子弹精灵可见时,从项目窗口拖动Bullet 1材质到精灵上。Unity 在您释放鼠标按钮之前提供了一种视觉指示,以显示新材质应用于精灵后的外观(这是一个相当巧妙的技巧;这同样适用于将材质分配给 3D 对象)。

  5. 现在,这里是魔法发生的地方。选择层次中的bullet1精灵以显示其检查器,展开底部的材质部分,然后点击颜色选择器(注意您应该退出预制件模式,因为在那里看不到后处理)。然后,执行以下操作:

    1. 颜色字段设置为适当的发光颜色,然后提高强度值以达到所需的发光量(如图图 7.4所示,但请记住,后处理光晕效果的强度值也在这里起作用)。

这是我们的最终成果:

图 7.4 – 子弹材质 HDR 颜色设置

图 7.4 – 子弹材质 HDR 颜色设置

我们看起来不错 – 哈哈!

奖励活动

为玩家角色和武器添加基于SpriteEmission_Unlit着色器的材质!

如您从我们的简单着色器中看到的那样,在 Unity 中提升游戏视觉效果并不需要太多努力!为了在游戏视觉效果上取得另一个快速胜利,让我们为玩家角色添加一个微妙的光效,使其在环境中脱颖而出。

向玩家添加 2D 灯光

这是一个非常快速且简单的效果,但效果巨大。我们在这里要做的只是将2D 灯光作为玩家的子对象添加。当灯光与玩家相关联,并且位于预制件****层次中时,它将被附加到对象上。

使用以下步骤添加灯光:

  1. 在场景中右键单击Player预制件的根对象。

与预制件一起工作

关于使用预制件的提醒:如果你在预制模式(通过在项目窗口中双击预制件)中添加对象,你将无法可视化光设置的变化。然而,你可以通过在场景中点击层次结构窗口中的箭头图标(*>)进入预制件隔离模式,仍然能够可视化变化。最后,你可以在场景中直接修改预制件,但请记住应用覆盖以保存对预制件的更改。

  1. 从菜单中选择 | 聚光灯 2D

  2. 调整到您喜欢的值。参考图 7**.5,这是我使用的设置:

    • 将灯光放置在角色的胸部

    • 0.6

    • 1.5),7

    • 0.01(在这里添加一点以对环境照明做出微小贡献)

    • 3距离

前后对比可以在图 7**.5中看到。注意玩家如何在右侧从环境中弹出,而左侧的东西看起来相当平坦:

图 7.5 – 使用 2D 光使玩家突出显示

图 7.5 – 使用 2D 光使玩家突出显示

这很简单!让我们通过添加一个简单易加的额外视觉效果来获得另一个快速胜利!

使用尾迹渲染器进行抛光很容易

Trail Renderer组件创建了一个跟随移动对象的尾迹。这是一个为事物添加更多影响性移动的好方法,通过调整几个设置,它可以变得既微妙又夸张。

我们将直接进入正题。

为预制件添加尾迹

按照以下步骤为我们的子弹添加尾迹:

  1. 打开Bullet 1预制件进行编辑。

  2. 打开创建菜单,添加效果 | 尾迹(作为bullet1精灵的兄弟添加到根节点)。

  3. 调整值以获得良好的尾迹效果!以下是我用作起始点的值:

    • (0.0, 0.2), (0.5, 0.0) – 使用此曲线将确保尾迹不会延伸得太长

    • 0.2

    • 350

    • Package/Universal RP/Runtime/Materials)

    • 照明 = 关闭(投射阴影)

这些设置和结果可以在图 7**.6中看到:

图 7.6 – 在子弹预制件上可视化尾迹渲染器组件

图 7.6 – 在子弹预制件上可视化尾迹渲染器组件

不要忘记你还需要设置Bullet 1预制件的精灵渲染器、地面以及前景/背景排序层。

清除池化预制件的尾迹

Trail Renderer组件在子弹 GameObject 后面创建了一个多边形尾迹(这就是尾迹渲染的方式),即使 GameObject 在场景中被停用,尾迹仍然存在。

这对我们来说是个问题,因为这正是我们使用对象池所做的事情:当 GameObject 项目返回池中时,将其停用。

幸运的是,Trail Renderer 组件提供了一个 Clear() 方法来清除轨迹。我们只需要在初始化弹道时调用此方法,voilà!问题解决。

将以下代码添加到 ProjectileBase 类中:

public virtual void Init(Vector2 direction,
    UnityAction<ProjectileBase> collisionCallback)
{
    // If there is a Trail Renderer component on
    // this GameObject then reset it.
    if (TryGetComponent<TrailRenderer>(out var tr))
        tr.Clear();
    …
}

在这里,我们只是使用 TryGetComponent<TrailRenderer>() 来检查是否已将 TrailRenderer 组件添加到 GameObject 中,并且只有在存在的情况下才返回其引用,使用 out 参数。如果组件不存在,则不会进行分配,这与 GetComponent() 不同。

我们使用 if 语句来评估 TryGetComponent()bool 返回值,因此只有当弹道上有 Trail Renderer 组件时,才会调用 Clear() 方法(本质上,重置它)。

额外阅读 | Unity 文档

Component.TryGetComponent: docs.unity3d.com/2022.3/Documentation/ScriptReference/Component.TryGetComponent.xhtml

在本节中,我们学习了如何启用后处理并添加如 VignetteBloom 这样的效果覆盖,使我们的子弹发光,玩家在光中脱颖而出。我们还完成了子弹上的简单轨迹效果!

接下来,我们将添加一个可配置的敌人角色及其变体。

敌人预制件和变体 – 使用 SO 配置

而不是必须存在于场景中的 GameObject,我们可以创建一个基于文件的资产,可以从任何 GameObject(包括预制件)引用,任何游戏中的任何地方,称为 SO。

由于这是一个单个资产引用,因此不需要额外的分配,无论场景中有多少对象引用它,都使用相同的值。非常酷!

SO(可脚本化对象)作为一个小型高效的数据容器,还允许将数据与其消耗数据的代码分离。数据可以从后端云系统中更新,而无需重新编译代码或构建整个游戏的新版本。

能够对生产中的游戏数据进行响应 – 在玩家可能需要快速解决问题的场合 – 是 SO 的一个优秀应用。

其他开发者已经使用 SO 用于中间件组件,甚至完全解耦的事件系统,这些系统对设计师友好,因为它们允许在编辑器中设计时进行配置(也就是说,开发者不需要在对象之间连接新事件等)。

Unity 还在其 Open Projects 开发计划中基于 SO 构建了完整的游戏架构。而且,如果不提一下 Ryan Hipple(Schell Games)在 2017 年 Unite Austin 上现在臭名昭著的 使用 Scriptable Objects 构建游戏架构 讲座,那就有些不完整了(在 额外材料 – Unity 文档 提示框中提供了链接),他在讲座中描述了如何使用 SO 构建更可扩展的系统和数据模式。

额外材料 | Unity 文档

ScriptableObject:docs.unity3d.com/2022.3/Documentation/Manual/class-ScriptableObject.xhtml

打开项目:unity.com/open-projects

Unite Austin 2017 - 使用 Scriptable Objects 进行游戏架构:youtu.be/raQ3iHhE_Kk

在这部分介绍完成之后,我们将继续创建我们的第一个 SO 并使用它来配置敌人的特性。

创建具有配置的敌人 Prefab

MonoBehaviour脚本类似,SO 的创建方式有一些例外:

  • SO 必须继承自ScriptableObject而不是MonoBehaviour

  • 它不能附加到 GameObject(作为组件)。相反,它作为文件资产保存,并由组件作为在检查器中公开的字段引用。

  • 它不会接收到与MonoBehaviour脚本相同的所有 Unity 消息事件(最明显的是缺少Start()Update()FixedUpdate())。

  • 它可以通过使用CreateAssetMenu创建基于ScriptableObject类的新自定义资产。相比之下,MonoBehaviour只能在场景中进行配置并保存为 Prefab(然后可以在Prefab 模式中编辑)。

新的敌人配置数据 SO 资产的脚本模板看起来像这样:

using UnityEngine;
[CreateAssetMenu(fileName = "New EnemyConfigData",
    menuName ="ScriptableObjects/EnemyConfigData")]
public class EnemyConfigData : ScriptableObject
{
    public float Speed, AttackRange,
        FireRange, FireCooldown;
    public bool CanJump;
    public float JumpForce;
}

这里,我们可以看到[CreateAssetMenu()]属性,它将在编辑器中创建一个新的菜单项,以便基于此EnemyConfigData SO 创建新的文件资产。

前往项目窗口并选择创建 | ScriptableObjects | EnemyConfigData,我们可以创建多个敌人配置资产:

图 7.7 – 创建 ScriptableObjects 资产菜单

图 7.7 – 创建 ScriptableObjects 资产菜单

声明的SpeedAttackRangeFireRange等成员字段为不同类型的敌人提供了可配置的数据。在之前的代码中尚未定义,但您也可以创建方法(例如封装字段、返回计算、辅助方法和计时器)。

在新的Assets/Scripts/Data文件夹中使用前面的ScriptableObject模板创建一个名为EnemyConfigData的新脚本。

关于冒险游戏,并参考我们的游戏设计文档GDD)中的第四章表 4.3,我们将介绍两种敌人角色(包括无聊且无趣的维护机器人)。

在新的Assets/Data文件夹中创建两个名为Enemy A ConfigEnemy B Config的敌人配置数据资产,并分配一些默认值,以赋予每个机器人独特的特征,如下面的截图所示:

图 7.8 – 敌人配置的多个资产

图 7.8 – 敌人配置的多个资产

您也可以从前面的截图中得出结论,邪恶的外星植物实体已经控制了维护机器人,它们现在处于其控制之下!不,不是那个确切的意思,但我已经导入并设置了我们将应用配置数据的敌人演员。

导入、绑定、创建预制件、添加逆运动学IK)以及添加动画的过程与我们为玩家角色执行的工作流程相同。要回顾此工作流程,请返回到第五章中的使用 PSD 导入器设置玩家角色部分。

现在请创建两个敌人的预制件 – Enemy AEnemy B – 使用 GitHub 项目仓库中提供的美工;创建自己的,或者作弊并从项目仓库中下载已经完成的敌人预制件(最好不选择最后一个选项,因为您需要练习)。

冒险游戏 2D 艺术资产

要跟随本章内容,请从以下项目 GitHub 仓库下载艺术资产:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch5/Art-Assets

呼!现在我们有了敌人预制件,我们可以添加一个将利用敌人配置数据的组件。在Assets/Scripts文件夹中创建一个新的名为EnemyController的脚本 – 注意我们现在又回到了创建MonoBehaviour脚本:

Public class EnemyController : MonoBehaviour
{
    [SerializeField]
    private EnemyConfigData _config;
}

到现在为止,这个简单的脚本应该对您来说已经很有意义了。我们添加了一个名为_config的字段声明,类型为EnemyConfigData。我们为访问器提供了一个显式的保护关键字private,以便没有其他脚本可以访问它,但添加了[SerializeField]属性,这样我们就可以在检查器中进行赋值。

要实现以下截图中的配置,请将EnemyConfigData脚本拖动到Enemy B预制件(在根对象上),然后将Assets/Data文件夹中的Enemy B Config ScriptableObject资产拖动到EnemyController组件。我们刚刚为我们的敌人添加了变量配置数据!对Enemy A重复此操作:

图 7.9 – **Enemy B**配置

图 7.9 – Enemy B配置

要快速访问用于编辑值的ScriptableObject资产,请双击分配给Config字段的ScriptableObject资产引用。或者,您可以通过在项目窗口中右键单击资产并选择属性来打开聚焦检查器

这样,您可以在工作时不改变检查器到其他选定的对象/资产的情况下查看和编辑数据(这种例子可以在图 7.8中看到,其中两个敌人配置都打开了)。

打开聚焦检查器也是锁定检查器到单个对象的另一种方法,您可以通过点击检查器窗口右上角的锁形图标来实现(如图 7.9 所示)。

既然我们已经有了敌人角色,让我们看看如何通过扩展这些基础预制体来使用预制体变体轻松添加一些变化!

创建用于替代敌人类型的敌人变体

当 Unity 最终添加了对 嵌套预制体(大约在 Unity 2018.3)的原生支持时,它还包含了一个名为 预制体变体 的出色新功能,这对于拥有基于相同基本预制体属性的唯一变体集非常有用。

基础预制体将包含对象所需的所有基本行为,然后可以创建几个变体来覆盖属性,以改变对象的行为或外观。

在我们的例子中,以敌人预制体为例,通过 EnemyConfigData 资产分配一组独特的配置值覆盖了基础预制体。这还可能包括颜色、艺术品或组件的变化。

我们通过为游戏中想要拥有的不同敌人特性创建额外的 EnemyConfigData 资产来改变配置数据。

额外阅读 | Unity 文档

预制体变体: docs.unity3d.com/2022.3/Documentation/Manual/PrefabVariants.xhtml

创建预制体变体

让我们通过使其移动更快、射击更远以及通过缩短冷却时间来提高射速,为玩家创建一个 Enemy B 预制体的变体,以增加难度级别。

要做到这一点,让我们按照以下步骤操作:

  1. 首先,我们将 Assets/Data 文件夹中的 Enemy B Config EnemyConfigData 资产文件进行复制:

    1. 点击 Enemy B Config 资产在 Enemy BConfig Difficult 中。

    2. 在新资产上,分别调整 706060 的值)。

  2. 现在,切换到 Assets/Prefabs 文件夹。

    1. 右键点击 Enemy B 预制体,然后选择 Enemy B Difficult

    2. 在选择新变体并处于 Assets/Data 文件夹中时,将 Enemy B Config Difficult 资产拖动到 EnemyController 组件。

现在我们有一个 Enemy B 预制体,分配了不同的 SO 配置数据,但由于我们只覆盖了字段分配,所以敌人对象的其余部分完全相同。

图 7.10 展示了在编辑器中分配了困难敌人配置 SO 数据后我们的新敌人预制体变体看起来像什么:A 是困难敌人预制体,B 显示这个预制体有一个 Base 预制体,它是其变体,C 显示 SO 配置 字段的重写分配:

图 7.10 – 困难敌人 B 预制体变体设置

图 7.10 – 困难敌人 B 预制体变体设置

我们可以重复此过程来创建不同类型的敌人或任何其他类型的 Prefab,我们将在游戏中使用。想想看,维护机器人可能开始游戏时没有任何植物侵害,然后随着游戏的进行逐渐建立侵害。我们可以通过简单地覆盖艺术资产来使用 Prefab 变体实现不同阶段的变化。Prefab 变体可以使艺术上的渐进式变化变得容易实现!

通过使用 SO 来覆盖配置数据,我们将数据与 Prefab 资产(包括各种组件、艺术资产、声音、效果等)分离。SO 数据是一个小的对象,可以在不编辑 Prefab 的情况下进行更新,这使得设计师和非程序员更容易访问。

此外,如果只有数据需要更改,那么这只是一个微小的更新,可以在不推送整个 Prefab 资产的情况下推送到正在生产的游戏中。

从本节开始,拥有一组具有独特变化的敌人很好,但如果它们基于其配置数据具有一些行为那就更好了!

下一节将探讨为敌人机器人添加行为。

使用 FSM 实现基本敌人行为

第二章中,我们简要介绍了状态模式,因此现在我们将探讨如何实现此设计模式以保持我们敌人角色的状态。具体来说,使用 FSM,我们可以声明敌人可以在任何给定时间处于的固定状态集(即有限)——FSM 只会做这些事情。

我们 FSM 的第一个实现可能不太符合 SOLID 原则,但它希望足够简单,以便在实践中有意义。我们还可以将其用作一个例子,指出该方法中的任何缺陷,并在以后将其重构为更好的东西。

我应该指出,也许我们以后不会重构它……有时候,简单的方法就足够了,仅仅为了重构而重构只是浪费了本可以更好地用于加强核心游戏机制的时间,例如。

额外阅读 | 编程模式

有限状态机解释:www.freecodecamp.org/news/finite-state-machines/

让我们先看看我们希望我们的敌人角色具有哪些状态或行为。

状态模型

回到我们的 GDD 在第四章中的表 4.1,其中我们大致定义了敌人与玩家交战时的行为,我们可以推导出以下所需的最小状态:空闲巡逻攻击死亡

现在,我们可以设计一个 UML 状态图来表示我们的敌人行为。确定何时在状态之间切换的条件也定义了:

图 7.11 – 敌人行为状态模型

图 7.11 – 敌人行为状态模型

参考图示并使用条件来确定何时从一个状态转换到另一个状态,我们可以观察到以下情况:

  • 默认的起始状态是空闲

  • 当处于空闲状态时,当计时器到期时,我们将转换到巡逻状态

  • 当处于巡逻状态时,当计时器到期时,我们将返回到空闲状态(例如,空闲 --> 巡逻 --> 空闲 --> 巡逻

  • 当处于空闲巡逻状态时,当玩家在范围内时,我们将转换到攻击状态

  • 当处于攻击状态时,当玩家移出范围时,我们将返回到巡逻状态

  • 当健康值为零时,我们将从任何状态转换到死亡状态

现在我们知道了需要哪些状态以及哪些条件会改变状态,我们可以继续编写这个状态机了!

一个简单的状态机模式

我们将使用枚举来定义我们的有限状态,并在EnemyController类中编写状态转换逻辑,以创建我们的第一个状态机。这段代码与你在初学者项目中可能遇到的内容类似,因为它易于理解且易于操作(如前所述,可能只需这些)。

但它确实有一些限制和缺点。第一个是状态机是EnemyController类的一部分,违反了单一职责原则SRP)。

让我们看看:

public class EnemyController : MonoBehaviour
{
    …
    public enum State { Idle, Patrol, Attack, Dead }
    private State _currentState;
    private void Start() => ChangeState(State.Idle);
    void Update()
    {
        switch (_currentState)
        {
            case State.Idle:
                // UNDONE: Do stuff --> change state?
                break;
            case State.Patrol:
                // UNDONE: Do stuff --> change state?
                break;
            // And so on.
        }
    }
    public void ChangeState(State state) =>
        _currentState = state;
}

如我们所见,我们在public enum State {}声明行中定义了我们的状态机状态。我们的状态机只能处于这些定义的状态之一。然后,我们将使用_currentState变量来跟踪我们的当前状态。

让我们跳到ChangeState()方法,在那里我们可以看到我们将通过传递一个State来调用它,将其设置为状态机的当前状态(即转换到不同的状态)。在Start()中,你可以看到我们如何调用ChangeState(State.Idle)来设置敌人的初始(默认)状态为空闲

最后,在Update()方法(每帧更新时调用)中,有一个switch语句,为State枚举中声明的每个状态都有一个实现。当_currentState等于定义的枚举状态之一时,我们将执行一些操作——你可以将switch语句与一系列的ifelse ifelse if语句相关联(但当我们不需要测试值范围或条件时,它无疑更易于阅读)。

switch (C#)

switch语句是一种选择控制语句,它测试一个表达式(类似于if语句)并执行由 cases 定义的匹配代码块(并由break语句终止)。如果没有匹配的表达式,可以定义一个默认情况。

我并不一定反对这种对状态模式过于简化的方法,但switch语句很快就会变得丑陋。随着状态的增多,它可能会变得难以管理,正如我们开始向其中添加条件和行为时将会看到的那样,但至少它不依赖于任意数量的布尔变量来尝试并维护某种形式的状态(而且不需要同时处理两个变量变为真——天哪!)。

基于枚举的方法有一个问题,它破坏了 SOLID 原则中的OEnemyController类,即对修改封闭)。我们更希望只更改受影响状态的代码,而不触及其他任何东西!

与状态无关的功能代码不需要重新测试(例如,验收和回归测试),或者在团队环境中,甚至在将状态修改提交到版本控制系统(VCS)时也不需要进行代码审查。

至少,这种基于枚举的方法提供了一个结构,使得代码比没有它时更易于阅读,并且在不首先编写状态机的样板代码的情况下,很容易包含状态模式。

你可能能想象我们如何通过将特定状态的所有行为(和数据)封装到一个单独的类中来扩展这个状态模式。如果可以,太好了!如果不可以,也没关系,因为稍后我们会在第十三章中处理这个 FSM 的重构!

好吧——现在我们已经排除了所有警告,让我们改变一些状态!

改变状态的行为

现在我们已经定义并编码了我们的简单有限状态机(FSM)模式中的有限状态集,你可能想知道我们如何添加改变状态的条件。不用担心——这很简单!

我们将首先添加所需的字段并分配默认值来评估前两个状态的条件。

空闲和巡逻行为

参考图 7.11 中的状态模型,让我们让敌人在一个特定时间保持空闲(静止)后,在两个位置之间巡逻路径(我们将在关卡中定义)。然后,在巡逻了特定时间后,我们的敌人将返回空闲状态,并在敌人没有攻击玩家或,嗯,死亡的情况下无限重复这个过程。

为了这个,我们首先向我们的EnemyConfigData SO 添加两个变量,将指定敌人空闲和巡逻的时间:

public class EnemyConfigData : ScriptableObject
{
    …
    [Header("Behavior Properties")]
    public float TimeIdle = 5f;
    public float TimePatrol = 15f;
}

不要忘记,你可以在编辑器中通过选择Assets/Data文件夹中的Enemy B Config资产来覆盖这些默认时间值。

现在,回到EnemyController类中,我们需要一种方法来跟踪我们何时进入状态,以便计算经过的时间:

private float _timeStateStart;
public void ChangeState(State state)
{
    _currentState = state;
    _timeStateStart = Time.time;
}

我们添加了_timeStateStart浮点变量,在ChangeState()方法(已从表达式主体更改为主体块)中将其设置为当前游戏时间。调用ChangeState()方法为我们提供了一种在进入状态时进行操作的方式,而不仅仅是直接将_currentState变量设置为我们要过渡到的新状态(这不会给我们提供选项)。

现在已经设置了所需的字段和分配的值,我们可以继续评估从/到空闲巡逻状态的转换条件:

…
void Update()
{
    switch (_currentState)
    {
        case State.Idle:
            // UNDONE: Do stuff.
            // Change state?
            if (Time.time - _timeStateStart
                >= _config.TimeIdle)
                    ChangeState(State.Patrol);
            break;
        case State.Patrol:
            // UNDONE: Do stuff.
            if (Time.time - _timeStateStart
                >= _config.TimePatrol)
                    ChangeState(State.Idle);
            break;
        …

如前述代码所示,我们有一个新的if块,它评估当前时间Time.time减去我们进入当前状态的时间_timeStateStart。如果差异大于或等于我们配置的空闲时间_config.TimeIdle,则调用ChangeState()方法以过渡到巡逻状态。简单易懂!

同样,我们将评估从_configTimePatrol值(即敌人应该巡逻多长时间)过渡。现在,你可能想知道:我们如何让敌人角色真正巡逻?

实现行为

对于EnemyController类,但为了避免重复操作并远离单一职责原则,我们至少将行为抽象成它们自己的类。

因此,我们将通过接口定义行为,这样我们就可以在需要或想要更改它而不修改实现它的类的情况下替换行为代码。

因此,在Assets/Scripts/Interfaces文件夹中创建一个新的 C#脚本IBehaviorPatrolWaypoints,并为巡逻 航点行为添加以下接口声明:

public interface IBehaviorPatrolWaypoints
{
    Transform WaypointPatrolLeft { get; }
    Transform WaypointPatrolRight { get; }
    void Init(Rigidbody2D rb, Vector2 direction,
        float acceleration, float speedMax);
    void TickPhysics();
}

在这里,我们可以看到我们在关卡中声明了两个点,将创建敌人巡逻路径的WaypointPatrolLeftWaypointPatrolRight。我们将在关卡中的每个位置放置一个空 GameObject,并将它们的引用分配到检查器中的这些字段。

为了更好地可视化巡逻路径的概念,参考以下图表,点(蓝色)代表航点(即空 GameObject),虚线(橙色)代表由航点创建的巡逻路径:

图 7.12 – 航点和巡逻路径

图 7.12 – 航点和巡逻路径

(剧透…玩家应该躲在墙南方的正方形区域(绿色)中,以避免巡逻代理的视线。小心!)

回到代码,我们接着有一个Init()方法,它将由实现类调用,并将传递(或者,可以说,注入)行为所需的功能参数。

最后是TickPhysics()方法的声明,该方法将由实现类的FixedUpdate()方法调用以执行在航点之间移动敌人的实际功能(使用物理)。

现在回到 EnemyController 类,让我们创建一个 IBehaviorPatrolWaypoints 类型的变量。我们将通过使用 TryGetComponent() 获取其实例的引用——它应该作为 EnemyController 对象上的兄弟组件存在。

// Implemented behaviors.
private IBehaviorPatrolWaypoints _behaviorPatrol;
private void Awake()
{
    …
    // Get behaviors and initialize.
    if (TryGetComponent<IBehaviorPatrolWaypoints>(
        out _behaviorPatrol))
    {
        _behaviorPatrol.Init(_rb, _movementDirection,
            _config.Acceleration, _config.SpeedMax);
    }
}

这就是我们如何在不修改实现类的情况下,用相同类型的其他行为替换行为。通过使用 Unity 的 GetComponent() 架构——它允许一种形式的 MonoBehaviour 实现 IBehaviorPatrolWaypoints 并通过接口获取组件实例。

组合(OOP)

组合通常被称为 具有-关系,是通过一个类使用实例变量(变量)来引用另一个对象(一个类或另一个类)来实现的。术语 组合 也用于描述面向对象编程(OOP)中的组合,因为它涉及到将多个对象组合在一起以实现一个结果。

由于 IBehaviorPatrolWaypoints 只是一个将对象在两个点之间移动的行为,并且它不必显式地用于敌人,因此它可以用于任何我们想要具有此行为的对象。

好的——在实现行为的最后部分,我们需要将 TickPhysics() 方法与 EnemyController 类的 FixedUpdate() 方法绑定,以便它能够执行其行为功能。我们这样做:

private void FixedUpdate()
{
    if (_currentState == State.Patrol)
        _behaviorPatrol?.TickPhysics();
    else
        _rb.velocity = Vector2.zero;
}

在这里,与 Update() 中的情况相同,是在调用当前行为的 tick 方法之前检查我们当前的状态(这意味着更多的代码异味,因为状态检查在类的多个地方发生)。

代码异味

代码异味指的是源代码中一个 易于识别的指标,表明代码库中可能存在更深层次或有趣的问题。这些不是错误也不是错误,而是违反基本原则的违规行为,这会降低代码库的质量。本质上,它们是不可量化的,并且取决于开发者的经验。

如果 _currentStatePatrol,则调用 _behaviorPatrol?.TickPhysics()。我们在行为变量(一个接口)上使用了 null 条件运算符 (?.),以防敌人对象没有实现 IBehaviorPatrolWaypoint 组件。然而,你可能更喜欢不使用 ?.,并在编辑器中进行游戏测试时让它抛出错误,以验证对象配置。如何工作取决于你(这通常被称为 开发者风格)。

空条件运算符 (?.) 和 Unity 对象

注意,空传播与 Unity 对象不兼容,因为 Unity 覆盖了空比较运算符(以正确返回已销毁但尚未垃圾回收的对象的 null)。当尝试在 Unity 对象上使用 ?. 运算符时,你的 IDE 应该提供警告(如果没有,那么就换一个新的 IDE)。正确的方法是简单地使用空比较。例如:

NO

_player?.Jump();

YES

if (_player != null)

_player.Jump();

完全实现行为的最后一步是创建一个实现 IBehaviorPatrolWaypoints 的类,并包含移动逻辑。我将在稍后的 第十三章 中展示完成后的代码,当我们重构和扩展敌人行为逻辑时,但现在,我要挑战你创建自己的脚本!

为了给你一个提示,看看我们是如何给玩家添加移动的(记住 – 使用物理;不要直接操作变换位置!)并从这个新组件脚本的类声明开始,命名为 PatrolWaypoints

public class PatrolWaypoints
    : MonoBehaviour, IBehaviorPatrolWaypoints
{
     // Do move between waypoints stuff.
}

或者,你可以作弊。我的意思是,现在就看看 GitHub 项目文件中这本书的最终代码:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch7/Unity%20Project/Assets/Scripts/Behaviors

我们将以相同的方式实现每个其他行为,在每个需要处理当前状态的方法中添加一个新的状态条件检查,以及执行以下操作:

  • 声明一个实例变量并使用 GetComponent() 获取引用

  • 通过其 Init() 方法初始化行为实例并提供所需的依赖项

  • 将对其 TickPhysics() 方法的调用添加到 FixedUpdate() 中以执行其功能

优化提示

在我们的基类 – EnemyController(在这种情况下)中实现行为,这意味着我们不需要为任何行为注册和调用其自己的 FixedUpdate() Unity 消息事件;因此,调用一个 tick 方法会更有效率,因为它减少了互操作开销(C# 代码从 C++ 引擎代码中调用)——如果我们有很多敌人,这肯定会有所增加!

然而,正如你所见,这实际上是通过基于枚举的状态模式变得有些丑陋了。现在,让我们继续设置过渡到 攻击 状态的条件。

攻击玩家的行为

我们需要一些额外的引用来为 攻击 行为提供 – 特别是玩家。毕竟,你无法攻击你看不见的东西。

第四章 中,我们在检查器中为我们的 Player 对象分配了 Player 标签,以确定是否是玩家与碰撞事件交互。嗯,我们现在将再次使用这个标签,但方式不同。

我们将使用标签作为 FindWithTag() 方法的参数,以获取场景中 Player 对象的引用。

额外阅读 | Unity 文档

FindWithTag: docs.unity3d.com/2022.3/Documentation/ScriptReference/GameObject.FindWithTag.xhtml

由于我们的敌人 Prefabs 可能会在运行时被实例化到场景中,因此我们需要动态地获取 Player 的引用,而不是将其分配给在检查器中公开的字段。因此,不可能进行这种分配(场景引用不能在基于文件的资产,如 Prefabs 和 SOs 中进行)。

仍然在 EnemyController 中,一旦我们获得了 Player 的引用,让我们检查与玩家的距离,如果玩家在我们的任意范围值内,则将状态更改为 攻击

private GameObject _player;
private void Awake() =>
    _player = GameObject.FindWithTag(Tags.Player);
private bool IsPlayerInRange(float rangeAttack)
{
    var distance =
        Vector2.Distance(transform.position,
            _player.transform.position);
    return distance <= rangeAttack;
}

我们首先声明一个 _player 变量来保存 Player 对象的 GameObject 引用。然后,在 Awake() 中,我们使用 FindWithTag() 通过传递之前声明的字符串常量 Tags.Player 在场景中找到玩家。

优化说明

FindWithTag() 是一个慢速命令,但我们会在 Awake() 中执行它,以便在游戏统计信息之前获取玩家的引用(即缓存引用)。通常,你不会想在游戏过程中这样做,因为它很慢,更不用说在 Update() 中了,因为这是每帧都会调用的!

接下来,我们添加 IsPlayerInRange() 方法来计算到 Player 对象的距离,使用高级三角几何数学!不——你已经在之前的代码中看到,我们只是使用 Vector2.Distance() 方法,并传递 EnemyPlayer 的当前位置来获取它们之间距离的浮点值。简单易懂!

之前,我们使用 return 关键字来停止方法中的代码执行。这里,我们做的是同样的事情(有点类似;在这种情况下,这是最后一个语句),但由于 IsPlayerInRange() 方法的签名被声明为 bool 而不是 void,我们需要返回一个布尔值,即 Vector2.Distance() 返回的距离小于或等于(<=)指定的 rangeAttack 值的结果。

问题

EnemyController 类是声明 IsPlayerInRange() 方法的最佳位置吗?这遵循 SRP 吗?如果我们需要更改计算玩家距离的逻辑,这会负面影响其他代码吗?

当你在思考这个问题时,我会把它留在 EnemyController 中(现在吗?)。

最后,随着我们的依赖项现在就位,让我们连接进入和退出 攻击 状态的条件:

void Update()
{
    switch (_currentState)
    {
        case State.Idle:
            // UNDONE: Do stuff.
            // Change state?
            if (IsPlayerInRange(_config.AttackRange))
                ChangeState(State.Attack);
            else if (Time.time - _timeStateStart
                >= _config.TimeIdle)
                    ChangeState(State.Patrol);
            break;
        …
        case State.Attack:
            // UNDONE: Do stuff.
            if (!IsPlayerInRange(_config.AttackRange))
                ChangeState(State.Patrol);
            break;
        …

如果我们回顾 is [player] in range 条件,并在满足条件时过渡到 攻击 状态。

我们将通过向我们的 Change state? if 块中添加 IsPlayerInRange() 并传递此敌人对象配置的 AttackRange 值来实现这一点。如果在范围内,那么:ChangeState(State.Attack)

相反,在 !) 调用 IsPlayerInRange() 来反转结果(逻辑非运算符在结果为 true 时返回 false)——这改变了评估从 玩家是否在范围内玩家不在范围内

以下代码(为了简洁)未显示,但IsPlayerInRange()条件检查(正如我们的状态模型所说的那样)。

现在,让我们看看我们如何处理我们的状态模型中的最终状态:死亡状态(实际上,这确实是非常最终的)。

死亡状态

现在,敌人将能够在范围内攻击玩家——我们将在下一章中查看实现确切攻击行为,第八章,当玩家攻击并使感染机器人敌人失去行动能力时;然而,现在我们可以提供一个死亡状态来处理这种情况。

当我们切换到这个状态时,我们将简单地像这样销毁敌人GameObject:

void Update()
{
    switch (_currentState)
    {
        …
        case State.Dead:
            Destroy(gameObject);
            break;
    }
}

使用Destroy()这样是可以的,因为我们仍然可以使用敌人的OnDestroy() Unity 消息事件为其添加死亡效果。

额外阅读 | Unity 文档

MonoBehaviour.OnDestroy: docs.unity3d.com/2022.3/Documentation/ScriptReference/MonoBehaviour.OnDestroy.xhtml

然而,条件需要在switch语句之外进行检查,因为我们想要持续检查某些生命值变为零——无论当前状态如何。我们可以通过在switch语句之后添加一个if语句来实现这一点——switch部分只是为了处理当前状态!

void Update()
{
    switch (_currentState)
    …
    // Any state.
    if (_health <= 0)
    {
        ChangeState(State.Dead);
    }
}

目前不必太担心_health变量;我们将在第八章中实现生命值和伤害系统。正如您在前面的代码中所看到的,我们只是检查这个敌人的生命值是否等于或低于零,如果是,就将其更改为死亡状态。

本节向您介绍了状态模式、状态模型 UML 图,以及根据状态模型为我们的敌人角色设置简单的有限状态机(FSM)来管理不同状态。

摘要

在本章中,我们通过引入 URP 后处理、着色器图、2D 灯光和轨迹渲染器效果,为射击和玩家角色添加了一些润色。哇!有了 Unity 提供的这些功能,我们可以轻松地为我们的游戏添加视觉质量。

我们继续通过创建两个敌人 Prefab 并为每个通过 ScriptableObject 资产分配唯一的配置变量来向游戏中添加一些可配置的敌人角色。然后,通过实现状态模式并使用 FSM 引入基本行为以及评估状态间转换的条件,为敌人对象赋予行为。

在下一章中,我们将通过添加敌人生命值和伤害系统来完善冒险游戏,我们将这些敌人实例化到关卡中,实现带有额外武器类型的攻击机制,创建一个简单的任务系统来收集解决入口谜题的关键对象,并引入一个新的事件系统以保持我们的代码松散耦合。

第八章:扩展冒险游戏

第七章中,我们通过应用一些简单的 VFX(主要是 Bloom)使用通用 RP、Shader Graph 为自定义 2D 着色器制作发光特定部分的精灵、2D 灯光突出玩家,以及使用 Trail Renderer 组件在我们的子弹精灵上快速实现 VFX,从而为游戏添加了润色。

我们随后将注意力从玩家转移到游戏中急需关注的敌人 NPC 上,通过使用 ScriptableObject 架构创建可配置的敌人,并引入基于状态模式的变化行为,实现了简单的有限状态机(FSM)。

在可玩角色和敌人(行为设置主要是)的基础功能到位后,我们现在可以继续添加攻击玩家的敌人以及反之亦然的敌人,使用可重用的生命值和伤害系统。

在本章中,我们将涵盖以下主要主题:

  • 生命值和造成伤害

  • 更新玩家和敌人使用生命值

  • 敌人波生成器

到本章结束时,你将能够生成具有简单生命值和伤害系统的敌人——这些系统也适用于游戏中的玩家或任何可伤害的对象!

技术要求

要跟随本章中为书中项目创建的相同艺术作品,请从以下 GitHub 仓库下载资源:

github.com/PacktPublishing/Unity-2022-by-Example

要跟随自己的艺术作品,你需要使用 Adobe Photoshop 或能够导出分层 Photoshop PSD/PSB 文件的图形程序(例如,Gimp、MediBang Paint、Krita 和 Affinity Photo)创建类似的艺术作品。

生命值和造成伤害

在我们的项目进行到这一点时,我们已经实现了我们在 GDD 中指定的许多内容(第四章表 4.1)为我们玩家角色和敌人,但一个重要的系统仍然缺失——生命值和伤害。

在接下来的章节中,我们不仅将解决为玩家和敌人添加可重用组件以使用生命值,还将完成敌人的攻击行为,对玩家造成伤害。玩家已经可以发射能够发射弹丸的武器,因此我们将向之前在第六章中制作的Bullet对象添加功能,使其也能造成伤害。

生命值系统

我们将开发一个HealthSystem组件,为Player、敌人和其他对象(例如,在环境中——想想可破坏的箱子)创建一个可重用的组件。这个生命值系统将跟踪生命值,受到伤害和/或治疗,并且可以添加到任何对象。其余的设置将包括创建接口,将系统连接起来,并使整个系统以抽象的方式运行(即可重用、可扩展、可维护)。

如同之前一样,为了清楚地了解我们将如何创建健康系统,我们将利用基于我们前面段落中描述的概念的 UML 图。如果需要,我们始终可以参考此图。

图 8.1 – 健康系统类图

图 8.1 – 健康系统类图

现在,让我们深入探讨!在Assets/Scripts文件夹中创建一个新的脚本,并将其命名为HealthSystem。由于它是 UML 图的核心,因此将成为我们为健康系统创建的最大的类,因为,嗯,它确实是健康系统!由于它将包含我们将在本节中编写的最多代码,我们将首先仅声明成员变量和一个 Unity 消息事件,然后随着我们构建系统的功能,逐步完善它:

using UnityEngine;
public class HealthSystem: MonoBehaviour
{
    [SerializeField] private int _healthMax;
    private int _healthCurrent;
    private void OnTriggerEnter2D(Collision collision)
    {
        // UNDONE: Test for a collision with a component
        // that can damage us.
        // UNDONE: HandleDamageCollision();
        // UNDONE: Test for a collision with a component
        // that can heal us.
        // UNDONE: HandleHealCollision();
    }
}

任务列表(IDE)

您无疑在先前的代码中看到了// UNDONE:,并可能想知道这是关于什么的。嗯,我们可以在我们的注释中使用一些标记,我们的 IDE 会识别它们,并基于它们生成任务列表!此功能帮助我们定位未完成的任务,并突出需要关注的事项。此外,您还可以创建自己的自定义标记,这对于您的特定需求可能非常有用(但您需要检查您的特定 IDE 是否支持)。

常见标记包括以下内容:

// TODO:

// UNDONE:

// HACK:

如您可能从我们迄今为止的代码中推断出的一样——如果您已经阅读了代码注释——HealthSystem通过与其他场景中的对象发生碰撞来工作,例如来自敌人的投射物或治疗拾取物。其他对象可以损坏或治疗带有HealthSystem组件的对象,这取决于对象继承的接口(从 UML 图中:IDamageIHeal)。甚至地面作为一个对象,如果玩家的速度在碰撞时超过某个阈值,也可能对玩家造成潜在伤害。

以下是迄今为止添加的代码的分解,以及所需添加的内容:

  • 变量声明将启用HealthSystem的核心功能:

    • _healthMax将指定对象的最大健康值(即,通过添加HealthSystem组件赋予健康值的对象)。

    • _healthCurrent将指定对象的当前健康值。当对象被损坏或治疗时,此值将相应地减少或增加。当对象在场景中创建时,我们应该将当前健康值设置为_healthMax值,我们现在将通过添加以下Awake() Unity 消息事件来完成此操作:

          private void Awake()
          {
              _healthCurrent = _healthMax;
          }
      
    • 注意,这里最大健康值(_healthMax 是序列化存储的值)将保存在 Object with Health 预制件中,但想象一下,你也可以使用 ScriptableObject、数据库、从云端获取的(JSON)数据,甚至 Unity 的 Remote Config(作为 Unity Gaming Services 的一部分),这样我们就可以动态地更改最大健康值(并且,使用这些方法中的某些,可以在任何时间进行更改,而不依赖于分发游戏的新版本)。

额外阅读 | Unity Gaming Services

远程配置:unity.com/products/remote-config

  • OnTriggerEnter2D() 是魔法发生的地方!与其他对象的碰撞驱动健康系统,因此我们将使用这个 Unity 消息事件来处理从物理系统触发的交互。

让我们先添加伤害和治疗方法,然后我们再添加调用这些方法的评估:

注意 | 物理交互

要使对象在碰撞时相互物理响应,请在没有启用 IsTrigger 的对象上使用 Collider 实例,并使用 OnCollisionEnter2D() Unity 消息事件来响应碰撞。要使对象在碰撞时没有物理响应,请使用具有启用 IsTrigger 字段的 Collider 实例,并使用 OnTriggerEnter2D() Unity 消息事件来响应碰撞。

  • HandleDamageCollision(),正如其名所示,处理当我们与可以伤害我们的另一个对象碰撞时的情况。我们将传递当调用 OnTriggerEnter2D() 时的碰撞参数,以及未来的添加,造成伤害的对象(该对象是从 IDamage 接口继承的)。

  • 将以下方法添加到 HealthSystem 类中:

    internal void HandleDamageCollision
        (Collider2D collision, IDamage damage)
    {
        // UNDONE: TakeDamage(amount);
    }
    
  • 好的,是的,我们在这里又添加了一个未完成的方法,我们将在稍后再次添加:TakeDamage()。我们正在保持方法简单,一次迈出一小步来构建健康系统。简单的 UML 图可能设定了一个期望,这将是容易的。嗯,是的,前提是我们一步一步地处理实现。

  • 在接下来的 Taking damage – IDamage interface 部分中,当我们添加所需的 IDamage 接口时,我们的未来自我将再次处理实现 TakeDamage() 方法。

  • HandleHealCollision() 方法与处理伤害的方法类似。我们将添加一个用于处理对象治疗的方法。然而,这次我们将省略传递 collision 对象作为参数;我们将对治疗的处理与受到伤害的处理有所不同(正如你将在接下来的 Healing – IHeal 接口部分中看到的)。

  • 将以下方法添加到 HealthSystem 类中:

    internal void HandleHealCollision(IHeal heal)
    {
        // UNDONE: ApplyHealing(amount);
    }
    
  • 好的,我们正在不断增加要在这里实现的方法,对吧?!就像 TakeDamage() 方法一样,我们将有一个用于治疗和改变受影响对象健康值的方法。

这就是HealthSystem类核心内容的布局。让我们继续添加我们新的健康系统类所继承的必需接口,并使事物真正运行起来!

需要接口!

在我们将HealthSystem组件添加到任何 GameObject 之前,还有很多工作要做,所以让我们整理并评估这些伤害和治疗的碰撞。

我们首先需要接口来评估与具有健康(例如,添加了HealthSystem组件的Player或敌人对象)发生碰撞的对象。我们将确定碰撞对象是否可以造成伤害或治疗——我们正在替换任务列表中的第一个UNDONE标记:

    // UNDONE: Test for a collision with a component
    // that can damage us.

从 UML 图中,我们可以看到我们想要评估的对象要么是从IDamageIHeal继承的。使用接口继承确保我们的类中存在提供预期功能所需成员(即,类必须满足接口的“契约”)。

标准的命名约定规定,在命名一个接口时,它应该以字母“I”开头,这为我们提供了一个机会,在命名上变得稍微聪明一点,甚至可以让人印象深刻,从而提高代码库的可读性。因此,对于一个可以损坏另一个对象的实体,我们将接口命名为IDamage——就像I damage [an object]。在治疗 – IHeal 接口部分,我们将添加额外的接口IHaveHealthIHeal——看看我们在这里做了什么。并不是说我在这里非常聪明,因为IDamageIDamageableITakeDamage对于游戏代码来说是非常常见的接口名称,原因相同的命名理由。

现在,让我们更新OnTriggerEnter2D()方法,添加以下包含接口的if语句(我们将在这一步之后创建这些接口):

    private void OnTriggerEnter2D(Collision collision)
    {
        // Test for a collision with a component that can
        // damage us.
        if (collision.
            TryGetComponent<IDamage>(out var damage))
        {
            HandleDamageCollision(collision, damage);
        }
        // Test for a collision with a component that can
        // heal us.
        else if (collision.
            TryGetComponent<IHeal>(out var heal))
        {
            HandleHealCollision(heal);
        }
    }

让我们看看以下两个评估以及它们如何为我们的健康系统提供所需的功能:

  • collision.TryGetComponent<IDamage>():如果你到现在还没有意识到,我是一个try get component 模式的粉丝——如果测试的对象上不存在该组件,我们可以优雅地失败。如果它存在,我们方便地有一个返回组件的out参数。简单!

优化提示

当使用TryGetComponent方法时,如果找不到组件,该方法不会在堆上分配内存。当它找到组件时,它只为返回值分配内存,而不是组件本身。这可以非常有益于提高性能和减少垃圾回收,与可以生成垃圾并分配更多内存的GetComponent方法相比——两者都会对性能产生负面影响。通过利用TryGetComponent,你可以避免不必要的内存分配,并保持游戏运行得像黄油一样顺滑。

因此,这里发生的情况是,如果我们碰撞的对象上有一个从 IDamage 继承的组件,即 我伤害这个对象,那么返回该组件并将其作为参数传递给 HandleDamageCollision(),同时传递碰撞对象本身。向前看一点并参考 UML 图,我们可以看到我们将实现一个 ProjectileDamage 组件(例如,在 Bullet 上),它继承自 IDamage

  • collision.TryGetComponent<IHeal>():这里也是一样。如果碰撞的组件上有一个从 IHeal 继承的组件,即 我治疗这个对象,那么返回该组件并将其传递给 HandleHealCollision()。再次参考 UML 图,我们可以看到我们将实现一个 PickupHeal 组件(例如,在 Water Diamond 上),它继承自 IHeal

目前,如果碰撞的对象既不造成伤害也不治疗,那么我们只需忽略碰撞(当然,当与任何东西碰撞时,这里显然的选择是进行相机震动!)。

现在我们有了接口的实现,我们需要实际创建它们。这些接口不会自己编写,所以让我们从 IDamage 开始。

受伤 – IDamage 接口

我们已经看到了 UML 图(图 8**.1)和 HandleDamageCollision() 代码,其中实现了 IDamage 接口,但尚未定义。我上面也撒了谎:如果我们使用 IDE 的重构工具,它们可以(至少部分地)自己编写(在 OnTriggerEnter2D() 中,IDamage 将有一个红色的波浪线)。在单词上右键单击(或在其上单击并按 Alt + EnterCtrl + . 取决于您的 IDE)并选择 在新的文件中生成接口IDamage 将生成以下内容:

internal interface IDamage
{
}

如果您决定不使用重构工具(为什么不呢?),在 Assets/Scripts/Interfaces 文件夹中创建一个新的 C# 脚本,并将其命名为 IDamage。即使您使用了重构工具,您可能仍然需要将生成的脚本移动到 Assets/Scripts/Interfaces 文件夹中,以保持整洁。

现在,我们需要一个字段来指定从 IDamage 继承的对象对具有生命值的对象造成伤害的值,所以添加一个 DamageAmount 变量声明,如下所示:

    int DamageAmount { get; }

记住,接口的所有成员都是公共的,因此不需要添加访问器。我们将属性设置为只读属性;我们只想让其他类读取值(不允许在继承接口的类之外修改 – 所有这些都很好,符合我们的喜好)。

现在我们有了 IDamage 接口。我们可以用它来制作投射物,以及,嗯,几乎任何其他伤害玩家的对象,通过在对象与 HealthSystem 之间发生碰撞时减去生命值。

现在,让我们回到 HealthSystem 类中修复 HandleDamageCollision() 方法,通过移除 // UNDONE: 注释并使用 DamageAmount 作为参数:

    internal void HandleDamageCollision
        (Collider2D collision, IDamage damage)
    {
        TakeDamage(damage.DamageAmount);
    }

这样我们就为我们的 IDE 的重构工具生成 TakeDamage() 方法做好了准备,所以让我们继续这样做。你知道的:红色波浪线等,然后选择 生成 方法‘TakeDamage。’

现在我们就到了这里:

    private void TakeDamage(int amount)
    {
        // UNDONE: Subtract from current health.
        // UNDONE: HealthChanged();
    }

我非常抱歉,有更多的 未完成 注释!请不要担心;通过这个非常临时的指令,我们将立即修复代码。在方法中填写以下语句:

    private void TakeDamage(int amount)
    {
        _healthCurrent = Mathf.Max(_healthCurrent - amount,
            0);
        HealthChanged();
    }

我们首先做的事情是更新当前健康值,通过从 _healthCurrent 中减去 amount 来计算受到的伤害量。在这里,我们得到了 Mathf.Max() 函数的帮助,这样当前健康值永远不会低于零(保持正值)。

附加阅读 | Unity 文档

Mathf.Max(): docs.unity3d.com/2022.3/Documentation/ScriptReference/Mathf.Max.xhtml

让我们取消注释 HealthChanged() 方法的占位符。创建一个空的方法块,但完成此方法将是我们的未来自我在实际上有一些设置来执行健康变化时的任务:

    private void HealthChanged()
    {
        // UNDONE: If current health is greater than zero,
        // notify the object with health.
        // UNDONE: If current health is zero, the object
        // with health dies/is destroyed.
    }

当健康值发生变化时,我们将调用此方法,这样我们就可以评估对象的当前健康状态并根据情况执行操作 – 例如通知其他类关于对象健康值的变化或如果健康值达到零则死亡/销毁。

因此,现在让我们设置一个对象,该对象将对玩家造成伤害。

子弹伤害组件

Assets/Scripts 文件夹中创建一个新的脚本名为 ProjectileDamage – 这是我们将添加到 Bullet 预制体的组件。为了确保此组件将对我们的健康系统造成伤害,它将实现 IDamage 接口:

using UnityEngine;
public class ProjectileDamage : MonoBehaviour, IDamage
{
    public int DamageAmount => _damageAmount;
    [SerializeField] private int _damageAmount = 5;
}

公共 DamageAmount int 变量必须声明以满足 IDamage 接口合约 – 它对于健康系统获取此弹头造成的伤害量值也是必要的!DamageAmount 是一个公共属性,因为所有接口成员都是公共的,并且不能包含字段。由于 C# 属性不会被 Unity 序列化,为了在 Bullet 预制体中分配值,我们将封装一个私有 _damageAmount 变量,并用 [SerializeField] 属性装饰它。如果你还没有猜到,这就是我们将继续整本书的结构。

当你保存完脚本后,打开 Bullet 预制体中的 ProjectileDamage 到根 GameObject,如图所示:

图 8.2 – Bullet Prefab ProjectileDamage 组件

图 8.2 – 子弹预制体中的 Bullet Prefab ProjectileDamage 组件

在前面的代码中声明 _damageAmount 时,我们将其默认值设置为 5,你已经在 检查器 窗口中看到的分配值中看到了这一点。

好的,我们现在可以伤害东西了;太棒了!但如果我们的玩家等对象没有机会治疗,那就真的不太公平了。

治疗 – IHeal 接口

我们已经处理了 UML 图(图 8**.1)的左侧,所以现在,类似于IDamage,我们需要定义IHeal接口来处理右侧。我们还将创建一个组件,可以添加到可以应用治疗的对象上。

现在回到OnTriggerEnter2D()方法中,让我们重复创建IDamage接口时执行的步骤,但这次是为IHeal

  1. Assets/Scripts/Interfaces文件夹中创建IHeal接口脚本(由您的 IDE 生成),并为指定治疗量添加HealAmount变量:

    internal interface IHeal
    {
        int HealAmount { get; }
    }
    
  2. 更新占位符HandlHealCollision()方法,在调用ApplyHealing()时指定接口中的HealAmount值作为参数:

        private void HandleHealCollision(IHeal heal)
        {
            ApplyHealing(heal.HealAmount);
        }
    
  3. 最后,创建ApplyHealing()方法:

        private void ApplyHealing(int amount)
        {
            _healthCurrent = Mathf.Min(_healthCurrent + amount,
                _healthMax);
            HealthChanged();
        }
    

我们首先更新当前健康值,通过将amount添加到_healthCurrent来增加治疗量。就像我们受到伤害时一样,我们再次从Mathf函数中获得一些帮助,但这次是Mathf.Min(),这样当前健康值就不会超过对象的最高健康值(没有作弊)。

附加阅读 | Unity 文档

Mathf.Min: docs.unity3d.com/2022.3/Documentation/ScriptReference/Mathf.Min.xhtml

然后,就像伤害一样,我们将创建一个治疗组件,当发生碰撞时将增加健康值。

PickupHeal 组件

Assets/Scripts文件夹中创建一个名为PickupHeal的新脚本:

using UnityEngine;
public class PickupHeal : MonoBehaviour, IHeal
{
    public int HealAmount => _healAmount;
    [SerializeField] private int _healAmount = 10;
}

ProjectileDamage脚本类似,我们可以看到我们已从IHeal继承(以确保使用我们的健康系统进行治疗)并通过定义HealAmount实现了IHeal接口。我们还通过声明一个序列化的私有_healAmount变量来封装HealAmount,这样我们就可以在10处设置值)。

关于代码架构的说明

如果您发现自己需要许多不同类型的伤害或治疗组件,可以为每个创建一个新的基类,实现接口,这样您就不必重复事件和调用的方法和函数。当前的实现适合我们的当前需求,因此您也可以在这里停止,或者挑战自己创建一个DamageBaseHealBase抽象类,分别由ProjectileDamagePickupHeal继承。

PickupHeal是一个我们将添加到…嗯…我们将添加到哪个对象上?让我们参考我们的 GDD(第四章表 4.2):

在冒险游戏中,玩家如何获得增益机制? 玩家将能够收集散布在环境中的能量碎片(水钻石),当收集到一定数量的碎片时,将使所有武器获得增强状态(增加造成的伤害)。

表 8.1 – 在 GDD 中添加增益

听起来不错。实际上,我认为我们可以做得更好。让我们修订;毕竟,GDD 是一个活文档:

在冒险游戏中,玩家如何获得增益机制? 玩家在探索游戏世界时可以收集能量碎片(水钻石)。玩家可以稍后使用收集到的能量来增强武器(增加造成的伤害)或治疗自己,为玩家在导航游戏挑战时提供更多策略选择。

表 8.2 – 修订 GDD 中的增益

更好!允许玩家在使用能量碎片为武器充电或治疗自己之间做出选择,这为游戏创造了一种风险与回报的策略。为了使这种机制对玩家有意义,游戏设计师必须仔细考虑这种方法,尤其是如果选择只有在某些时候才清晰的话!

目前,让我们只创建一个当拾取时将治疗玩家的对象。

创建水钻石拾取

让我们使用以下链接提供的艺术品中的水钻石:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch8/Art-Assets

图 8.3 – 水钻石艺术品

图 8.3 – 水钻石艺术品

要根据水钻石的艺术作品创建我们的水钻石拾取预制件,并且作为一个提醒,请按照以下步骤操作:

  1. 将艺术品导入到Assets/Sprites/Pickups文件夹。(顺便说一句,我喜欢这个水钻石艺术作品的效果;做得好,Nica!)

  2. 项目窗口中,选择水钻石精灵,并在检查器窗口中,使用精灵编辑器将提供的正常图设置为次级纹理。

  3. 将水钻石精灵拖动到Water Diamond (Heal)(记住,你可以在层次结构窗口中通过右键单击它并选择创建 空父对象来轻松地将 GameObject 设置为父对象)。

  4. CapsuleCollider2D实例添加到父对象,以启用物理(确切地说,是碰撞检测)。

  5. 使用编辑碰撞器按钮启用围绕钻石形状的碰撞器调整大小,如图所示。

图 8.4 – 水钻石治疗预制件

图 8.4 – 水钻石治疗预制件

  1. PickupHeal组件添加到预制件根目录(也见图 8**.4)。

  2. 最后,从Assets/Prefabs文件夹中拖动父对象。

为了快速提升视觉效果,你可以稍微作弊一下(作为独立游戏开发者,我们可以在任何地方节省时间,所以作弊是关键)并重用我们在第七章中为子弹创建的材料。按照以下步骤操作:

  1. Assets/Materials文件夹中,复制Bullet 1材质并将其重命名为Water Diamond 1

  2. 将水钻石精灵分配给MainTex通道,但保留bullet1_emission贴图。

  3. 将此新材质分配给Water Diamond 1预制件的图形上的Sprite Renderer Material字段。

  4. 调整 HDR 颜色和强度以获得所需的视觉效果。

我们的拾取预制件看起来不错。太棒了!

我们需要为这个预制件添加最后一点功能,使其成为玩家的拾取对象。它必须在“收集”后消失,我们可以通过回顾之前制作的现有可重用组件以及一个新的组件来实现这一点。

拾取行为组合

如前所述,能够将多个对象组合在一起以实现所需的行为或功能是一种组合形式。换一种说法,我们将结合两个或更多可重用组件,每个组件负责对象行为或外观的特定方面。这将允许我们在编辑器中直接创建新的行为,而无需引入新代码——这对你的团队中的设计师来说特别有价值,因为实验可以独立产生新的想法。

让我们从向OnTriggered事件的根gameObject添加TriggeredEvent组件开始,该事件在玩家与拾取对象碰撞时触发。我们想要销毁拾取对象,但没有内置的方法来做这件事。然而,这很容易解决。

销毁器组件

要销毁拾取对象,我们只需要一个额外的组件,作为单一用途但可重用的组件。这个组件将被添加到现有的拾取行为组合中。为了明确,我们需要通过调用TriggeredEvent.OnTriggered事件来销毁拾取对象。所以,首先,在Assets/Scripts文件夹中创建一个新的名为Destroyer的脚本:

using UnityEngine;
public class Destroyer : MonoBehaviour
{
    public float Delay = 0f;
    public void DestroyMe()
    {
        if (Delay > 0)
            Invoke(nameof(DestroyNow), Delay);
        else
            DestroyNow();
    }
    private void DestroyNow() => Destroy(gameObject);
}

简单。一个公开的方法,可以调用它来销毁对象——DestroyMe()。我添加了在销毁对象前设置延迟的选项——是的,我确信这是一个足够典型的用例,实际上只花了几秒钟就添加了,我将捍卫这不是违反 YAGNI 原则!

YAGNI | “你不需要它”

这个原则指出,程序员只有在必要时才应该添加功能。

我还喜欢的是DRY不要重复自己),它简单地说就是减少重复(一个基本例子是如果你发现自己多次编写相同的代码,将其提取到方法或抽象中)。

继续将Destroyer添加到Water Diamond (Heal)预制件的根目录,这样我们就可以在检查器窗口中连接东西了:

  1. 将执行状态下拉菜单设置为仅运行时

  2. Destroyer组件拖动到对象字段(使用其标题区域)。

  3. 在函数选择下拉菜单中,选择Destroyer | DestroyMe()

  4. 我会为这个拾取物留 0,但对于其他行为,你可能需要调整(看,你有这个选项!)。而且,虽然 Destroy() 有一个用于延迟销毁对象的第二个参数,但 Destroyer 类作为一个引入执行延迟的通用示例。

图 8.5 – 将 DestroyMe 分配给 OnTriggered (UnityEvent)

图 8.5 – 将 DestroyMe 分配给 OnTriggered (UnityEvent)

好吧,那么让我们谈谈哪些对象可以影响其他对象的健康——现在,那里就像野西一样,任何东西都能伤害任何其他东西。我们不能让这种情况发生,所以让我们戴上掩码,开始让每个人都表现得体——这是一个 LayerMask。

控制哪些伤害/治疗哪些

我们必须解决的问题是找到一个简单的方法来指定哪些对象可以伤害其他对象——这对于加强游戏设计至关重要。我们之前通过使用 标签LayerMask 解决了这个问题。我通常的偏好是仅在代码中比较单个类型的对象时使用标签,并使用 LayerMask 来区分几种不同类型的对象,LayerMask 的额外好处是它对设计师友好,因为分配是在 检查器 窗口中进行的。

什么造成伤害

评估对象的时间是在碰撞时,因此我们将相应地更新 HealthSystem 中的 handle collision 方法。但首先,我们需要在正确的位置定义 LayerMask 变量,从伤害开始。

IDamage 接口中添加 DamageMask 声明:

internal interface IDamage
{
    int DamageAmount { get; }
    LayerMask DamageMask { get; }
}

现在,添加以下变量,以便我们可以使用 DamageMask 并满足我们在 ProjectileDamage 中为接口实现所拥有的现有合约:

public class ProjectileDamage : MonoBehaviour, IDamage
{
    public LayerMask DamageMask => _damageMask;
    [SerializeField] private LayerMask _damageMask;
    …

这里是 _damageMask 变量的封装模式(告诉过你!)。

我们现在可以修订 HealthSystem 中的 HandleDamageCollision() 方法以实现掩码检查:

    internal void HandleDamageCollision
        (Collider2D collision, IDamage damage)
    {
        if (damage.DamageMask
            & (1 << gameObject.layer)) != 0)
        {
            TakeDamage(damage.DamageAmount);
        }
    }

这个 if 语句看起来有点熟悉;这是我们之前在 第六章 中用于我们的 Bullet 的相同的 这个对象是否在 LayerMask 中? 评估。所以,如果 ProjectileDamage.DamageMask 包含具有健康层的对象,那么才会调用 TakeDamage()

伤害是坏事。治疗是好事。让我们为可以治疗的事物做同样的事情。

什么可以治疗

我们将重复我们为造成伤害所做的事情,所以将 LayerMask 变量 HealMask 添加到 IHeal 接口:

internal interface IHeal
{
    int HealAmount { get; }
    LayerMask HealMask { get; }
}

PickupHeal 中实现更新的 IHeal 接口合约 – 封装 _healMask 变量:

public class PickupHeal : MonoBehaviour, IHeal
{
    public LayerMask HealMask => _healMask;
    [SerializeField] private LayerMask _healMask;
    …

作为对在 HandleDamageCollision() 方法中重复我们的做法的例外,我们不要在这里重复自己,通过也将层掩码检查代码添加到 HealthSystem.HandleHealCollision();相反,我们将层掩码评估提取到一个方法中,我们将给它一个非常好、易于理解的名字:IsLayerInLayerMask()(使用 Is 开头使它明显将返回一个布尔 truefalse 值,不是吗?):

    private bool IsLayerInLayerMask(
        int layer,
        LayerMask mask)
            => (mask & (1 << layer)) != 0;

实用方法(C#)

您可能会在其他类中需要这个 IsLayerInLayerMask() 检查,所以考虑创建一个新的静态类,用于此类实用方法,可以从代码库的任何地方使用。

或者,对于特定类型的操作,考虑添加一个 扩展 方法learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods.

让我们更新 HandleHealCollision() 并使用我们新的实用工具层掩码检查方法:

        private void HandleHealCollision(IHeal heal)
        {
            if (IsLayerInLayerMask(
                gameObject.layer, heal.HealMask))
            {
                ApplyHealing(heal.HealAmount);
            }
        }

更好的可读性!别忘了回到 HandleDamageCollision() 方法,重构层掩码评估以使用新的 IsLayerInLayerMask() 方法!

优化笔记 | 物理二维

我们还可以通过指定由其层处理的碰撞来控制对象之间的物理交互(编辑 | 项目设置 | 物理二维,选择 层碰撞 矩阵 选项卡)。

在本节中,我们创建了一个健康系统,任何游戏中的对象都可以使用它来接收伤害和恢复——它还提供了一个处理对象最终死亡/破坏的方法。您学习了如何通过不依赖于具体类引用的方式来使用接口以可扩展的方式将一切联系在一起。

我们还没有将我们的新 HealthSystem 添加到我们游戏的任何对象中。让我们在下一节中这样做,从玩家开始。

更新玩家和敌人以使用健康

不仅 Player 和敌人对象,任何对象都可以设置为使用 HealthSystem。这几乎不是什么不便;实际上,对象只需实现 IHaveHealth 接口。

分配具有健康的对象——IHaveHealth 接口

在健康系统 UML 图 (图 8**.1) 中,我们看到底部有一个具有健康的对象将实现 IHaveHealth 接口(在这里,一些有意义的命名)。在 Assets/Scripts/Interfaces 文件夹中创建一个名为 IHaveHealth 的新文件:

internal interface IHaveHealth
{
    void HealthChanged(int amount);
    void Died();
}

我们还没有为 Player 对象添加一个类,只有 PlayerController。我们不希望将健康添加到名为 controller 的东西上,因为这不符合单一职责原则——并且控制器的唯一关注点是移动。让我们现在通过在 Assets/Scripts 文件夹中创建一个名为 Player 的脚本来解决它:

using UnityEngine;
public class Player : MonoBehaviour, IHaveHealth
{
}

确保它实现了 IHaveHealth。您可以使用 IDE 的重构工具再次在这里。IHaveHealth 应该有那个非常有用的红色波浪下划线——所以,使用 IDE 重构在它上面 实现接口,您将得到以下内容:

public class Player : MonoBehaviour, IHaveHealth
{
    public void HealthChanged(int amount)
    {
        throw new System.NotImplementedException();
    }
    public void Died()
    {
        throw new System.NotImplementedException();
    }
}

在我们决定对这些方法执行什么操作之前,我们现在将其保持原样。您将在 throw 中收到提醒,表示在程序运行时发生了异常——在这种情况下,NotImplementedException: The method or operation is not implemented

使用 throw 时的注意事项

然而,请注意:抛出这些异常将导致调用方法中的程序执行停止——这意味着在HealthChanged()之后的任何语句都不会被执行!如果不确定,可以将throw语句替换为类似Debug.LogError("Player.HealthChanged() has not been implemented!");的内容。

对于Enemy也做完全相同的操作:

  1. Assets/Scripts文件夹中创建一个新的Enemy脚本。

  2. IHaveHealth添加到类声明中。

  3. 实现接口IHaveHealth的方法。

最后一步是将组件添加到相应的对象中:将Player添加到Player预制体中,将Enemy添加到所有敌人预制体中。当然,它们也都会添加HealthSystem,如图所示:

图 8.6 – 具有健康的玩家和敌人预制体

图 8.6 – 具有健康的玩家和敌人预制体

最大健康值的初始值只是测试的初始值。游戏测试将确定它们最终将落在什么值上,这取决于难度和平衡的游戏玩法。你做到了!

现在我们有了具有健康属性的对象,我们HealthSystem的最后一个部分也需要完成——处理健康变化。

处理健康变化

为了完成我们完全功能的HealthSystem,我们只需要处理具有健康属性的对象的健康变化。回到我们的HealthSystem类中,添加一个将持有具有健康属性的对象引用的变量,并在Awake()中使用GetComponent()调用获取对象引用:

public class HealthSystem : MonoBehaviour
{
    …
    private IHaveHealth _objectWithHealth;
    private void Awake()
    {
        …
        _objectWithHealth = GetComponent<IHaveHealth>();
    }

作为我们未来的自己,我们将重新审视HealthChanged()方法,并消除那些最后的残留UNDONE标记注释!如果 GameObject 上没有实现IHaveHealth接口的兄弟组件,我们将在控制台中发出警告(并使用return语句作为取消执行后续代码的一种方式),否则继续处理健康变化:

    private void HealthChanged()
    {
        if (_objectWithHealth == null)
        {
            Debug.LogWarning($"HealthSystem on " +
                $"'{gameObject.name}' requires a " +
                $"sibling component that inherits from " +
                $"IHaveHealth!", gameObject);
            return;
        }
        if (_healthCurrent > 0)
            _objectWithHealth.HealthChanged
                (_healthCurrent);
        else
            _objectWithHealth.Died();
    }

有了这些,我们的健康系统就完整了!它允许将健康添加到任何对象中,并赋予任何对象造成伤害或治愈的能力,而无需任何具体的类引用!接口获胜!

在创建健康系统时,我们覆盖了很多领域,并在几个类之间来回编写了很多代码,所以不要忘记,你始终可以参考书中 GitHub 仓库中本章的完整项目代码:github.com/PacktPublishing/Unity-2022-by-Example

按照我们的组合模式,让我们快速看看我们如何设置在HealthSystem交互时轻松添加不同行为(即组件)的能力。

使用 UnityEvent 添加行为

我们之前已经使用过 UnityEvent,用于 第四章 中提到的 TriggeredEvent 组件([B18347_04.xhtml#_idTextAnchor079])。它是灵活的,因为监听器可以通过代码注册或分配在 检查器 窗口中(你知道我是个粉丝),所以它将是我们需求的完美用例。

仅需要添加几个 UnityEvent 实例,以便在处理 IDamageIHeal 的碰撞时调用。让我们首先向接口添加一个方法声明:

internal interface IDamage
{
    …
    void DoDamage(Collider2D collision, bool isAffected);
}
internal interface IHeal
{
    …
    void DoHeal(GameObject healedObject);
}

正如你在声明中的差异所看到的,我们将在每个实现中稍作调整。DoDamage() 将传递两个参数,用于碰撞以及对象是否受到碰撞的影响(例如,它刚刚发生碰撞还是受到了伤害的影响?)。我们可以使用这个布尔值来改变诸如视觉效果(例如,小粒子效果与可观的粒子效果)等事物,而 DoHeal() 则只需传递正在被治愈的对象。

现在,让我们实现接口的更改,从 ProjectileDamage 类中的伤害开始。添加 UnityEventDoDamage() 方法:

public class ProjectileDamage : MonoBehaviour, IDamage
{
    …
    public UnityEvent<Collider2D, bool> OnDamageEvent;
    public void DoDamage(Collider2D collision,
        bool isAffected)
            => OnDamageEvent?.Invoke(collision,
                isAffected);
}

下面是前面代码片段中编写的实现的具体细节:

  • OnDamageEvent: 声明为一个具有两个参数的 UnityEvent 实例。Collider2D 用于获取对象之间的交点位置。isAffected 值表示是否由于碰撞而应用了伤害——这是来自层掩码评估的,我们将在下一分钟看到。

注意,我们在这里不会使用 event 关键字,因为它是一个 UnityEvent 实例——它不是一个委托类型,而是一个可序列化的类。否则,始终使用 event 关键字来强制事件模式,只有实现类应该调用!

  • DoDamage(): 这是一个在交互发生时由 HandleDamageCollision() 调用的公共方法,它的唯一责任是调用 UnityEvent 实例(传递参数)。

现在为 PickupHeal 也做同样的操作——使用接口实现更改。

public class PickupHeal : MonoBehaviour, IHeal
{
    …
    public UnityEvent<GameObject> OnHealEvent;
    public void DoHeal(GameObject healedObject)
        => OnHealEvent?.Invoke(healedObject);
}

下面是对这些代码更改的解释:

  • OnHealEvent: 声明为一个具有一个参数的 UnityEvent 实例。GameObject 实例只是受到治愈影响的对象。使用方法可以是简单地获取对象的变换位置以实例化对象或粒子效果。

  • DoHeal(): 就像伤害方法一样,这是一个在交互发生时由 HandleHealCollision() 调用的公共方法,它也仅负责调用 UnityEvent 实例(传递参数)。

最后一步是将公共的 Do 调用添加到 HealthSystem.OnTriggerEnter2D() 方法中。更新 HandleDamageCollision() 如下:

    internal void HandleDamageCollision
        (Collider2D collision, IDamage damage)
    {
        var isAffected = IsLayerInLayerMask(
            gameObject.layer, damage.DamageMask);
        damage.DoDamage(collision, isAffected);
        if (isAffected)
            TakeDamage(damage.DamageAmount);
    }

我们引入了一个局部布尔变量 isAffected 来获取 IsLayerInLayerMask() 的结果——然后我们可以使用这个变量代替多次调用 IsLayerInLayerMask()

然后,我们只需调用DoDamage(),并且只有当对象受到伤害对象的影响时才调用TakeDamage()

现在,像这样更新HandleHealCollision()

    private void HandleHealCollision(IHeal heal)
    {
        if (IsLayerInLayerMask(gameObject.layer,
            heal.HealMask))
        {
            heal.DoHeal(gameObject);
            ApplyHealing(heal.HealAmount);
        }
    }

与伤害不同,我们不在乎伤害对象是否有影响。如果它在HealMask中,我们将处理治疗。我们只需要调用公共的DoHeal() - 传递正在被治疗的物体 - 我们就完成了!

现在我们已经在ProjectileDamagePickupHeal组件上公开了一个事件,让我们重构一个早期的组合来破坏水钻石拾取。因此,我们有一个其用法的示例。

对破坏者进行重新组合

由于PickupHeal现在有一个在碰撞发生时触发的UnityEvent实例,我们可以改进收集水钻石对象时破坏其组合。我们之前使用了TriggeredEvent组件,但现在我们需要将Destroyer.DestroyMe()函数分配给OnHealEvent函数选择下拉菜单。

图 8.7 – 修改破坏者

图 8.7 – 修改破坏者

参考图8.7,让我们来了解一下这个变化:

  1. (A) – 点击小OnHealEvent标签以向列表中添加一个新的监听器。

  2. (B) – 将Destroyer组件拖到对象字段(使用其标题区域)。

    • 在函数选择下拉菜单中,选择破坏者 | DestroyMe( )
  3. (C) – 右键单击TriggeredEvent标题区域以弹出上下文菜单并选择移除组件

你最终会得到一个检查器窗口,看起来像最右边的图像——你完成了。简单易懂。

此外,我们不再需要担心TriggeredEvent组件的IsTriggeredByPlayer,因为DoHeal()只有在HealMask检查满足时才会被调用。

在本节中,我们已经创建了一个完全实现的健康系统,这是一个游戏规则的改变(是的,这是一个糟糕的玩笑)。再次,你学习了接口的力量以及我们如何快速向现有系统添加功能。我们还通过重构一些可重用组件来练习组合,以探索破坏治疗拾取对象的不同方法。

在下一节中,让我们通过引入波生成器来让一些讨厌的敌人来测试健康系统。

敌人波生成器

波生成器听起来可能很吓人,但它只是一个简单的脚本。我们需要从一个给定的位置和固定(或随机)的时间间隔实例化一个新的敌人。我们还将通过限制生成的敌人数量来确保事情不会失控。

因此,考虑到这一点,让我们来看看我们的新EnemySpawner脚本 - 在Assets/Scripts文件夹中创建它 - 看看你是否能指出我刚才提到的几个要求在哪里得到了实现:

using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private Enemy _enemyPrefab;
    [SerializeField] private float _spawnInterval = 5f;
    [SerializeField] private int _maxSpawned = 3;
    private int _objectCount = 0;
    private void Start()
        => InvokeRepeating(
            nameof(SpawnEnemy), 0f, _spawnInterval);
    private void SpawnEnemy()
    {
        if (_objectCount < _maxSpawned)
        {
            var enemy = Instantiate(_enemyPrefab,
                transform.position, Quaternion.identity);
            enemy.Init(DestroyedCallback);
            _objectCount++;
        }
    }
    public void DestroyedCallback() => _objectCount--;
}

让我们分解这个类——现在大部分内容应该看起来很熟悉:

  1. 声明一个用于将要生成的敌人 Prefab 的变量——我们在这里使用 Enemy 类型而不是 GameObject,因为我们稍后引用 _enemyPrefab 时,将直接引用 Enemy 类,而无需执行 GetComponent()

  2. 声明一个用于 _spawnInterval 的变量,它将是生成下一个敌人之前的延迟。

  3. 声明一个用于 _maxSpawned 的变量,它将是屏幕上(从这个生成器)同时存在的敌人总数。

  4. 声明一个用于 _objectCount 的变量,它跟踪当前已生成的敌人数量。

  5. 创建 Start() 方法——在这里,我们将简单地使用 InvokeRepeating() 在指定的生成间隔(_spawnInterval)重复调用 SpawnEnemy()

  6. 创建 SpawnEnemy() 方法——我们首先检查是否已经实例化了 _maxSpawned 数量的敌人,如果没有,就使用 Instantiate() 创建一个新的 Enemy

    1. 我们创建一个新的(使用 var 进行隐式声明的)局部 enemy 变量——从 Instantiate() 调用返回——这样我们就可以调用 Init() 并传入一个回调参数(作为伪构造函数)。这代替了通常的 C# 构造函数(使用 new 关键字创建的对象,如果你还记得,我们无法在 MonoBehaviour 中这样做)。

    2. 使用 _objectCount++ 增加已生成对象的数量。

  7. 定义传递给 Enemy.Init() 调用的 DestroyedCallback() 方法,以便在敌人对象被销毁时,可以减少当前生成的敌人数量——结果是在生成器实例化另一个敌人以保持 _maxSpawned 数量。

不要忘记对象池!

注意,如果我们有多个波次的敌人,我们确实希望通过引入对象池来优化这一点。请参阅 第六章

我们必须将 DestroyedCallback 绑定到 Enemy 类,因为它是通过 enemy.Init() 调用传递给实例化敌人对象的。现在让我们添加所有支持这些功能的内容;这并不多,所以打开 Enemy 脚本并添加以下内容:

public class Enemy : MonoBehaviour, IHaveHealth
{
    private event UnityAction _onDestroyed;
    internal void Init(UnityAction destroyedCallback)
        => _onDestroyed = destroyedCallback;
    private void OnDestroy()
        => _onDestroyed?.Invoke();
    …

在这里,我们有一个 UnityAction 实例,我们将使用它来在敌人对象被销毁时调用回调——你之前已经见过所有这些。

我们只需要实际销毁敌人对象,我们会在对象死亡时这样做,正如由 IhaveHealth 接口实现的 Died() 方法所指示的:

    public void Died() => Destroy(gameObject);

关于 DestroyedCallback 和为什么在敌人被销毁时我们不必 注销事件 的说明:这里的责任是颠倒的,因为 EnemySpawner 没有持有实例化敌人对象的引用。你只需要从可能成为无效引用的事件中注销(或 RemoveListener)。

让我们设置一个可以重复使用的 Prefab,作为预配置的敌人生成器。

创建敌人生成器 Prefab

在 Unity 中,在你的当前打开的场景中,创建一个新的空游戏对象在EnemyB Spawner 1中——我们可以为不同的敌人生成行为有不同的预制件。确保将其放置在环境中的地面水平上,因为生成器的变换位置将被用作敌人实例化点。将EnemySpawner组件添加到EnemyB Spawner 1对象中,并将Assets/Prefabs文件夹中的EnemyB预制件拖到敌人预制件字段中,如图 8.8 所示。

图 8.8 – 敌人生成器设置

图 8.8 – 敌人生成器设置

最后,将Assets/Prefabs文件夹中的EnemyB Spawner 1对象拖动到场景中。太棒了!

如果你现在进行敌人波次生成器的测试,它们将只是堆叠在一起,变得杂乱无章,并且无处可去——换句话说,根本不像机器人。让我们将生成与巡逻行为集成,以保持事物的有序性。

将生成与巡逻行为集成

如果你还没有注意到,因为我们从一开始就关注项目的代码结构并遵循良好的编程实践,维护和扩展代码以添加新功能一直很简单且直接。将我们的新波次生成器集成到现有的巡逻行为中也将是快速的工作。我们只需要添加一些东西来设置它。

首先,让我们看看实例化的Enemy B预制件是否有巡逻行为,如果有,则调用SetWaypoints()。为此,让我们修改EnemySpawner类中的SpawnEnemy()方法。在这里,我们可以看到我们再次使用TryGetComponent()来优雅地处理组件不存在的情况:

    private void SpawnEnemy()
    {
        if (_objectCount < _maxSpawned)
        {
            …
            if (enemy.TryGetComponent
                <IBehaviorPatrolWaypoints>(out var patrol))
                    patrol.SetWaypoints(
                        _waypointPatrolLeft,
                        _waypointPatrolRight);
        }

PatrolWaypoints类中,添加以下SetWaypoints()方法(或者,再次使用 IDE 的重构工具生成它),以便外部设置左右航点的私有变量(封装在起作用):

    public void SetWaypoints(
        Transform left,
        Transform right)
    {
        _waypointPatrolLeft = left;
        _waypointPatrolRight = right;
    }

此外,别忘了:我们需要在IBehaviorPatrolWaypoints接口中添加一个SetWaypoints()方法声明,以便它可以从EnemySpawner类中的引用访问:

public interface IBehaviorPatrolWaypoints
{
    …
    void SetWaypoints(Transform left, Transform right);
}

严肃地说,就是这样——三个简单的添加就将巡逻行为完全集成到我们的敌人生成器中。你只需要在场景中添加两个空的游戏对象作为这个敌人生成器的巡逻路径,一个用于左侧,一个用于右侧,并在检查器窗口中分配它们,如图 8.8 所示。

然而,你会注意到我们有一个问题(你进行了测试,对吧?)。敌人在巡逻时无法在航点之间相互穿过——这是一个简单的问题,可以通过移除它们之间的物理交互来解决。首先,我们需要一个层来设置敌人对象…比如说Enemy

编辑器窗口右上角的下拉菜单中选择编辑层…

图 8.9 – 添加敌人层

图 8.9 – 添加敌人层

在“敌人”下添加它。

现在我们可以通过指定由它们的层处理的碰撞来控制敌人对象之间的物理交互。使用“敌人”层(取消选中敌人/敌人):

图 8.10 – 物理二维层碰撞矩阵

图 8.10 – 物理二维层碰撞矩阵

当你现在在场景中测试敌人波生成器时,生成的敌人将会巡逻经过彼此。太棒了!

在本节中,我们创建了一个波生成器,以固定的时间间隔实例化新的敌人,并将其与现有的巡逻行为集成。我们通过解决敌人对象之间的物理交互来结束,使它们在巡逻时能够相互通过。

摘要

在本章中,我们介绍了实现一个任何游戏中的对象都可以使用的健康系统,该系统可以用来接收伤害、治疗以及处理对象的最终死亡/破坏。这个系统是通过使用接口设计的,以可扩展的方式将所有内容连接在一起,而不依赖于具体的类引用 – 接口的灵活性允许我们快速向现有代码中添加新功能。

我们继续创建了一个波生成器,它以固定的时间间隔实例化新的敌人,并将其与现有的巡逻行为集成。这允许添加更复杂的敌人行为,这为游戏增加了新的挑战。此外,我们还讨论了如何禁用对象之间的物理交互,这使得巡逻的敌人能够相互通过。

最后,我们进一步探索了组合,通过重构一些可重用组件来探索破坏治疗拾取对象的不同方法。通过这样的例子,强调了良好编程实践和利用接口构建灵活和可扩展系统的重要性。

在下一章中,我们将通过创建一个简单的任务系统来收集解决入口谜题的关键对象,以完成冒险游戏。我们还将引入一个新的事件系统,以保持我们的代码松散耦合。

第九章:完成冒险游戏

第八章中,我们首先创建了一个灵活的生命值系统,该系统可以被添加到任何对象中,使其具有生命值、受到伤害和恢复健康的功能。该系统是可扩展的,这意味着能够处理伤害和施加治疗的事物可以是任何东西,而无需修改HealthSystem类,因为我们使用了接口来实现行为(而不是具体的类类型)。现在对象能够受到伤害后,我们继续更新Player和敌人对象以使用生命值——因此,我们正在制作一个正在形成的真实游戏。

我们继续创建一个波次生成器,在固定的时间间隔实例化新的敌人,并与现有的巡逻行为集成。这使我们能够添加更复杂的敌人行为,从而为游戏增加新的挑战。

最后,我们通过重构一些可重用组件来进一步探索破坏治疗拾取对象的不同方法。通过示例强调了良好编程实践和接口在构建灵活和可扩展系统中的重要性。

在本章中,我们将通过创建一个简单的寻宝系统来收集关卡中解决位于星球表面栖息地站入口安全锁所需的钥匙来完成冒险游戏。我们还将引入一个新的全局事件系统,以保持我们的代码松散耦合。该事件系统将有效地管理代码库中各个寻宝系统组件之间的通信,因此我们首先处理这个问题。

在本章中,我们将涵盖以下主要主题:

  • 使用 C#创建一个事件系统以松散地绑定事物

  • 为收集钥匙任务创建寻宝系统

  • 解决钥匙谜题并赢得游戏

到本章结束时,你将能够创建一个跨不同类集成的寻宝系统,同时保持松散耦合(即,通过不使用外部(具体)类引用来减少依赖),并通过使用我们将创建的新可重用全局事件系统来实现可扩展性。你还将能够集成和定制一个用于你自己的谜题系统。

技术要求

要在本章中使用与本书项目中创建的相同艺术作品,请从本节提供的 GitHub 链接下载资源。

要跟随自己的艺术作品,你需要使用 Adobe Photoshop 创建类似的艺术作品。或者,你需要一个能够导出分层 Photoshop PSD/PSB 文件的图形程序(例如,Gimp、MediBang Paint、Krita 和 Affinity Photo)。

你可以从 GitHub 下载完整项目,链接为github.com/PacktPublishing/Unity-2022-by-Example

使用 C#创建一个事件系统以松散地绑定事物

在这里我们不需要 UML 图,因为设计相当简单。我们将使用 Dictionary 集合(一种特殊的 C# 集合)来存储我们分配的事件名称,并将事件回调处理程序添加到其中。当事件被触发时,所有添加的回调处理程序都将被调用。尽管我说这很简单,但我之前没有介绍它,因为还需要先介绍一些编程概念。

新的事件系统

由于 UML 图在这种情况下无法很好地说明 EventSystem 的功能,我决定创建以下图作为实现介绍的起点(见图 9**.1):

图 9.1 – EventSystem 图

图 9.1 – EventSystem 图

在查看以下代码之前,先进行一下快速的心理练习,看看你是否能从这个图中想象出代码应该是什么样子。

根据这个图,这是构成我们新事件系统基础类的模板,省略了一些我们尚未覆盖的细节;它是否与你预期的相似?

using UnityEngine;
using UnityEngine.Events;
using System;
using System.Collections.Generic;
public class EventSystem : MonoBehaviour
{
    private Dictionary<string, Delegate> _events
        = new();
    public void AddListener<T>(
        string eventName, UnityAction<T> listener)
    {
        // UNDONE: Subscribe handler to event name.
    }
    public void RemoveListener<T>(
        string eventName, UnityAction<T> listener)
    {
        // UNDONE: Unsubscribe handler from event name.
    }
    public void TriggerEvent<T>(string eventName, T arg)
    {
        // UNDONE: Invoke handlers for event name.
    }
}

你可能不知道 Dictionary 的声明会是什么样子——我们还没有介绍它——但你至少知道我们需要为它声明,对吧?

在这里,我们还有一些额外的事情要解释,并介绍一些新的 C# 项目,包括 DictionaryDelegate 和泛型类型(正如你可能注意到的,<T>T 在代码中随处可见):

  • Dictionary<string, Delegate>:C# 的 Dictionary 是一种包含 List 的集合类型,而 List 是一种包含单个列表的集合类型。

    让我们把这个问题用游戏开发术语来解释……C# 的 Dictionary 类似于一个魔法袋,你可以存储很多物品,只要它们有一个独特的名称,你就可以找到任何你想要的。你可以跟踪玩家在冒险中需要的所有酷炫物品,让你的游戏保持组织性和酷炫性!

    • string 类型,表示字典中项的键类型。我们将键设置为通过名称标识特定事件,并将事件处理程序方法添加到其中。

    • Delegate 类型,并且是存储在特定键项中的值。我们将值设置为可以添加方法处理器的委托类型,例如 UnityAction(注意,在这里,delegateUnityAction 的基类型)。事件可以通过事件名称触发,调用所有分配的事件处理程序方法。简单!

    • Dictionary,我们可以通过指定 new 关键字来分配一个新的空字典。这是 C# 在 Unity 2022.2 中的新特性,因为在之前,你不得不在这里再次明确地重述 Dictionary 以及键和值的类型——但你还必须为在 Inspector 视图中可序列化的变量做同样的事情!

字典(C#)

字典表示键值对的集合。它与列表集合类型具有相似的性质和方法。有关更多信息,请参阅learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2?view=net-8.0

  • AddListener(): 当我们向EventSystem添加事件时,我们将调用此方法。方法签名具有以下参数:

    • <T>: 在 C#中,有一个名为T的编程概念,它代表任何类型作为泛型类型参数。这允许你定义一个方法、类、参数或其他可以与调用代码指定的任何类型一起工作的东西,例如stringintfloatVector3GameObject等。这允许在不同的代码部分之间重用,而无需使用特定类型并重复相同的代码来支持这些类型。这听起来可能有点复杂,但希望通过前面的示例可以理解。

    • 我们使用泛型类型的用例是实现一个可以传递任何类型作为参数的事件委托。随着我们继续处理EventSystem的其余部分,这将会变得更加清晰。

    • eventName: 一个string类型的字符串,用于唯一标识已与EventSystem注册的事件。这是我们向特定事件添加额外监听器以及我们知道要触发哪个事件的方式。

    • listener: UnityAction<T>,这是事件委托。UnityAction应该很熟悉,因为它首次在第三章中介绍,作为一个用于更新 UI 的事件监听器。这里的区别在于我们添加了一个T类型的泛型参数,这意味着处理方法在调用时可以传递任何类型的单个参数。

  • RemoveListener(): 这用于从EventSystem中删除事件。方法签名与AddListener()的签名相同,因此我们在这里不需要重复。然而,将有一个明显不同的地方:我们将删除一个方法处理程序而不是添加一个。

  • TriggerEvent(): 当我们想要调用已通过AddListeners()添加方法处理程序作为监听器(当然)的特定事件时,我们将调用此方法。方法签名具有以下参数:

    • eventName: 同样,这是我们将用于调用方法处理程序(即添加的监听器)的事件名称字符串。

    • T arg: 我们将传递给被调用方法的参数值,以便它们可以接收和处理所需的数据。再次强调,由于T是一个通用类型,我们可以传递任何类型的参数(不错,对吧?)。

在前面的代码中,我说的是 事件名称,但我们不会在整个代码库中使用字符串字面量来表示事件名称,这样做太愚蠢了。相反,让我们创建一个 EventConstants 类,我们将使用它来表示事件名称。这就像我们在代码中通过添加 Tags 类来引用分配给游戏对象的标签时所做的(例如,Player)。

让我们通过创建以下 EventConstants 类来定义我们的第一个事件名称常量,该类保存在 Assets/Scripts/Systems 文件夹中:

public class EventConstants
{
    public const string MyFirstEvent = "MyFirstEvent";
}

事件名称将是 常量(在字面意义上和比喻意义上)将事物松散地联系在一起,所以我们不需要在观察和响应事件的类之间有具体的类引用。现在,我们可以独立测试我们的类——通过 Player 不需要指定对 UIManagerEnemy 等的引用。我们还可以将一个类移动到新的项目中,而不用担心引入不必要的类。

现在我们已经了解了事件系统模板以及我们将如何指定事件,让我们深入了解实现的方法的具体细节:AddListener()RemoveListener()TriggerEvent()

事件管理方法

首先,让我们看看 AddListener() 方法。为了回顾,AddListener() 负责将事件添加到事件字典中(如果它尚未存在,即注册事件),并将监听器添加到事件的处理方法 delegate 中,如下所示:

    public void AddListener<T>(string eventName,
        UnityAction<T> listener)
    {
        if (!_events.ContainsKey(eventName))
            _events.Add(eventName, null);
        _events[eventName] = (UnityAction<T>)_events[eventName]
            + listener;
    }

让我们分析一下这里的代码:

  • ContainsKey():在我们将事件添加到事件字典时,我们首先需要做的是查看是否已经注册了相同名称的事件(这很合乎逻辑,对吧?)。C# Dictionary 提供了几个方法来实现这一基本功能,这就像 C# List 一样,我们可以利用这些方法。

    具体来说,我们使用 ContainsKey() 并传入 eventName。如果键已经在字典中存在,这将返回一个布尔值 true,如果不存在则返回 false,因此我们可以使用 if 语句来评估并采取适当的行动。

  • Add():通过处理 ContainsKey() 返回的结果,我们将使用 Add() 方法(再次,类似于 C# List)将事件——使用事件名称作为键——添加到字典中。

    注意,对于值参数——就像在 Add(key, value) 中——我们为委托指定了 null。这仅仅是因为我们将在下一行将监听器分配给委托,这时这个事件是否刚刚添加并不重要。

  • _events[eventName]:我们可以通过简单地指定方括号中的键来从 _events 字典中获取特定事件的值(这类似于我们通过指定方括号中的索引(int)来返回数组的值)。

  • (UnityAction<T>): 在 C#中将一个类型放在另一个类型之前称为类型转换。由于_events[eventName]是一个Delegate类型,而UnityAction以委托为其基类型,我们可以通过类型转换(显式转换)将事件字典的值作为UnityAction来操作。在这里,<T>表示我们将使用一个泛型参数。

类型转换(C#)

C#是一种在编译时强类型(或静态类型)的语言,因此一旦声明了一个变量,就不能将其再次声明为不同类型。为了克服这一点,如果你需要将值复制到另一个类型的变量中,C#提供了各种类型转换操作,类型转换就是其中之一。

这里有一些额外的阅读材料:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/types/casting-and-type-conversions

  • + listener: UnityAction允许你添加额外的监听器;使用+(添加)运算符将使我们能够做到这一点。

    注意,仅使用+运算符是向可能为null或已具有监听器的委托添加监听器的正确语法,与之前不同,一直追溯到第三章,在那里我们在订阅事件时使用+=运算符来添加事件处理方法。

现在,让我们看看相反的情况。对于RemoveListener(),当不再需要事件监听器时(特别是当对象被销毁时),我们想要移除事件监听器,这样我们就不尝试调用无效的处理方法引用。这通常只是良好的实践——对于某些监听器类型,如果不这样做可能会导致内存泄漏:

    public void RemoveListener<T>(string eventName,
        UnityAction<T> listener)
    {
        if (_events.ContainsKey(eventName))
            _events[eventName] =
                (UnityAction<T>)_events[eventName] - listener;
    }

我们在这里不需要再次分解代码,因为它基本上与AddListener()相同。主要区别如下:

  • 如果事件不存在,我们不需要添加它;我们只关心它是否存在,以便我们可以移除一个监听处理方法。

  • 我们使用-运算符代替+运算符来移除指定的监听器处理方法

最后,我们有TriggerEvent()方法。到目前为止,这些方法的命名已经足够直观,所以接下来应该不会令人惊讶。然而,我们在这里做了一些不同的事情,以检索字典中eventName键的值来调用添加的处理方法:

    public void TriggerEvent<T>(string eventName, T arg)
    {
        if (_events.TryGetValue(eventName, out Delegate del))
            (del as UnityAction<T>)?.Invoke(arg);
    }

这就是事情是如何工作的:

  • _events.TryGetValue(): 你可能会发现这个语法很熟悉,因为我们已经几次使用了TryGetComponent()。非常简单,我们尝试获取指定eventName键的值,如果存在,我们将值作为名为delout参数返回。如果未找到,if评估将短路,因此只有当返回值时才会调用Invoke()

  • (del as UnityAction<T>):我们在这里引入了 as 操作符关键字。类似于我们使用 () 来进行类型转换,我们也可以使用 as 进行类型转换。特别是,当处理可空类型时,as 是一个好的选择,因为我们还使用空条件运算符 (?.),这样我们就不会错误地尝试在空委托上调用 Invoke()

as 操作符(C#)

as 操作符将对象转换为不同的类型,但如果转换失败则返回 null,而不是像其他类型转换技术那样抛出异常。

这里有一些额外的阅读材料:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/type-testing-and-cast#as-operator

  • Invoke(arg):这是我们如何将监听器委托中由 UnityAction<T> 声明指定的泛型参数 T 连接到回的。最后一部分,形成一个完整的循环,将是接收参数的处理方法的签名。当我们到达 创建收集钥匙任务的任务系统 部分时,所有这些都应该都清楚,我保证!

接下来,为了我们不会忽略如何从需要处理事件的类中访问事件系统的一个基本细节,让我们将事件系统变成一个 Singleton - 一个真正的 Singleton!

强制单例实例

我们的 EventSystem 将是一个 Singleton 实例,这样我们就可以从代码的任何地方访问它。然而,我们还没有实现一个完整的 Singleton 模式,其中只能保证存在一个实例。我们将通过强制执行该模式并销毁任何想要成为重复项的实例来解决这个问题。

我们在 第三章 中看到了一个基本的实现,我们只是将实例设置为一个静态变量,甚至没有尝试寻找和销毁被引入的额外实例(不好的做法)。现在我们来了。添加以下 Instance 公共静态变量声明和新的 Awake() 方法代码:

public class EventSystem : MonoBehaviour
{
    public static EventSystem Instance { get; private set; }
    …
    private void Awake()
    {
        if (Instance == null)
            Instance = this;
        else
            Destroy(gameObject);
        DontDestroyOnLoad(gameObject);
    }
    …

魔法发生在 Awake() 方法中,我们之前只是这样做:

private void Awake() => Instance = this;

我们现在可以检查我们的静态 Instance 是否已经被分配;如果没有,就分配它。但是,如果 Instance 已经被分配,一个想要成为重复项的实例将被添加到我们的 场景层次结构 中。让我们立即销毁它!

我们的 EventManager 需要一直存在,以便在游戏的整个生命周期中,从任何类中随时响应注册和触发事件,因此我们需要一种方法来保持其持久性。

有时候,编码过程就像一场游戏:找到这个,摧毁那个,保留这个。所以,如果你不觉得有趣,你就做错了什么!

EventSystem | 完整代码

要查看本章中 EventSystem 类的完整代码以及所有与事件相关的代码,请访问本书的 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch9/.

单例持久化

Unity 提供了一种方法来保持特定对象持久,无论当前场景如何,都能在加载新场景时存活下来,等等,这就是通过使用 DontDestroyOnLoad。只需调用它并传递你想要持久化的 GameObject 对象作为目标参数即可。在我们的情况下,在 Awake() 中,我们传递了 gameObject,它代表组件(脚本)附加到的当前对象。

额外阅读 | Unity 文档

DontDestroyOnLoad: docs.unity3d.com/2022.3/Documentation/ScriptReference/Object.DontDestroyOnLoad.xhtml.

系统 GameObject

被指定为 DontDestroyOnLoad 的对象必须位于 EventSystem 组件的根目录。我会把它放在场景层次结构的顶部,以保持一些可见的依赖关系顺序——这完全取决于你如何选择在这里组织事物。此外,记得你可以添加空对象,仅用于组织标题(别忘了将标签设置为 EditorOnly),正如在 图 9.2 中引用的 Systems 对象所示。2*:

图 9.2 – 系统根 GameObject

图 9.2 – 系统根 GameObject

在本节中,我们学习了如何创建一个全局事件系统,它允许我们的类保持松散耦合,因为我们不需要在另一个类中引用类型来响应触发动作。如果 EventSystem 组件的使用仍然不清楚,不要担心——在下一节中,我们将通过创建一个基于它的简单任务系统来立即提供一个示例。

为收集钥匙任务创建任务系统

现在我们有了我们的超级解耦的全局事件系统,我们将立即将其用于实际用途。再次参考我们的 GDD,我们知道玩家在某个时候必须收集一些关键的部件来解决谜题,以便他们可以前进:

冒险游戏中的次要游戏机制是什么? 玩家将在环境中寻找隐藏的钥匙部分。这些部件需要正确组合作为输入,以获得进入栖息地站的入口。

表 9.1 – GDD 任务参考

一个可以支持“收集一定数量的物品”的典型游戏系统是任务系统。一个可能更简化的方法,但也是一个基于系统的解决此问题的方法,可以是基本的库存系统。然而,任务系统将提供额外的机会来提供一个更完整的示例——特别是对于实现事件系统。

让我们快速看一下玩家在寻宝过程中将寻找什么,以及与它们互动以重新进入在星球上建造的栖息地站。Kryk’zylx 技术对我们来说是个谜,但 suffice it to say,他们在确保基地安全时喜欢挑战。邪恶植物实体散布的关键部件以及缺失部件的入口安全谜题锁都可以在以下图中看到:

图 9.3 – 钥匙和安全谜题艺术

图 9.3 – 钥匙和安全谜题艺术

我们现在的任务是编写寻宝系统代码,并完善实现该系统所需的游戏机制。那么,让我们开始吧!

寻宝系统

我将提供一个QuestSystem类的代码模板,类似于介绍事件系统的方式。然而,这次我将提供完整的实现,因为你会发现它与EventSystem有相似的设计。这次我也不会提供图表。我将把它作为一个挑战留给你,在本节结束时自己创建一个。

Assets/Scripts/Systems文件夹中创建一个名为QuestSystem的新脚本,并使用以下代码:

using UnityEngine;
using System.Collections.Generic;
public class QuestSystem : MonoBehaviour
{
    private Dictionary<string, bool> _quests = new();
    public void StartQuest(string questName)
    {
        if (!_quests.ContainsKey(questName))
            _quests.Add(questName, false);
    }
    public void CompleteQuest(string questName)
    {
        if (_quests.ContainsKey(questName))
            _quests[questName] = true;
    }
}

好的,不,你并没有经历似曾相识的感觉。QuestSystem代码的基本概念与EventSystem类似,其核心也是一个 C#的Dictionary

这里是分解:

  • Dictionary<string, bool>: _quests字典的声明将包含一个用于识别寻宝任务名称(类似于事件名称)的string键,而条目的值将为bool类型,作为任务是否完成的指示(即true表示已完成)。

  • StartQuest(): 开始一个寻宝任务意味着如果它尚未添加,则将其添加到字典中。寻宝任务将通过作为唯一参数传入的questName来识别,简单明了。

  • CompleteQuest(): 如前所述,我们将使用_quest值作为任务完成的指示器,因此如果指定的任务存在于字典中,则将其值赋为truebool的默认值为false,这就是为什么我们不需要在任何地方分配这个值)。

在这里,我们将再次重复如何访问QuestSystem,因为我们将再次使用 Singleton 模式。然而,主要的是,我们将尽可能通过全局事件系统解耦对QuestSystem的引用(即,没有紧密耦合的对象)。

将以下声明添加到Awake()方法中的公共静态Instance属性和Singleton 管理代码,如下所示:

    public static QuestSystem Instance { get; private set; }
    private void Awake()
    {
        if (Instance == null)
            Instance = this;
        else
            Destroy(gameObject);
        DontDestroyOnLoad(gameObject);
    }

使用 Singleton 模式解决了获取核心系统引用的大问题,但与更复杂的模式(例如服务定位器模式)相比,它也有一些缺点。在进一步进行之前,我们将解决这些缺点之一。

我们的系统存在一个潜在问题,因为我们要求在游戏开始时 EventSystem 实例对每个其他系统都是可用的——作为核心系统,松散耦合一切并保持良好。既然如此,我们必须确保它首先初始化。

脚本执行顺序

要设置脚本事件函数的执行顺序(例如,Awake() 消息事件),Unity 在 项目设置 中提供了 脚本执行顺序 赋值。简单来说,你可以为想要首先初始化的脚本设置一个较低的顺序号,并确保它在依赖于它初始化的其他脚本之前。

额外阅读 | Unity 文档

脚本执行顺序设置:docs.unity3d.com/2022.3/Documentation/Manual/class-MonoManager.xhtml

对于我们在这里的使用,我们需要 EventSystemQuestSystem 之前运行。

  1. 打开 编辑 | 项目设置… | 脚本 执行顺序

  2. 如果它们还没有在列表中,请使用右下角的加号(+)按钮将这两个脚本添加到列表中。

  3. 然后,点击并拖动它们到指示的位置:

图 9.4 – 项目设置中的脚本执行顺序

图 9.4 – 项目设置中的脚本执行顺序

  1. 完成后,点击 应用

额外阅读 | Unity 文档

Unity 还提供了一个使用属性指定脚本执行顺序的代码解决方案。该属性是 [DefaultExecutionOrder(int)],你可以装饰类声明并设置顺序值。

例如,将此属性添加到 QuestSystem 类声明中的样子如下:

[DefaultExecutionOrder(-500)]
public class QuestSystem : MonoBehaviour
{ …

说到执行,确保将 QuestSystem 组件添加到 EventSystem 中)。

那么,没有任务的任务系统又是什么呢?!

任务

如果你选择接受这个任务,你的任务将是找到并收集解决栖息地入口安全谜锁所需的缺失部件。代表实际任务及其要求的任务对象可以说是任何任务系统的基本部分。正如你在 脚本执行顺序 部分所看到的,保持活动任务列表相当简单——我们向列表中添加一个任务,并在完成时将变量设置为 true。简单易懂。

我们将要介绍的下一个任务系统部分将是任务代码。在我们开始之前,我们将确保我们定义的任何任务都将有一个易于分配的唯一标识符(即使在 检查器 视图中)。

名称一致性

我们现在需要解决的问题是如何在不使用魔法字符串的情况下保证任务名称的一致性,从代码的不同部分引用任务名称,并在检查器视图中选择它们。

我们已经看到了如何使用 enum,现在类似地,但这次我们不是为 FSM 的状态使用它,而是要为唯一的任务名称标识符使用它;再次提醒,我们不会依赖于字符串字面量!这类似于我们之前如何使用字符串常量(例如,事件名称和标签),但作为一个 enum 类型而不是常量,我们有一些额外的优势,包括在 检查器 视图中从可用任务列表中进行选择时!

Assets/Scripts/Quests 文件夹中创建一个名为 QuestNames 的新脚本。将所有默认脚本模板代码替换为以下代码:

public enum QuestNames
{
    // Quest name, unique ID.
    CollectKeysQuest = 10
}

这是我们定义任何新任务的地方。我们已经填充了 CollectKeysQuest 名称,并给它分配了一个唯一的 ID 10(这是一个尚未使用的任意数字)——确保在添加额外的任务名称时遵循此模式,分配名称和 ID。

我们将在继续我们的编码任务以完成这个任务系统时,看看任务名称是如何声明和引用的!

任务基类

是的,没错,另一个基类意味着我们在这里将使用更多的面向对象设计!而且因为我们已经在 第二章 中覆盖了这一点,所以对你来说这几乎不是什么不便,对吧?我们的特定任务将随后从我们将要编写的下一个新任务基类中派生出来,为实施任务提供一致性模板。

我们已经定义了任务名称。每个任务都需要包含任务名称,所以让我们首先设置我们任务的基础类。

Assets/Scripts/Quests 文件夹中创建一个名为 QuestBase 的新脚本:

using UnityEngine;
public abstract class QuestBase : MonoBehaviour
{
    public QuestNames QuestName => _questName;
    [SerializeField]
    private QuestNames _questName;
    …

首先要注意的是,QuestBase 类被声明为 public abstract class。这意味着我们无法直接使用这个类——我们无法在 QuestBase 中将抽象类添加到 GameObject 对象(即,这只是一个 基础模板)。

其次,我们可以看到 QuestName 的声明,它使用了我们之前编写的 QuestNames 枚举类型。我们封装了一个 private _questName 变量,并用 [SerializeField] 属性装饰它,以便可以在 检查器 视图中分配给枚举类型:

图 9.5 – 任务名称检查器视图的分配下拉菜单

图 9.5 – 任务名称检查器视图的分配下拉菜单

_questName 值通过 public QuestName 属性对其他类可用——表达式体 (=>) 声明该属性仅为获取器(即,您无法分配值)。

到目前为止,一切顺利。然而,这只是一些复习,因为我们在这里覆盖的内容都不是新的。对于我们将为 QuestBase 抽象类声明的第一个两个方法也是如此。我们将对 StartQuest()QuestCompleted() 使用 virtual 方法,因为继承的类可能需要覆盖提供的基本功能:

    …
    public virtual void StartQuest()
        => QuestSystem.Instance.StartQuest(QuestName.ToString());
    protected virtual void QuestCompleted(string questName)
        => Debug.Log($"Quest '{questName}' completed!");
    …

到目前为止,代码可能已经很直观了,但解释总是有帮助的。让我们来分解一下:

  • public virtual void StartQuest(): 这个方法有一个public访问器,因为它打算从外部类调用以触发任务的开始。我们调用任务系统的StartQuest()方法(通过其单例实例),并将唯一的任务标识符(即基于enum的任务名称)传递给它,以便将其添加到活动任务的Dictionary中,以便稍后参考。

    我们在方法签名中使用virtual,以防继承的类需要做更多的事情(比如,除了开始任务之外)。

  • protected virtual QuestCompleted(): 这个方法有一个protected访问器,因此它只能在类内部和派生类中调用——不能是外部类,因为确定任务完成逻辑应该基于特定任务的要求,而不是某些外部因素。

    再次强调,我们在方法签名中使用virtual,以防继承的类需要做其他事情(比如,为不同类型的任务实现不同的逻辑或行为,例如支线任务)。目前,我们只是将一条消息记录到控制台,表明任务已完成。

现在是时候享受乐趣了!我们将依赖事件系统来实现以下功能:监听任务何时完成。这也是为什么我们没有将QuestCompleted()方法声明为public的原因——QuestCompleted()是作为监听器传递给事件系统的处理方法:

    …
    private void OnEnable() => AddListeners();
    private void OnDisable() => RemoveListeners();
    protected virtual void AddListeners()
        => EventSystem.Instance.AddListener<string>(
            EventConstants.OnQuestCompleted, QuestCompleted);
    protected virtual void RemoveListeners()
        => EventSystem.Instance.RemoveListener<string>(
            EventConstants.OnQuestCompleted, QuestCompleted);
}

这里有一些需要解释的要点:

  • OnEnable(), OnDisable(): 我们分别添加和移除基类的事件系统监听器。如果派生类需要为特定任务添加额外的监听器,则可以覆盖AddListeners()RemoveListeners()

  • AddListeners(): 在这里,我们向事件系统添加一个监听器以监听任务完成事件。我们将使用一个字符串参数将任务名称作为参数传递给处理方法。

  • RemoveListeners(): 我们只需要移除在AddListeners()中添加的监听器——记住,移除事件监听器始终是良好的实践!

    注意,尽管简单地移除监听器不需要传递参数在逻辑上没有意义,但我们仍然需要它,因为委托定义必须与添加为监听器的处理方法的签名相匹配。

作为最后一步,将EventConstants.OnQuestCompleted的字符串常量添加到EventConstants脚本中。或者,在你的 IDE 中,OnQuestCompleted应该有一个红色的波浪线,表示定义未找到。接下来,使用你的 IDE 的重构工具生成变量(但确保它与其他事件名称常量保持一致)。

有了这些,QuestBase已经完成了!现在,准备创建一个从基类派生的特定任务类。

收集任务键

我们将需要收集的缺失的安全谜题关键部件称为“钥匙”,并且需要三个来完成我们的任务。这些是一个简单任务的基本要求,但我们仍然需要一种方法来不仅明确声明要求,还要评估和传达完成情况。

我们将首先在 Assets/Scripts/Quests 文件夹中创建一个新的脚本,名为 CollectKeysQuest,并从 QuestBase 继承,而不是从 MonoBehaviour 继承:

using UnityEngine;
public class CollectKeysQuest : QuestBase
{
    [SerializeField] private int _numKeysRequired = 3;
    private int _keysCollected = 0;
    …

我们还声明了完成这个任务所需的钥匙数量。_numKeysRequired 将是 private 的,这样就没有其他类可以访问它,但我们会使用 [SerializeField] 属性来设置这个值在 3))。

_keysCollected 将跟踪玩家收集的钥匙数量(private —— 这不是任何人的事),我们将通过事件系统(非常方便)来增加这个值:

    …
    protected override void AddListeners()
    {
        base.AddListeners();
        EventSystem.Instance.AddListener<bool>(
            EventConstants.OnKeyCollected, KeyCollected);
    }
    protected override void RemoveListeners()
    {
        base.RemoveListeners();
        EventSystem.Instance.RemoveListener<bool>(
            EventConstants.OnKeyCollected, KeyCollected);
    }
    private void KeyCollected(bool arg0)
    {
        _keysCollected++;
        // UNDONE: Evaluate quest completion.
    }
}

好的,这是一个熟悉的模式,但让我们还是澄清一下:

  • override AddListeners(): 对于这个特定的任务,我们需要监听玩家收集钥匙的时刻。使用事件系统,我们不需要了解任何关于 Player 对象或甚至收集钥匙所实现的脚本;我们观察到收集到了钥匙,并使用 KeyCollected() 方法进行处理。我们在这里使用 override 关键字,因为我们需要的功能比基类本身提供的更多:

    • base.AddListeners(): 说到基类提供的内容,我们仍然需要它!我们仍然可以通过使用 base 关键字来确保覆盖基类的方法——在派生类中访问基类成员。
  • override RemoveListeners(): 你明白了——保持良好的习惯,移除之前添加的监听器:

    • base.RemoveListeners(): 与 base.AddListeners() 相同
  • KeyCollected(): 我保证我们不会再次有要跟随的未完成的标记的面包屑路径!但,现在,当 OnKeyCollected 事件被触发时,我们只会增加 _keysCollected 变量:

    • bool arg0: 我们的 EventSystem 要求我们传递一个带有处理事件的手柄参数。对于收集钥匙的事件,我们不需要传递任何参数,但我们必须声明点什么!布尔值是最小的类型,我觉得这是我们这里能做的最少的恶。

    • EventConstants.OnKeyCollected: 一定要添加到 EventConstants 中以解决缺失定义的错误。

极好——我们有一个任务了!现在,如果只有我们能得到这个任务就好了!在我们的游戏中,没有那些站在街角、向任何经过的疲惫旅行者分发任务的刻薄 NPC,所以我们将利用我们已有的可重用组件来触发我们任务的开始。

任务提供者 GameObject

我们不需要为给玩家分配任务编写新的脚本;我们只需要开始收集钥匙任务。我们可以通过在环境中创建一个触发体积,使用我们非常有用且可重复使用的TriggerEvent组件,并在CollectKeysQuest组件上简单地调用公开声明的StartQuest()方法来轻松完成它。

这是在场景视图和检查器视图中的样子:

图 9.6 – 场景中的任务提供者对象

图 9.6 – 场景中的任务提供者对象

尝试构建组成任务提供者对象的对象和组件,并将其放置在玩家开始位置的附近。

你成功组装了吗?让我们回顾一下步骤:

  1. 在场景中创建一个新的 GameObject,并将其命名为Quest Giver – Keys Quest,然后将其放置在玩家开始位置的附近(参考图 9.6以获取示例)。

  2. (A) 将CollectKeysQuest脚本添加到新对象中。在任务名称下拉菜单中选择收集钥匙任务,因为我们只有一个任务,并将所需钥匙数量设置为三。

  3. (B) 接下来添加一个BoxCollider2D组件(使用true,因为我们不希望玩家与这个 GameObject 进行物理交互;我们只想将其用作触发体积。调整碰撞体的尺寸以确保玩家会与之碰撞)。

  4. (C) 添加一个TriggeredEvent组件,并将当OnTriggered()被调用时调用的方法连接起来。

  5. 点击小加号(将CollectKeysQuest组件拖到第一个事件字段,并从下拉列表中选择CollectKeysQuest.StartQuest()方法)。

  6. BoxCollider2D拖到第二个事件字段,从下拉菜单中选择BoxCollider2D.enabled,并保持复选框未勾选(即设置为false;这将禁用碰撞体,防止触发开始任务额外的次数)。

  7. 确保只有玩家可以触发任务开始,通过勾选IsTriggeredByPlayer

  • 作为最后一步,将Prefab对象保存在Assets/Prefabs文件夹中(通过从层次结构拖动到项目窗口)。这样,我们可以在以后快速将任务提供者放入场景中。

任务已接受!你不会开始一个不可能完成的任务,对吧?现在,让我们看看我们如何完成收集钥匙任务

任务完成

我们已经定义了一个特定的任务,我们已经给了玩家这个任务,现在我们必须在任务中取得进展以完成它。进展是在我们在游戏级别收集钥匙时取得的(你知道的,就像我们在本书的第一个项目中完成的收集游戏一样)。

CollectKeysQuest类中,正如你所知,当OnKeyCollected事件被触发时,会调用KeyCollected()方法。我们只让它增加变量,以跟踪收集到的钥匙数量。现在让我们通过评估任务要求是否满足来完成这个任务。

通过添加if块来完成KeyCollected方法,如下所示:

    private void KeyCollected(int keyId)
    {
        _keysCollected++;
        if (_keysCollected >= _numKeysRequired)
        {
            QuestSystem.Instance.CompleteQuest(
                QuestName.ToString());
            EventSystem.Instance.TriggerEvent(
                EventConstants.OnQuestCompleted, QuestName);
        }
    }

一个非常简单的if语句检查增加后的_keysCollected变量是否大于或等于(>=)完成任务所需的钥匙数量,该数量由_numKeysRequired变量定义(在then中我们将执行以下操作:

  • QuestSystem单例实例上调用CompleteQuest()并传入任务名称作为参数。我们需要在QuestName变量上使用ToString(),因为它是一个enum值,在内部,它被存储为int类型;我们需要一个string类型的参数。

  • 通过EventSystem单例实例触发OnQuestCompleted事件,并且再次传入QuestName作为参数。这同样是一个string类型。

未完成令牌任务已完成。太好了!

添加了任务事件常量

我们需要添加一些事件名称常量来支持我们刚刚为任务系统和收集钥匙任务添加的内容。作为一个回顾,以下是现在的EventConstants脚本应该看起来像什么。我为一些快速组织添加了一些注释,以保持事情整洁:

public class EventConstants
{
    public const string OnMyEvent = "OnMyEvent";
    // QuestSystem events.
    public const string OnQuestCompleted = "OnQuestCompleted";
    // Quests' events.
    public const string OnKeyCollected = "OnKeyCollected";
}

我们的任务系统已经很好地组合在一起,但我们仍然缺少一个最终的功能,那就是能够知道是否有一个已经开始的任务(即,添加到_quests字典中)已经完成(即,任务任务的要求已经满足,并且调用了CompleteQuest())。

任务状态

要知道任务的状态,请继续在QuestSystem类中添加以下IsQuestComplete()方法:

    public bool IsQuestComplete(string questName)
    {
        if (_quests.TryGetValue(questName, out bool status))
            return status;
        return false;
    }

你应该在这里识别出一个熟悉的模式与TryGetValue()(作为Dictionary中可用的一种方法)——我们已经多次用TryGetComponent()覆盖了类似的模式。TryGetValue()如果_quests字典中存在值,将返回true。然后,将返回的值设置为out变量status,以便立即被下面的if块代码消费。这非常方便,正如我所说的,我是一个大粉丝(甚至在我的代码中复制了这个模式)。

我们将继续通过立即从TryGetValue()调用中返回任务的状态来短路方法。否则,我们返回false,表示任务默认未完成,因为它甚至不在字典中。

现在我们可以从我们的QuestSystem单例实例中查询特定任务的状态,我们可以创建一个可重用的组件,用于在我们的游戏中相应地做出反应。

任务完成组件

当玩家到达栖息地入口时,我们需要检查任务是否完成。这将允许我们显示安全谜题锁,如果任务已完成,或者如果没有完成,提供其他操作。

Assets/Scripts/Quests文件夹中创建一个名为QuestHasCompleted的新脚本:

using UnityEngine;
using UnityEngine.Events;
public class QuestHasCompleted : MonoBehaviour
{
    public QuestNames QuestName;
    public UnityEvent OnQuestComplete;
    public UnityEvent OnQuestIncomplete;
    public void CheckQuestComplete()
    {
        if (QuestSystem.Instance.
            IsQuestComplete(QuestName.ToString()))
        {
            OnQuestComplete?.Invoke();
            return;
        }
        OnQuestIncomplete?.Invoke();
    }
}

这并不是我们之前没有见过的。QuestName 字段将允许我们在 UnityEvent 字段中指定我们想要知道完成状态的哪个任务。简单来说。

这是基于此新组件的对象在 检查器 视图中的外观:

图 9.7 – 谜题触发器对象设置

图 9.7 – 谜题触发器对象设置

现在让我们创建这个 谜题触发器 对象,并将其连接起来,以便当玩家进入触发体积时,如果 收集钥匙任务 已完成,它将禁用 PlayerInput(确保玩家不能再移动)并显示 谜题(解决入口安全谜题锁是玩家在这个关卡的目标)。

这是如何构建一个 任务是否已完成? 对象的:

  1. Puzzle Trigger 中创建一个新的空 GameObject,因为我们想在玩家到达栖息地入口时显示谜题。

  2. (A) 添加一个 QuestHasCompleted 组件,并从可用的任务名称下拉菜单中选择 收集钥匙任务

  3. OnQuestComplete() 分配以下事件(点击加号,从下拉菜单中选择 PlayerInput.enabled,并取消勾选复选框。以这种方式禁用 PlayerInput 将意味着不会处理任何输入以与玩家执行操作 – 我们不希望玩家角色再移动,因为现在应该是解决谜题的时候了。

  4. 以下分配是为了我们未来的自己;我们将使用对 钥匙谜题 对象的引用并将其激活(显示),以便玩家可以解决它 – 钥匙谜题 将在本章的最后部分添加,那时我们将重新访问这个分配。

  • (B) 添加一个 TriggeredEvent 组件,并将 QuestHasCompleted 组件分配给 OnTriggered 事件,然后从下拉菜单中选择 QuestHasCompleted.CheckQuestComplete()。确保 IsTriggeredByPlayer 被勾选。* 不要忘记添加一个 BoxCollider2D 组件并将其设置为 true;大小将根据栖息地入口进行调整,以便玩家在到达时与之交互。

对于放置,如果您还没有将栖息地入口添加到您的关卡中,现在就是时候了!我们在将所有内容组合在一起时并没有探索您关卡设计的每一个细节 – 自然地,您一直在做您的关卡设计作业:

  1. 将来自 Assets/Sprites/Object Elements 文件夹的栖息地入口精灵 entryway 放入环境中,并使用 Background 排序层。您可以使用我们之前建立的 Level (默认) | In Back 结构在 场景层次结构 中对其进行组织。

  2. 谜题触发器 对象放置在入口处,并相应地设置碰撞器大小(参考 图 9**.7 中的示例)。

正如我们在前面的代码和描述中看到的,QuestHasCompleted 组件有一个事件,当检查完成和不完整的任务状态时会被触发。我们已经填充了 OnQuestComplete() 事件,但还有一个 OnQuestIncomplete() 事件。现在我们不会在这里分配任何函数,但想象一下,我们可以向玩家显示一个对话框,说明任务的完成要求尚未满足,他们无法继续。

挑战 | 任务系统图

在使用图 9.1 中的事件系统图作为参考的同时,为任务系统创建一个基本对象引用图。不要过于关注你选择的代表每个部分的形状,但请保持一致,并使用相同的形状来表示相同的对象类型。如果你想进一步挑战自己,可以创建一个 UML 图!

我们已经准备好执行一个任务的生命周期了。现在,我们需要收集那些讨厌的关键部件!

收集钥匙

我们已经以几种不同的方式处理了玩家的可收集物品,所以可收集的关键物品将是一个熟悉的概念。唯一的区别是,我们现在将依赖于 EventSystem 来触发一个 收集钥匙 事件。正如我们已经知道的,CollectKeysQuest 任务正在监听 OnKeyCollected 被调用,所以这就是我们将实现触发它的地方。

如你可能也猜到的,我们将使用 Prefab 对象来制作谜题部件的钥匙(通过 Prefab Variant)。因此,这将需要一个组件来实现关键收集行为。

KeyItem 组件

首先,让我们准备我们将用来制作可收集关键部件的艺术作品。为了视觉参考,以下是我们将用于创建关键部件和在栖息地入口门上的占位符安全谜题锁的内容(如果你感到紧张,试着忽略正在蔓延的植物实体的藤蔓):

图 9.8 – 关键变体和入口安全谜题锁

图 9.8 – 关键变体和入口安全谜题锁

艺术资产

为了使用与本项目创建相同的艺术作品进行跟随,请从本书的 GitHub 存储库下载资产:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch9/Art-Assets

将安全谜题锁、谜题占位符图像和单个钥匙部件的艺术资产导入到 Assets/Sprites/Puzzle 中。对于提供的钥匙部件艺术作品,我已设置了以下属性以实现所需的大小和位置:

  • 500

  • XY 值,以便枢轴位于艺术品的中心:

图 9.9 – 关键艺术品 – 导入设置

图 9.9 – 关键艺术品 – 导入设置

现在,让我们编写收集关键物品的代码,以便我们准备好组装每个关键部件的预制件。

Assets/Scripts 文件夹中创建一个名为 KeyItem 的新脚本,代码如下:

using UnityEngine;
[RequireComponent (typeof(Collider2D))]
public class KeyItem : MonoBehaviour
{
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag(Tags.Player))
        {
            EventSystem.Instance.TriggerEvent(
                EventConstants.OnKeyCollected, false);
            Destroy(gameObject);
        }
    }
}

最后,是的——这就是我们特别触发OnKeyCollected事件的地方!

这个收集物品的脚本可能比之前的脚本更简单。我们使用OnTriggerEnter2D()来检测玩家何时进入触发体积。这意味着我们需要在附加KeyItem脚本的 GameObject 上添加一个作为兄弟的碰撞组件吗?是的,没错——我们需要一个具有CircleCollider2DCollider2D对象,保持它既高效又简洁。

此外,请注意,我们正在比较碰撞对象的标签——使用我们的Tags.Player常量——以确保只有玩家与触发体积交互。

让我们对事件系统的TriggerEvent()调用给予一些额外的关注,因为我们的事件系统需要一个额外的参数作为事件参数传入。我们必须传入某些东西

正如在处理方法介绍时解释的那样,布尔值是 C#中最小的数据类型(1 字节);我们将传入false作为类型的值——我们也可以自由地这样理解,“是或否,我会为这个事件传递一个参数值吗?否。”我们甚至不需要指定bool类型——例如TriggerEvent<bool>()——因为类型可以从参数值推断出来。

最后,我们将销毁键部件对象,因为我们已经收集了它,并且不再需要在关卡中使用它。我们在这里直接使用Destroy(gameObject)是为了简洁。不过,为了保持一致性,如果你愿意,现在你应该能够为自己连接可重用的Destroyer组件(挑战接受?)。

现在,是时候构建可收集的键物品预制件了!

KeyItem 预制件和变体

在我们的项目中创建可重用物品的预制件现在已经成为一种本能。让我们快速浏览一下创建新的可收集键物品预制件的步骤;然后,我们将制作具有所有三个键部件及其各自艺术作品的变体

  1. 将之前导入的key1 Sprite(位于Assets/Sprites/Puzzle文件夹中)从项目窗口拖动到场景层次结构中。

  2. 双击它,使其在(0, 0, 0)处聚焦(如果不是,请在检查器视图中的变换选项中重置,以确保没有偏移被保存在预制件中)。

  3. key1重命名为简单的key(在检查器视图顶部选中时,点击它第二次,或使用F2/Enter键)——当我们创建额外的键变体时,这将会变得有意义。

  4. 右键单击它,选择Key1

  5. CircleCollider2D组件添加到Key1对象上,并启用触发器。设置半径值,使其击中框略大于拼图部件 Sprite(参见图 9.10)。

  6. KeyItem脚本添加到父Key1对象上——没有需要配置的内容;所有行为都在代码中处理。

  7. Key1项目窗口中的Assets/Prefabs文件夹拖动到Key1对象,使其成为一个预制件。

现在,要创建额外的键件预制件作为 Prefab 变体,请按照以下步骤操作:

  1. 层次结构中选择Key1,然后按Ctrl/Cmd + D两次以创建它的两个副本。

  2. 分别重命名副本Key2Key3

  3. 对于Key2,在子键对象上更改SpriteRendererkey2。然后,对Key3做同样的操作,并设置key3

  4. Assets/Prefabs文件夹中拖动Key2。然后,在Key1预制件中但覆盖Key3

提示 | Prefab 编辑模式 | 变体

注意,当在Prefab 编辑模式中打开Prefab 变体时,所有覆盖项都由检查器视图左侧的蓝色指示器表示。

这里是我们刚刚创建的关键 Prefab 和变体:

图 9.10 – 键项预制件

图 9.10 – 键项预制件

玩家现在可以收集键件以完成任务,但我们应该如何在关卡中放置它们?首先,从场景中删除Key1Key2Key3对象;我们将生成它们。

QuestSystem | 完整代码

要查看QuestSystem类以及本章中所有与任务相关的完整代码,请访问本书的 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch9/

键生成器 – 随机性

让我们探索一种简单的方法来实现一些基本的随机实例化对象,以便每次游戏都不同 – 解决可重玩性对玩家参与度是有益的!

游戏设计中的随机性

在你的游戏开发之旅中,你将遇到游戏中的随机性主题 – 而不仅仅是纸牌游戏!随机性在游戏设计中的作用适用于整个章节 – 哎呀,整个书籍!所以,记住这一点,这将是你可以如何通过有效结果添加随机性到简单机制的最基本示例之一。在关卡设计中,我们将随机在由更多生成点标识的位置实例化三个键。

代码的结构将是接收一个KeyItem(对象)数组(即键件)和一个Transform(位置)数组(即放置在关卡中的点)作为输入,然后输出(即实例化)对象,按下一个随机选择的生成点顺序(确保不重复使用任何生成点)。

你想象过代码可能的样子吗?让我们看看。让我们一步步创建每个部分的代码。

首先,在Assets/Scripts文件夹中创建一个名为KeyInstantiator的新脚本。

我们首先声明包含键对象和生成点的数组:

using UnityEngine;
using System.Collections.Generic;
public class KeyInstantiator : MonoBehaviour
{
    [SerializeField] private KeyItem[] _keyPrefabs;
    [SerializeField] private Transform[] _spawnPoints;
    …

在这里,我们声明了两个数组:

  • KeyItem[] _keyPrefabs:由于以下原因,它已被序列化,以便可以在KeyItem中分配,而不是更通用的GameObject

    • 我们只想将包含KeyItem组件的 Prefab 分配给数组。

    • 在引用集合中的项目时,我们将消耗项目作为KeyItem类型,并避免进行GetComponent<KeyItem>()调用。

  • Transform[] _spawnPoints; 这已经被序列化,以便可以在检查器视图中分配;我们将分配放置在关卡中关键部件可能生成的位置上的 GameObject。考虑到游戏设计,请确保在关卡中放置超过三个,这样我们就不只是随机化三个相同位置出现的关键部件 – 你的限制将是设计或你的关卡(并且我会确保在恰好有几个感染机器人巡逻的地方放置其中一个)。

数组(C#)

数组是一种用相对的方括号([])声明的类型,它表示该类型的项目集合。数组中的项目通过它们的索引值来标识,它从零开始(例如,_spawnPoints[0]是集合中存储的第一个Transform)。

这里有一些额外的阅读材料:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/arrays/

接下来,我们将向Start()方法的实现中添加一个List类型,以处理从_spawnPoints数组中当前可用的生成点。我们为什么要这样做?我们已经有了一个array类型,现在又有一个List类型?是的。在 C#中,如果我们想要调整大小(即删除一个项目),数组就不容易处理,但List类型可以。

声明以下List类型,并添加带有生成点分配的Start()方法:

    private List<Transform> _availablePoints;
    private void Start()
    {
        _availablePoints = new List<Transform>(_spawnPoints);
        …

我们声明了一个List类型,并在Start()中用_spawnPoints值初始化它:

  • List<Transform> _availablePoints: 这是一个private成员变量,因为我们只会在类内部处理这些点。我们将使用它来确定可以实例化关键点的点。

  • Start(): 游戏开始时,我们将把关键部件散布在整个关卡中……所以我们将使用 Unity 提供的MonoBehaviour消息事件Start()来做这件事,是的。

现在是时候添加随机位置实例化了 – 在Start()方法实现中添加以下foreach循环:

        foreach (var item in _keyPrefabs)
        {
            var randomIndex = Random.Range(
                0, _availablePoints.Count);
            Instantiate(item,
                _availablePoints[randomIndex].position,
                Quaternion.identity);
            _availablePoints.RemoveAt(randomIndex);
        }
    }
}

好吧,让我们分析这个最后的部分:

  • foreach (var item in _keyPrefabs): 我们使用foreach来迭代在检查器视图中分配的可用KeyItem Prefabs(我们所有的三个关键 Prefab/变体部件)。

  • Random.Range(0, _availablePoints.Count): 这是一种魔法吗?不,这是 Unity 的int值,然而,在我们的用例中;否则,当使用float值时,它是包含第二个数字的)。我们不希望使用Count值,因为数组索引是从零开始的(因此我们否则必须指定Count - 1):

    注意,我们每次迭代都会获取_availablePoints.Count。那是因为,在下面两行中,我们将通过返回的randomIndex值从List中删除随机选择的点,因此它不会被再次使用。

额外阅读 | Unity 文档

Random.Range(): docs.unity3d.com/2022.3/Documentation/ScriptReference/Random.Range.xhtml

  • Instantiate(): 我们将数组中的当前 item 在随机选择的出生点位置 randomIndex 处实例化到场景中,旋转为零(即 Quaternion.Identity)。

  • RemoveAt(randomIndex): C# List 类型提供了一个删除方法,不仅可以从集合中删除一个项目,还可以调整其大小。因此,Count 属性反映了剩余项目的数量。

当这样分解时,听起来很多,但这只是一个简短而甜美的随机放置脚本。

最后一步当然是设置实例化器在我们的场景中,以便可以将关键部件放置在游戏关卡中。

实例化器场景对象设置

这里我们要做的是在我们的场景中设置新的 KeyInstantiator 脚本,以便我们的钥匙可以随机生成供玩家寻找:

  1. 创建一个 KeyInstantiator 组件。

  2. Project 窗口的 Assets/Prefabs 文件夹中到 KeyPrefabs 字段。

  3. 然后,在关卡中放置 – 超过三个! – 代表钥匙可能出生位置的 GameObject 之后,在 Hierarchy 中选择它们,并将它们拖到 Inspector 视图的 SpawnPoints 字段(别忘了你可以锁定 Inspector 窗口,这样在您选择要分配的对象时它不会改变)。

图 9**.11 展示了前面步骤的结果:

图 9.11 – 关键实例化对象设置

图 9.11 – 关键实例化对象设置

你可能已经注意到了前一个图中的粉色钻石。我已经将一个粉色钻石图标(可在 Inspector 视图的顶部分配)分配给关键出生点对象,以便我在进行关卡设计时在 Scene 视图中容易找到它们。我还将关键出生点分组在 Hierarchy 中的父 Key Spawn Points 对象下。

有了这个,现在当游戏开始时,我们的关卡中就有了随机放置的关键部件。接下来,我们将在解决钥匙谜题之前解决一个绘图问题!

实例化精灵绘制顺序

关键部件将在 Default0 处实例化,对于默认值。因为我们已经将环境设置为从“深度层的中心”起源的 默认对象,我们可以确信实例化的部件不会被遮挡。如果需要,这可以在实例化时进行补偿,但如果我们对环境布局稍加注意,就可以简单地避免这种情况。

在本节中,我们学习了如何创建一个任务系统,创建具有独特属性和完成要求的任务,将任务分配给玩家,并查询任务状态以推进游戏玩法。我们还看到了如何利用事件系统在其基础上构建其他系统。

在下一节中,我们将集成滑动拼图作为栖息地入口的安全锁系统,解决拼图即可赢得游戏。

解决关键谜题并赢得游戏

我们可以在这里花很多时间设计一个新颖的谜题用于安全锁系统。但这超出了本书的范围,也不会提供我想覆盖的学习机会 – 那就是在你游戏中使用第三方资产。这并不是说你不应该努力在你的游戏中引入新的原创想法 – 任何能让你区分游戏并给玩家带来独特体验的方法都是值得的!

我们将使用众所周知的滑动拼图作为栖息地入口的安全谜题锁。

滑动拼图

我之前只是简要提到了Unity Asset Store,但现在我想给它一些应得的关注。Unity Asset Store 包含了 Unity 和第三方提供的丰富资源。你可以找到几乎所有你为游戏所需的东西,包括预制的系统、框架、角色、动画、2D 和 3D 艺术资源、音乐和音效、VFX 等等,几乎涵盖了你能想象到的每一个类型和风格。

然而,整合来自不同供应商的解决方案和资源并不总是那么简单。本节将致力于利用现有预制资产的价值,并识别一些你可能需要解决的问题,以便它们能在你的项目中正常工作。你可能认为我暗示了第三方资产的质量水平,表明它们并不好 – 虽然在某些罕见情况下这可能确实如此(如往常一样,买者需谨慎),但问题可能完全与资产无关,而是与新技术版本发布带来的技术和进步有关。而不是继续在抽象层面上讨论,让我们继续前进,看看我将提供的具体例子。

作为参考,以下是我们将创建的滑动拼图,拼图块已经打乱(参见图9.3查看未打乱的版本):

图 9.12 – 滑动拼图打乱后的拼图块

图 9.12 – 滑动拼图打乱后的拼图块

如前所述,我们将利用 Unity Asset Store 中的一个现有资产,快速整合滑动拼图功能,这将包括将图像切割成拼图块、打乱拼图块、响应用户滑动拼图的输入,以及计算何时拼图被解决。如果我们必须独立开发,所有这些要求都需要更多的时间来创建、编码、调试和测试。Unity Asset Store 的资产通常具有数十位(在某些情况下是数百位)开发者在其项目中使用这些资产并报告错误和差异给资产开发者的好处 – 你可以直接从他人的努力中受益。

不再拖延,我们将使用一个名为滑动拼图游戏的免费资源,由 Hyper Luminal Games 提供(assetstore.unity.com/packages/templates/packs/sliding-tile-puzzle-game-41798):

图 9.13 – 滑动拼图游戏

图 9.13 – 滑动拼图游戏

由于Unity 脚本 API的变化,以及为了支持通用渲染管线URP),即我们基于该项目的时间(早在 2016 年,Unity 5),我们需要执行几个步骤才能在 Unity 2022 项目中使用该资源。

让我们开始获取资源并将其导入到项目中。

导入滑动拼图资源

在完成购买资源或点击添加到我的资源的过程后,你可以在 Unity 资源商店中使用在 Unity 中打开按钮。或者,你可以在 Unity 编辑器中打开包管理器窗口,从下拉菜单中选择我的资源,在列表中找到资源,点击下载,然后点击导入

根据我的经验,大多数资源都是设计为导入到你的现有项目中的。然而,一些资源作为完整项目提供,因此不能直接导入到你的项目中。滑动拼图资源需要额外的步骤才能导入到你的项目中,因为它是一个完整项目,最好不要覆盖任何当前项目设置!

让我们一步步来操作;这不会花费太多时间:

  1. 资源商店页面或包管理器导入滑动拼图资源将产生以下导入完整项目警告对话框:

图 9.14 – 导入完整项目对话框

图 9.14 – 导入完整项目对话框

  1. 点击切换项目按钮以创建一个临时项目,该资源将被导入其中。Unity 将自动生成一个临时项目名称。当我们从该项目中提取资源完成后,我们将删除该项目,所以请放心。

  2. 当 Unity 打开并完成导入后,你将在项目窗口中找到Assets/HyperLuminal文件夹。

  3. 右键点击SlidingTilePuzzle文件夹并选择导出包…。这将打开一个导出包对话框,在这里你可以更改导出内容。我们希望包含所有内容,所以点击右下角的导出…按钮。

  4. 当文件保存窗口打开时,将.unitypackage文件名输入为SlidingTilePuzzle,并选择一个易于访问的文件夹;我们很快将从这个相同的文件夹导入。

  5. 关闭 Unity。你将看到一个保留项目?对话框。你可以安全地点击忘记按钮,因为我们不再需要它:

    注意,即使你确认忘记了它,你仍然可能在 Unity Hub 中看到一个错误的项目,代表这个临时项目。如果是这样,请将其删除并删除项目文件夹。

  6. 现在,回到我们的游戏项目中,让我们通过从我们之前保存的位置打开SlidingTilePuzzle.unitypackage来导入保存的.unitypackage文件:

图 9.15 – 导入 Unity 包对话框

图 9.15 – 导入 Unity 包对话框

  1. 所有项目都应标记为,除非我们之前已导入该包。请继续点击导入,以便我们继续集成滑动拼图。

小贴士 | 项目组织

资产供应商会在项目文件夹结构中自行选择其资产的位置,因此导入许多第三方资产可能会变得有些杂乱无章。为了在项目文件中保持一定的理智,我建议将所有第三方资产放在这个Assets/Third Party文件夹下。

例如,在图 9.15中,你可以看到SlidingTilePuzzle,我们将将其导入到Assets/HyperLuminal文件夹。请创建一个新的Assets/Third Party文件夹,并将HyperLuminal文件夹作为其子文件夹。问题解决。

现在,让我们更新资产,使其符合我们的 Unity 2022 项目要求。

更新拼图瓷砖着色器

我们的首要任务是更新从内置的遗留渲染器到 URP 渲染器。在 Unity 5 中,我们只有内置的渲染器,因此我们需要将与此资产相关的渲染相关项目进行转换是有意义的。

如果你现在打开Assets/Third Party/HyperLuminal/SlidingTilePuzzle/Scenes文件夹中的SlidingTilePuzzle示例场景并进入播放模式,你会看到一个巨大的粉色方块。粉色(或洋红色)是 Unity 用来表示材质或着色器错误的颜色 – 除非你专门创建了一个这个颜色的对象,否则在场景游戏视图中看到它通常不是什么好事。

该资产采用预制体方法来制作你想要的滑动拼图类型,并为3x34x45x5滑动拼图提供预制体 – 我们将制作一个 3x3 滑动拼图,并使用特定的3x3预制体。按照以下步骤更新渲染器特定问题:

  1. Assets/Third Party/HyperLuminal/SlidingTilePuzzle/Prefabs文件夹中,复制SlidingTile_3by3预制体并将其重命名为SlidingTile_3by3_URP

  2. 双击SlidingTile_3by3_URP进入预制体****编辑模式

  3. 找到ST_Puzzle Display组件,将拼图着色器Mobile/Unlit更改为Packages/Universal RP/Shaders/2D/Sprite-Unlit-Default – 你必须从Packages中拖动这个着色器。

  4. 保存预制体。

我们还需要对渲染器问题进行一次更新,那就是拼图瓷砖本身,但首先,我们需要一个新的Universal RP Sprite材质。按照以下步骤更新资产:

  1. Assets/Materials 文件夹中创建一个新的 材质,命名为 PuzzleTile 1 并选择它。

  2. 检查器 视图的顶部 着色器 下拉菜单中,选择 通用渲染管线/2D/Sprite-Unlit-Default 着色器。

  3. Assets/Third Party/HyperLuminal/SlidingTilePuzzle/Prefabs 文件夹中,我们还有一个 SlideTile 预制体。复制它并将其重命名为 SlideTile_URP

  4. 双击 SlideTile_URP 进入 预制体编辑模式

  5. 找到 MeshRenderer 组件并将 Element 0 更改为 PuzzleTile 1

  6. 保存预制体。

现在,滑动拼图 资产的渲染问题已经更新,我们可以在 Lit 2D (URP) 场景中工作了!

我们在更新中只有一个问题,那就是交互性——也就是说,能够根据玩家输入滑动拼图。

更新输入系统

新输入系统之所以被称为新,是因为……嗯,它是新的。Unity 5 只有一个传统的 InputManager 系统;当然,由于我们在项目中使用新的输入系统,我们需要进行一些更改以支持它。所有渲染器的更新都是在编辑器中完成的,但现在,我们得更改一些代码。

进行以下代码更改:

  1. Assets/Third Party/HyperLuminal/SlidingTilePuzzle/Scripts 文件夹中找到 ST_PuzzleTile 脚本,并在你的 IDE 中打开它。

  2. 在脚本顶部添加一个新的 using 语句。我们需要这个 UnityEngine 命名空间来支持代码更改:

    using UnityEngine.EventSystems;
    
  3. IPointerClickHandler 接口添加到类定义中,以支持新输入系统的 pointer click 方法:

    public class ST_PuzzleTile : MonoBehaviour, IPointerClickHandler
    
  4. 在脚本底部,将 void OnMouseDown() 替换为以下内容:

    public void OnPointerClick(PointerEventData eventData)
    
  5. 保存脚本并返回到 Unity 编辑器;你完成了。

不幸的是,你不能再使用供应商的 SlidingTilePuzzle 示例场景来测试滑动拼图。

要测试更新以及每个你想使用滑动拼图的场景的最终更新要求,请按照以下步骤操作:

  1. 创建一个新的场景(文件 | 新建场景Ctrl/Cmd + N)并选择 Lit 2D (URP) 新场景模板。

  2. PhysicsRaycaster 组件添加到 主摄像机

  3. UI Event System 添加到场景中。确保更新 StandaloneInputModule 组件以支持新输入系统。

  4. SlidingTile_3by3_URP 预制体添加到场景中。

  5. 进入 播放模式。测试。享受。

这样就完成了对 滑动拼图 进行 URP 渲染和新输入系统现代化的所有更新要求。还不错。

正如你所见,Unity 资产商店是一个极好的资源,拥有许多才华横溢的资产发布者。它可以帮助你快速构建游戏原型、打磨你的游戏,并在早期创建垂直切片(以寻找你的项目出版商或投资者)。

然而,我们需要进行一个额外的更改,以便将滑动拼图集成到我们的游戏玩法中。那就是添加一个当安全拼图锁被解决时的事件,以便我们可以触发适当的动作 – 在这一点上,一个合理的动作是授予玩家进入栖息地站点的权限。芝麻开门!

添加完成事件

在这个任务中,我们不会涉及任何新的内容。我们已经以几种不同的方式添加了事件。这次我们的选择将是一个UnityEvent事件,这样我们就可以在检查器视图中设置触发处理程序。

Assets/Third Party/HyperLuminal/SlidingTilePuzzle/Scripts文件夹中找到ST_PuzzleDisplay脚本,并在您的 IDE 中打开它。然后,通过以下添加修改脚本:

  1. 在脚本顶部添加一个新的using语句,如需声明我们的事件:

    using UnityEngine.Events;
    
  2. 添加一个在拼图完成时被触发的public UnityEvent事件:

    public class ST_PuzzleDisplay : MonoBehaviour
    {
        …
        public UnityEvent OnPuzzleComplete;
    
  3. 通过在if(Complete)块内添加OnPuzzleComplete调用行来修改CheckForComplete()方法:

    public IEnumerator CheckForComplete()
    {
        …
        // if we are still complete then all the tiles are correct.
        if(Complete)
        {
            Debug.Log("Puzzle Complete!");
            OnPuzzleComplete?.Invoke();
        }
        …
    

我们现在有一个方便的方式来响应入口处安全拼图锁被解决/完成。

当我们在栖息地入口处设置QuestHasCompleted组件时,我们为我们的未来自己留下了未完成的事情。现在我们是我们的未来自己,因此我们可以完成OnQuestComplete事件分配,并向玩家展示我们的安全拼图锁。

设置新的拼图预制件

是的,没错 – 我们还需要另一个预制件来表示滑动拼图锁。我们现在将设置它,以便在收集到所有三把钥匙并到达栖息地入口时向玩家显示 – 使用我们的图像。

按照以下步骤创建新的预制件:

  1. SlidingTile_3by3_URP预制件拖动到(0, 0, 0)

  2. 右键单击并选择Key Puzzle Lock – 你将得到实际的拼图作为子组件(是的,这是我们标准的预制件结构方法,如果你还不确定的话)。

  3. Assets/Sprites/Puzzle文件夹中找到key_puzzle1-complete_512图像(这是我们在之前的KeyItem 组件部分中导入的图像之一),并在512中更改其导入设置

  • 找到ST_PuzzleDisplay组件并分配以下内容:

    1. 拼图图像key_puzzle1-complete_512

    2. 拼图缩放0.70.70.7图 9.16 – 滑动拼图配置

图 9.16 – 滑动拼图配置

  1. 现在,禁用SlidingTile_3by3_URP对象。是的,你听到的没错;我们将禁用带有ST_PuzzleDisplay组件的对象,因为我们只想在玩家收集到所有三把钥匙并到达栖息地入口时显示拼图并执行其功能。

  2. Assets/Prefabs文件夹拖动以创建拼图锁预制件。

现在我们已经准备好了定制的安全拼图锁!最终的设置确保它在需要时在栖息地入口处显示。

将拼图锁添加到入口处

我们已经在栖息地入口处设置了我们的拼图触发器。现在,让我们将其安全拼图锁添加到该位置,以便它在入口门处显示并可交互。由于一切都已经设置好并准备就绪,这是一个简单的两步过程:

  1. 钥匙拼图锁预制件放置在关卡中靠近、略微上方或正好在栖息地入口门处。您可以参考图 9**.12了解我放置的位置:在门上,正好在玩家角色头部上方。

  2. 通过以下步骤显示由QuestHasCompleted组件的OnQuestComplete()事件触发的滑动拼图:

    1. 通过点击小加号(SlidingTile_3by3_URPGameObject的子对象 | SetActive(bool)从函数下拉菜单,并勾选复选框以传递true),为OnQuestComplete()添加一个新条目作为PlayerInput.enabled的附加操作。

就这样!你现在可以完全测试任务生命周期了,收集散布在关卡中的三个钥匙以完成任务,触发已完成任务的的事件,并解决游戏中的滑动拼图。太棒了!

剩下的就是我们在解决入口拼图锁时需要做的事情——即胜利。

胜利

当安全拼图锁被解决后,我们就可以进入栖息地站。我们刚刚添加到Sliding Tile Puzzle Game代码中的OnPuzzleComplete() UnityEvent事件被触发,所以这是我们为胜利状态做些事情的机会。

我的游戏设计计划和本书项目的计划是在第十章中继续游戏玩法,在那里事情将开始变得更加深入。我并不是在比喻意义上说这个;下一章引入的项目将是一个 3D第一人称射击FPS)游戏。现在,我们将实现一个漂亮的黑白电影淡入,以及待续…。但是,我们如何在不知道大量同步线性时间代码的情况下解决实现淡出和在屏幕上显示文本序列的问题呢?让我们看看。

时间线

时间线不是 Unity 2022 的新特性。它自 Unity 2017 以来就存在了,但我感觉它是一个被低估的核心特性,没有得到游戏开发者应有的关注。然而,对于电影内容创作者来说,它却是一个变革性的功能,因为时间线允许轻松创建影响场景中几乎所有对象的线性序列。

时间线基于两个协同工作的元素:一个基于时间线文件资产的资源和一个可播放导演组件。需要注意的是,时间线实例是场景相关的。

附加阅读 | Unity 文档

时间线:docs.unity3d.com/Packages/com.unity.timeline@1.8/manual/index.xhtml.

我们将利用时间线简单性的纯粹力量,线性地影响一组对象,以消除这个结束淡出。让我们首先创建我们的时间线实例:

  1. 首先,在项目中创建一个位置来存储时间轴资产。创建一个名为Assets/Timelines的新文件夹。

  2. Ending Timeline内通过右键单击并选择创建 | 时间轴

  3. 我们现在可以通过在项目窗口中双击结束时间轴资产来打开时间轴窗口。

  4. 如果你习惯于在的时间尺度上工作,请使用时间轴窗口右上角的齿轮图标选择(如图 9.17.17 所示)。

  5. 通过将结束时间轴资产拖动到场景层次结构中完成时间轴的创建,以创建基于场景的实例。

我们将使用两个 UI 小部件来实现淡出效果和标题,按照以下步骤操作:

  1. Image组件的根组件中添加Canvas组件以填充屏幕。我们不需要特定的图像;默认的背景精灵就足够好了。

  2. 右键单击RectTransform400

  3. TextMeshPro – Text (UI): 待续…

最后,我们将这些 UI 小部件拖入时间轴以设置序列。按照以下步骤添加小部件并定义它们各自的序列:

  1. 确保在时间轴窗口中可以看到结束时间轴。如果时间轴窗口没有打开,可以从窗口 | 序列 | 时间轴打开它,然后在层次结构中单击Endling Timeline实例。

  2. Image 0(透明)拖动到255(不透明)处,持续 1.5 秒:

    1. 点击红色开始录制圆形按钮(000000),使用0设置第一个关键帧。

    2. 255拖动以设置第二个关键帧。

    3. 停止录制。

  3. 文本(TMP)拖动到时间轴窗口的轨道列表部分,并在提示时选择添加激活轨道

    我们希望文本在淡出完成后显示心跳,因此将Active剪辑在时间轴上拖动到 1.8 秒处。Text (TMP)绑定的 GameObject 将仅在时间轴剪辑的范围内活动,并在其外停用。文本部分完成。简单易懂。

要在游戏视图中预览时间轴序列的结果,请单击播放按钮或按空格键:

图 9.17 – 时间轴结束淡出标题

图 9.17 – 时间轴结束淡出标题

如果你输入PlayableDirector组件是在我们将它拖入场景时添加到时间轴实例的。

检查器视图中选择PlayableDirector组件的值:

  • PlayOnAwake = false

  • WrapMode = Hold

我们还想要确保在编辑器的设计时间中 UI 小部件没有可见状态。这样,它们就不会在我们触发时间轴播放之前显示。对场景中的 UI 小部件值进行以下更改:

  1. 对于 UI Image 0

  2. 对于 UI 文本(TMP)对象,停用对象(在检查器视图顶部取消勾选复选框)。

由于我们已经在时间线中记录了颜色值和活动状态,因此我们可以安全地在场景中设置这些值,而不会影响它们的序列行为。

现在唯一剩下的事情就是在滑动拼图完成时激活我们的结束序列。在层次结构中找到SlidingTile_3by3_URP对象,在ST_PuzzleDisplay组件上添加一个动作到OnPuzzleComplete: Runtime OnlyPlayableDirector.Play()

待续——或者第一幕的结束,由你决定。无论如何,这仅仅是对时间线的一个冰山一角介绍——它是一个极其强大的场景、电影和游戏或音频序列工具!

在本节中,你学习了如何从 Unity Asset Store 导入第三方资产,并将其更新为适用于 Unity 2022,同时通过扩展供应商提供的代码来触发事件。你还了解了 Unity 的 Timeline 功能,并创建了一个简单但有效的全黑电影淡出效果。

摘要

在本章中,你学习了如何创建一个高效且方便的全局事件系统,以灵活、可扩展和更易于维护的方式构建比直接耦合类更复杂的寻宝系统。你还学习了如何创建一个具有独特要求的特定寻宝任务,在将寻宝状态设置为完成之前需要满足这些要求,同时学习了如何引入随机性以收集所需物品。

我们继续学习如何从 Unity Asset Store 导入第三方滑动拼图资产,并升级它以兼容 Unity 2022 和 URP 渲染器,同时扩展代码以将其集成到我们的游戏代码中。我们完成了安全拼图锁的解决,并通过一个用于全黑淡出的电影时间线序列赢得了游戏。

在下一章中,我们将创建一个 3D FPS 游戏,以直接继续 2D 冒险游戏。我们将从上次离开的地方继续,进入室内栖息地环境,在那里我们将学习灰色拳击,以快速用ProBuilder完善可玩 3D 关卡设计。我们还将利用 Unity Asset Store 中的另一个资产——但这次是 Unity 直接提供的——用于我们的 FPS 角色控制器。我们还将通过将一些现有的 2D 组件转换为 3D 使用来查看代码重用。

第四部分:3D 游戏设计

在本部分,你将学习关于 FPS 类型的 3D 游戏,使用 Unity 的内置 3D 工具集进行环境设计的 3D 艺术技术,以及如何在 C#代码中涵盖物理 API 方法的同时将 2D 系统转换为 3D 系统。你还将学习如何使用 Unity 的 Starter Asset 创建 3D FPS 玩家角色,并通过添加之前课程中的可重用组件来了解玩家机制。你还将学习如何引入战术,使玩家能够在具有美丽照明和优化性能的完整 3D 环境中抵御感染机器人。你还将学习声音设计、音频管理器和音频播放器组件,这些组件将帮助玩家沉浸在游戏中。

本部分包括以下章节:

  • 第十章创建 3D 第一人称射击游戏(FPS)

  • 第十一章继续 FPS 游戏

  • 第十二章通过音频增强 FPS 游戏

第十章:创建一个 3D 第一人称射击(FPS)游戏

第九章中,我们创建了一个全局事件系统、一个任务系统以及一些松散耦合的组件,它们共同协作,为玩家提供多样化的任务。有了这些系统,我们迅速实现了收集物品以通知、更新和满足特定任务要求的功能。创建的组件是可扩展和可重用的,可以用于创建任意数量的任务。

我们随后导入并修复了通用渲染管线URP)的渲染,并将第三方滑动拼图资产的重构代码作为一个示例,展示了如何利用 Unity Asset Store 为我们的游戏服务。我们通过在场景中设置拼图并使用新艺术作品,在拼图解决时触发事件,以淡入黑色序列结束关卡来完成。

本章将从上一章的 2D 冒险游戏结束的地方继续,通过引入我们刚刚进入的栖息地内部的 3D FPS 游戏。

在本章中,我们将涵盖以下主要主题:

  • 在继续 GDD 的同时进行 3D 设计

  • 使用 ProBuilder 和预制件灰盒化 3D 环境

  • 使用 Unity Starter Asset 创建 FPS 玩家角色

  • 将环境交互重构为 3D API 方法

  • 实践中的代码重用——向玩家添加预制组件

到本章结束时,您将能够设计并构建一个由我们在 Unity 编辑器中制作的模块化部件组成的灰盒 3D 环境,快速添加 FPS 角色控制器,并重用和重构 2D 代码以用于 3D 项目。

技术要求

您可以在 GitHub 上下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

在继续 GDD 的同时进行 3D 设计

2D 游戏的关卡设计更为直接,因为玩家只在两个维度中导航。相比之下,3D 游戏在游戏玩法中涉及额外的维度——深度,这导致了更复杂的关卡设计。在 2D 中,屏幕空间由XY坐标表示。在 3D 中,地面平面由XZ(深度)表示,而Y仍然用于垂直轴——Unity 的 3D 坐标系统被定义为Y-up 环境

图 10.1 – 2D 与 3D 坐标

图 10.1 – 2D 与 3D 坐标

正如我们从之前的 2D 工作中所知,Z 轴仍然存在,但通过相机直接表示——要么在前方,要么在后方——并且仅在场景中分层对象时适用某些情况。

接下来,我们将尝试使用模块化方法简化 3D 设计过程,但一切又从游戏设计开始。让我们回顾一下本章中引入的新 3D FPS 项目对 GDD 的游戏玩法变化。

现在是我们更新游戏玩法机制蓝图以反映我们对 外世界 3D FPS 游戏的演变愿景的机会。然后我们可以确保栖息地内部设计生产的各个方面都将与这一新体验保持一致。

表 10.1 中,你可以看到我已经更新了相关的游戏玩法部分:

描述游戏玩法、核心循环和 进展 沿着中央控制系统前进,在角落和长长的走廊中窥视,同时处理需要沿途充电的损坏动力装甲,以恢复操作。小心那些徘徊的感染维护机器人!
什么是收集游戏的核心理念? 以第一人称视角,玩家将在环境中导航,为他们的动力装甲(健康)充电,并射击感染了维护机器人的目标。
需要实现哪些系统来支持 游戏机制 玩家移动、具有弹药装填和射击能力的武器,以及具有收集(充电)和伤害能力的健康系统。

表 10.1 – 更新的相关游戏玩法部分

接下来,我们需要更新玩家角色和敌人的背景故事,使其相关,如 表 10.2 所述:

主要角色的 挑战结构是什么? 居住站的环保控制系统已关闭,玩家角色的动力装甲由于损坏而失去了维持玩家的能力。玩家必须在战斗感染维护机器人的同时,在站内寻找 充电 来生存,直到到达中央系统。玩家将面对邪恶的植物实体 Boss。
敌人 B:****描述游戏中的第二个敌人及其如何推动故事。这位敌人是谁? 类型:维护机器人轮式背景:在殖民化前部署在栖息地维护和支持任务中的机器人。目标:维护、人员支持技能:快速充电弱点:移动性有限
Boss: Viridian Overmind****描述关卡 Boss 及其如何推动 故事****这位 Boss 是谁? 一个有感知的植物实体感染了栖息站中央控制系统的中枢,迫使玩家面对邪恶的外星生物。

表 10.2 – 更新的角色和敌人背景

这是一个新的游戏关卡,因此也需要定义:

描述游戏发生的环境。它看起来如何,谁居住在那里,以及 哪些是兴趣点 游戏发生在一个遥远星球表面的居住站内。栖息地欺骗性地很大,有许多小连接走廊。有维护机器人四处游荡,执行他们的自主任务。充电收集点方便地放置在整个站点的走廊交叉口。
描述游戏关卡 游戏关卡是一个模块化构建的栖息地站内部,有许多走廊和房间服务于不同的目的,以及一个中央控制系统房间。

表 10.3 – 环境和关卡定义

最后,我们需要更新输入控制方案,使其在 3D 空间中有效:

定义输入/控制 方法动作 键盘:WASD键用于移动,鼠标用于瞄准,左鼠标按钮用于射击主要武器,E键用于交互。游戏手柄:左摇杆/方向键用于移动,右摇杆用于瞄准,右扳机或Y键用于射击,按钮A用于交互。

表 10.4 – 更新的输入/控制方法动作

通过对外部世界游戏 3D 概念设计和游戏玩法机制的 GDD 修订,我们为过渡到关卡设计阶段奠定了基础 – 确保关卡将与我们的整体概念保持一致,并提供预期的玩家体验。

使用 ProBuilder 和预制件进行灰色盒 3D 环境设计

在某些方面,创建 3D 环境与创建 2D 环境相似,因为您仍然需要有意义地放置事物以供游戏使用。当然,我们必须考虑额外的维度,并使用 3D 模型而不是 2D 图像资源。

在 Unity Hub 中,请创建一个新的Unity 2022项目,并使用3D (URP) 核心模板作为我们的起点。我们将继续使用来自先前 2D 项目的 URP 渲染器。这对于 3D 来说仍然是一个很好的选择,因为它将在最广泛的设备上表现出色,包括移动平台。有了这个,尽管听起来我们可能正在剥夺自己一些能力,但我们并没有,因为 URP 也非常擅长制作美丽的 3D 视觉效果。

额外阅读 | Unity 文档

URP 概述:docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4014.0/manual/index.xhtml

我们将通过使用称为灰色盒(也可能被称为屏蔽)的技术来加快设计过程 – 这将使我们能够使用简单的几何形状来大致绘制关卡设计,而不会被细节所分散。我们还将能够早期进行关卡测试,并识别玩家在环境中导航和解决一般可玩性问题的任何潜在问题。

让我们先看看我们将要制作的内容。

栖息地内部关卡

像在早期章节中一样,我将提供一个示例关卡设计(再次让你承受我的草图,尽管这次更加精细),你可以跟随。关卡地图包括一个入口点 – 我们在上一款 2D 冒险游戏结束时进入的栖息地站 – 并通往一个中央控制系统,我们将面对邪恶的植物实体!

图 10.2 – 栖息地内部关卡地图草图

图 10.2 – 生态室内关卡地图草图

我们将采用模块化的方法来设计关卡,因此按照这个设置,我们将能够快速适应不同的布局。正如您所看到的,我已经添加了一个图例,用于标识打算使用的模块——这些将成为我们的可重复使用的组件,作为构建和构建关卡的大致框架。充电拾取点(绿色)、玩家的起始位置(P)以及 Boss 角色的位置也已被定义。

太好了!现在我们有一个可执行的计划。接下来,我们可以开始创建我们灰盒套件所需的 3D 模块。如果您在想,但我没有 3D 模型师/艺术家技能!不用担心,因为这将是一个基本的介绍——我也相信游戏开发者应该至少具备这些 3D 知识。

安装 ProBuilder

Unity 提供了更多内置工具,我们可以将其用于我们的灰盒过程,形式是一个简单的 3D 建模功能,称为 ProBuilder。像 Unity 的许多功能一样,我们将从 包管理器 安装 ProBuilder。在 Unity 2022 中,工具被收集到功能集中,因此打开 窗口 | 包管理器,选择 3D 世界构建 功能(包括 ProBuilder 和相关工具),然后安装它。

安装完功能后,我们还需要一个额外的步骤来完成 ProBuilder 的设置。我们使用的是 URP 渲染器,这需要安装支持用的着色器和材质:

  1. 包管理器 中,找到并选择左侧列表中的 ProBuilder

  2. 在右侧,选择 示例,然后点击 通用渲染管线 支持 项的 导入 按钮。

  3. 导入完成后,转到 编辑 | 首选项… | ProBuilder,确保在 网格设置 下,材质 设置为 ProBuilder 默认 URP

安装 ProBuilder 添加了一个新的顶部菜单项,名为 工具,允许打开 ProBuilder 窗口并访问其他功能,导出和调试日志首选项。对于我们的灰盒过程,我们将专注于 ProBuilder 窗口内的对象创建过程,但这并不意味着 ProBuilder 只限于创建简单的灰盒对象;它已经发展成为一个完整的 3D 建模和纹理产品,根据您游戏的风格,也可以用于最终资产创建。

额外阅读 | Unity 文档

ProBuilder: docs.unity3d.com/Packages/com.unity.probuilder@5.0/manual/index.xhtml

现在我们通过转到 工具 | ProBuilder | ProBuilder 来打开 ProBuilder 窗口,我们将设置好从草图图例创建模块化构建块。

模块化部件、预制件和变体

与室外环境的关卡设计不同,室外环境的形状在放置和位置上具有有机性质,我们正在制作一个受控空间的室内环境。因此,我们将使用需要相互吸附的模块来构建(你知道,需要一个气密栖息地所需的精度)。这通常是用于任何在自然界中找不到的制造结构空间的通用方法。

Unity 通过提供一些辅助构建选项很好地处理了模块化设计。

网格吸附

Unity 提供了一个网格吸附系统,它与 ProBuilder 配合得非常好,可以简化我们进行模块吸附等任务的生活。网格吸附允许在 X、Y 或 Z 轴平面上精确定位 GameObject,并适用于移动、旋转或缩放操作。

转换 GameObject – 即实现移动、旋转和缩放操作 – 也可以在不与预定义的网格线吸附间距对齐的情况下以增量方式执行。可以通过在场景视图中使用对象变换 Gizmo 时按住 Ctrl/Cmd 键来执行增量吸附(吸附增量设置位于网格吸附设置右侧的按钮上,如图 10.3所示)。

额外阅读 | Unity 文档

网格吸附:docs.unity3d.com/2022.3/Documentation/Manual/GridSnapping.xhtml

我们的关卡草图没有任何尺寸(我只是用网格来大致确定事物的一致大小),因此我们不受创意流动的限制;现在我们正在制作精确的模块化部件,我们需要引入一些计量单位来允许部件的吸附。如果我们不控制细节的大小,那么需要连接以创建封闭内部关卡设计的模块将会相当棘手。

为了设置网格吸附,我们首先需要激活移动工具,并确保在工具设置覆盖层中将句柄方向设置为全局(这是在场景视图中启用吸附的必要条件),如下面的截图所示:

图 10.3 – 网格吸附工具栏

图 10.3 – 网格吸附工具栏

注意,变换工具将选定的 GameObject(s) 沿着活动 Gizmo 轴吸附到网格上 – 使用网格视觉按钮,该按钮位于网格吸附设置左侧,如图 10.3所示,目前设置为网格 平面 Y 轴。

让我们确保网格吸附设置已设置好,以便我们开始创建栖息室内部的模块化部件。参照图 10.3,按照以下步骤设置吸附网格:

  1. 从场景视图中的工具栏覆盖层中选择移动工具。

  2. 工具设置覆盖层中将句柄方向设置为全局

  3. 验证网格视觉 | 网格平面 = Y

  4. 验证网格吸附 = 启用

  5. 设置网格吸附 | 网格大小 = 2(默认值为1)。

  6. 设置增量吸附 = 0.25

我有机械制图和 CAD 的经验,所以我喜欢知道我在使用什么尺寸——即使,是的,我们将吸附到我们刚刚定义的网格单位上。ProBuilder 的默认材质也包括一个网格纹理,但仍然可能很难在没有一些可见值的情况下确定所需的尺寸。因此,让我们使用我们将要构建的对象的视觉尺寸。从文件菜单,通过转到工具 | ProBuilder | 尺寸来设置尺寸覆盖层为显示

网格吸附将非常有帮助——确保模块部分尺寸的一致性——但我们还可以通过一个简单的构建辅助工具再进一步。

建设平面

我们将放置一个参考平面以帮助创建模块。我决定标准模块尺寸将是 6 个单位(对于你想要与之一起工作的基座模块的大小来说,这相当任意;我觉得对于这个设计来说,使用较小的模块会更好),因此我们将所有尺寸——包括模块以及地图尺寸——都基于这个标准(以及它的可分割尺寸,即吸附单位大小为 2;所以,希望这一切都能顺利进行)。

让我们继续:

  1. Assets/Scenes文件夹中,复制SampleScene并将其命名为Habitat Interior 1

  2. 现在,在ProBuilder窗口打开的情况下,选择新建形状,然后点击创建形状覆盖层中的平面选择图标。

  3. 网格吸附和尺寸显示已启用,因此点击并拖出一个 6 x 6 单位的平面。

图 10.4 – ProBuilder 参考平面

图 10.4 – ProBuilder 参考平面

  1. 再次点击新建形状来停止创建形状。

  2. 现在,让我们将平面位置重置为世界空间中的(0, 0, 0)。在平面被选中时,右键点击(0, 0, 0)Module Reference Plane

小贴士 | 最大化 Unity 窗口

要在 Unity 中最大化当前活动窗口,如图 10.3中场景视图窗口所示,您可以使用键盘快捷键Shift + 空格键

现在让我们制作第一个模块部分!

制作模块部分

我们将这些模块部分无缝地组合在一起,以形成我们需要的各种配置,从而制作出栖息地站级的复杂空间。因此,让我们开始制作我们环境的模块部分,从一个墙段开始:

  1. 要制作一个墙段,点击新建形状,但这次点击立方体选择图标。

  2. (0, 0, 0)开始,拖出一个长度为参考平面(6 个单位)且在Z方向(远离参考平面;蓝色辅助轴)上-2 个单位的形状。

  3. 然后,向上移动(高度为4单位。

由于网格吸附的结果,看起来这个盒子有点太厚,无法代表一堵墙,所以让我们编辑形状并使其稍微薄一些:

  1. 图 10.5所示,旋转相机以查看墙的另一侧。我们可以在场景视图中通过按住Alt/Option键,然后点击并拖动鼠标指针来旋转。

  2. 然后在ProBuilder 选择覆盖层中点击面部选择选项(A)。

  3. 现在点击墙的正面将只选择面多边形。在按住Ctrl/Cmd键的同时,拖动移动变换操控器的 Z 轴手柄(蓝色),直到墙体厚度为 1 单位(B)。

图 10.5 – ProBuilder 增量表面对齐编辑

图 10.5 – ProBuilder 增量表面对齐编辑

  1. 完成编辑面后,返回到对象选择模式(A)。

灰盒化不一定意味着 100%没有所有细节;我通常喜欢在我的灰盒化组件部分中添加至少一些小元素,以传达一些设计美学,因此我们将在构建一些附加部分之前,在墙体段上添加一些细微的细节。

让我们在墙体段上添加一些几何细节:

  1. 制作一个 2 x 2 单位的新立方体,高度为 4 单位。然后,使用1.5 x 0.25 x 4。将其放置在墙体段末端以提供端盖。

  2. 复制端盖立方体,并将其放置在墙体段另一端(就像书签)。请注意,两端应位于参考平面内,因此将与墙体段相交(这是可以的——我们希望将部件限制在我们的参考尺寸内,以便像乐高积木一样对齐)。

  3. 制作一个6 x 2单位的新立方体,高度为2单位,然后编辑其大小并将其定位,使其在墙体段长度上形成一种连接条。

我们 3D 建模的结果可以在以下屏幕截图中看到:

图 10.6 – 墙体截面细节添加

图 10.6 – 墙体截面细节添加

提示 | 照明

注意,场景中的光照阴影已被关闭,以便专注于建模任务。您可以通过在场景层次结构中选择方向光对象,然后在检查器中找到阴影部分,将阴影类型设置为无阴影来实现这一点。

我会说这个墙体部分现在对我们第一个模块化组件来说是完整的。因此,我们当然会想要将其制作成 Prefab,这样我们就可以在需要的地方重复使用它,并且如果我们需要修改它,场景中的所有引用都将更新。

按照以下步骤创建墙体 Prefab:

  1. 添加一个新的空 GameObject,并将(0, 0, 0)重置。这将是我们父对象,并确保我们的模块化部分在它们的对齐点上保持一致性——构成模块的各个单独对象将通过这个锚点枢轴进行变换。

  2. 选择并拖动墙体组件的所有部分,使其成为新空 GameObject 的子对象。

  3. 现在,将父对象重命名为Wall 1,并将其拖动到新的Assets/Prefabs文件夹中,以创建 Prefab。

您现在拥有制作灰色盒子工具包所需的所有工具和知识。这很简单,太好了!

我们需要为我们的灰色盒子工具包准备哪些预制件才能从关卡中制作出完整的块?我们可以从地图图例(见图 10.2)中找到答案。只需要几个预制件即可制作——减去我们刚刚制作的那个墙:

(A) 墙 1 6 单位长度
(B) 墙 2 12 单位长度
(C) 门口 18 单位长度(等长的墙、开放空间、墙)
(D) 连接器 1 6 x 6 单位,带有地面平面
(E) 连接器 2 6 x 12 单位,带有地面平面

表 10.5 – 灰色盒子工具包部件

连接器 1 (D) 将是房间之间相同类型的连接器,这样它就可以作为一个完成的模块创建,而无需在需要时从单个墙部件组装。连接器 2 (E) 预制件也是如此,只是更长。在这里,我们可以看到所有模块化部件:

图 10.7 – 灰色盒子工具包预制件

图 10.7 – 灰色盒子工具包预制件

现在继续创建额外的墙、门和连接器模块化部件;我会等着。只需确保始终从我们的建筑平面原点开始,以确保锚点枢轴位于(0, 0, 0)——通过在那里创建一个新的空 GameObject 并将其作为立方体的父对象——这样我们的工具包部件就能始终正确地定位。这些锚点枢轴指令基于图 10.4中所示,而不是图 10.7中所示,在那里墙部件在创建后被移动以获得更好的可见性。

我们的灰色盒子工具包完成后,我们现在可以开始绘制关卡。

灰色盒子关卡设计

我们有地图草图作为参考,但如果我们随意拖动工具包预制件来尝试填充它,这仍然会过于抽象。为了使过程更加流畅,我们可以使用 ProBuilder 平面根据我们的对齐网格来布置地图草图。这将确保添加工具包模块将变得简单快捷。

另一个绘图辅助工具(我们尽可能多地获取帮助)是将网格视觉设置的不透明度设置为最大值(如图 10.7所示),以便网格完全可见。

根据我们的草图和表 10.5中我们模块化工具包部件的大小,使用 ProBuilder 平面绘制关卡:墙壁、门和连接器。ProBuilder 默认将平面创建在Y0处,这正是我们想要的位置(地面水平)。在绘制关卡时,锁定视图为俯视图将有所帮助。因此,使用场景 gizmo(场景视图右上角,图 10.8中的(A)),点击Y(绿色)手柄,点击中心立方体以设置视图为正交(无透视),然后点击小锁图标。

额外阅读 | Unity 文档

场景视图导航:docs.unity3d.com/2022.3/Documentation/Manual/SceneViewNavigation.xhtml

这里是我们关卡地图的开始:

图 10.8 – 地图布局平面

图 10.8 – 地图布局平面

正如您将注意到的(如图 图 10.8 所示),白色的 ProBuilder 平面有点发光。这是因为从我们复制 SampleScene 以来,后处理体积就在场景中。如果它是一个干扰因素,只需在层次结构中禁用 全局体积 对象,或者,在绘制关卡时,通过 视图选项 工具栏(B)关闭效果。

这里是完成后的关卡地图,入口位于位置 (A),并前往位于位置 (B) 的中央控制系统室:

图 10.9 – 完成的地图布局

图 10.9 – 完成的地图布局

我在这里使用“极其重要”的定义来解释“中心”这个词,而不是“在某个东西的中间”的定义,只是为了澄清与房间布局的任何可能的混淆。

所有困难的事情都已经完成。我们现在要做的就是将我们的模块化工具包部件拖到绘制地板平面的边缘 - 我们确保的锚点枢轴位于模块的原点。我们已经确保一切应该能够正确吸附并相互连接。一旦你在场景中有一个模块化预制件,你可以使用 Ctrl/Cmd + D 快速复制它,然后移动和旋转到合适的位置。

注意,您可能需要旋转模块以封闭房间和走廊的周边。这就是锚点枢轴也发挥关键作用的地方 - 部件将在枢轴处旋转,确保保持正确的吸附。您可以在检查器中输入一个值或使用 工具栏 面板中的 旋转变换 工具(按住 Ctrl/Cmd 键以进行增量旋转并确保精确的 90 或 180 度旋转)来旋转部件。

如果我们在地图的房间和走廊的大小或间距上犯任何错误,我们会很快发现,但使用这种模块化方法快速进行更正是微不足道的。轻而易举。

这里是我将内部栖息地关卡与我们的模块化灰色盒子工具包结合起来的结果:

图 10.10 – 完成的栖息地内部灰色盒子

图 10.10 – 完成的栖息地内部灰色盒子

在本节中,我们学习了如何使用简单的 ProBuilder 模型预制件构建的 3D 环境,这些预制件用于创建简单的灰色盒子部件套件。然后我们发现了如何绘制关卡设计并使用 Unity 的网格吸附系统来快速轻松地使一切就位。

对于下一部分,我们需要测试我们所构建的内容。这次,我们不会从头开始编写玩家控制器,而是将利用 Unity 的 Starter Assets 快速构建玩家。

使用 Unity Starter Asset 创建 FPS 玩家角色

让我们快速看一下使用预构建资产(如 Unity 的Starter Asset 角色控制器)与自行编码相比的一些一般性好处:

  • 它们节省时间和精力——构建复杂系统需要时间,并且需要解决过程中出现的任何问题。

  • 它们经过测试和优化以提高性能,并且通常使用最佳实践——因为它们是由 Unity 提供的(他们对在引擎中创建组件有一些了解),并且被各个级别的游戏开发者广泛使用,这些资产将具有高性能并且不太可能出现错误。

  • 这些资产基于 Unity 的CharacterController组件构建——这些资产是模块化构建的,确保与其它系统(如相机和战斗系统)和资产兼容;它们是 FPS 游戏的一个很好的起始基础。

  • 它们提供了一个学习机会——预构建资产可以是一个很好的学习工具,因为你可以检查它们是如何工作的。

  • 它们提供了丰富的自定义选项——Unity 为大多数玩家控制器使用提供了许多开箱即用的自定义选项(特别是对于这种情况下的 FPS,这对我们来说太棒了!)

这些优势非常显著,有助于快速解决问题,我们将充分利用它们。现在让我们安装 Unity Starter Assets。

安装 Unity Starter Assets

我们将使用 Unity 从 Asset Store 提供的(免费)Starter Assets - FirstPerson CharacterController | URP资产来构建我们的 3D 第一人称射击游戏。

Starter Assets - FirstPerson CharacterController | URP (Unity Technologies)

你可以在 Unity Asset Store 这里找到这个资产:assetstore.unity.com/packages/essentials/starter-assets-first-person-character-controller-urp-196525

安装过程将非常直接,与我们已经从包管理器中安装资产的方式类似。然而,由于 Starter Assets 有一些必需的依赖项,安装过程中可能会有一些小插曲。不用担心;这只是一个小麻烦,正如你通过以下步骤很快就会看到的那样:

  1. 如果你还没有保存场景,现在保存一下(Ctrl/Cmd + S)——你将在接下来的几步中看到原因。

  2. 按照前面的 URL 打开 Unity Asset Store 中的资产。

  3. 如果尚未登录,请登录。

  4. 点击添加到我的资产按钮(接受Asset Store 服务条款 和 EULA)。

  5. 在 Unity 编辑器中打开项目后,点击浏览器窗口顶部的在 Unity 中打开按钮(你也可以在任何时候点击 Asset Store 页面上的在 Unity 中打开按钮),你会看到以下对话框:

图 10.11 – 在 Unity 中打开对话框

图 10.11 – 在 Unity 中打开对话框

  1. 点击打开 Unity 编辑器按钮将焦点设置到 Unity 编辑器,并打开包管理器,其中已预先选中Starter Assets - FirstPerson CharacterController | URP包(多么方便)。

  2. 点击窗口右上角的下载按钮。

  3. 下载完成后,点击安装按钮。

  4. Starter Assets 包需要新的PackageChecker脚本),我们将会收到提示,如下截图所示:

图 10.12 – Starter Assets 依赖警告

图 10.12 – Starter Assets 依赖警告

  1. 当然,我们想要点击安装/升级,这样我们才能在我们的项目中实际使用 Starter Asset!如果你不小心跳过了这个步骤,你仍然可以通过包管理器安装新的输入系统包和Cinemachine包。然而,当输入系统依赖项安装完成后,我们将看到一个对话框来启用原生平台后端:

图 10.13 – 更新原生平台后端

图 10.13 – 更新原生平台后端

  1. 点击,Unity 编辑器将重新启动(你不高兴我告诉你在步骤 1中保存场景吗?)。

  2. 当 Unity 编辑器重新打开时,返回包管理器并再次点击安装按钮(有点烦人,但似乎在当前的依赖要求下是不可避免的)。这次,安装将顺利完成。

这样就完成了安装,让我们看看 Starter Assets 提供了什么。

Starter Assets 游乐场场景

我们可以通过打开提供的游乐场场景来快速测试第一人称角色控制器。前往Assets/StarterAssets/FirstPersonController/Scenes以找到游乐场场景。在游乐场场景中,我们有第一人称角色控制器所需的所有对象,以及一个我们可以测试角色控制器的简单环境。

然而,你可以通过将它们使用网格顶点吸附在一起来快速操作游乐场环境,以提供一些额外的测试几何形状。而不是试图确定吸附网格设置。要使用顶点吸附对象,请使用移动工具选择你想要转换的网格,然后按住V键以激活顶点吸附。移动到你想要使用的顶点——通常是网格的角点——然后点击并拖动到另一个对象的任何其他顶点上。简单且非常有帮助!

额外阅读 | Unity 文档

定位游戏对象和顶点吸附:docs.unity3d.com/Manual/PositioningGameObjects.xhtml

特别值得注意的是,你还可以使用顶点吸附来将对象精确地放置在另一个对象的表面上。为了完成这个操作,在已经按住 V 键的同时,在移动到想要吸附的表面时按住 Shift + Ctrl/Cmd 键。

不要忘记,你可以在场景视图窗口中随时使用 F 键来重新聚焦当前选定的对象 – 这将围绕该对象设置环绕、平移和缩放。

好的,让我们看看如何使用我们将用于游戏的第一个视角角色控制器进行移动。

移动方式

首先,进入播放模式。现在,你可以使用鼠标四处查看,用 WASD 键移动玩家,按住 Shift 进行冲刺,并按 空格键 跳跃。键盘/鼠标和游戏手柄输入的 移动查看跳跃冲刺 动作由位于 Assets/StarterAssets/InputSystem 文件夹中的 StarterAssets 输入动作资产(输入动作)建立。

如以下截图中的场景层次结构窗口所示,我们拥有组成 Starter Assets 第一人称角色控制器的对象:

图 10.14 – 标准资产游乐场场景层次结构

图 10.14 – 标准资产游乐场场景层次结构

PlayerCapsule 对象是主要对象(Prefab),它包含提供第一人称角色控制器行为的模块化组件。从 PlayerCapsule 开始,组件的过程流程 – 从输入到变换操作 – 看起来是这样的:

PlayerCapsule à [StarterAssets (输入动作资产 / 输入动作)] Player à PlayerInput [发送消息] à StarterAssetsInputs à FirstPersonController à CharacterController

我鼓励你查看每个组件,以熟悉事物是如何连接起来提供这种功能的。我们实际上不需要深入研究这些组件来使事物工作,所以不会涉及。然而,当需要时,我们将扩展 Starter Assets 提供的基本功能。

然而,通过这个简要概述,我们应该了解将第一人称角色控制器引入我们的灰色盒子栖息地级别所需的内容。

将第一人称控制器添加到我们的级别

现在你已经在 Starter Assets Playground 场景中玩得开心之后,让我们回到我们的栖息地内部场景,添加玩家,以便我们可以进一步推进我们的游戏。正如我们在 Playground 场景层次结构中看到的那样,几个 Prefab 一起工作以提供第一人称角色控制器功能。因此,我们也会使用这些 Prefab。

使用提供的嵌套 Prefab,将控制器设置引入场景变得简单。以下是我们将遵循的步骤来将控制器设置引入场景:

  1. Assets/StarterAssets/FirstPersonController/Prefabs文件夹中,找到NestedParent_Unpack Prefab。正如其名所示,这是一个嵌套 Prefab,包含了我们设置玩家所需的所有内容。

  2. NestedParent_Unpack拖放到场景层次结构中。

  3. 如其名称所示,我们想要解包这个嵌套预制件,以便我们只有子预制件。右键单击NestedParent_Unpack,然后选择Prefab | Unpack

  4. 现在预制件已经解包,将子对象拖动到层次结构窗口的根目录。

  5. 您现在可以删除NestedParent_Unpack,我们就可以出发了!

如您在层次结构中的UI_Canvas_StarterAssetsInputs_Joysticks预制件对象中看到的。

图 10.15 – Starter Assets 移动控制

图 10.15 – Starter Assets 移动控制

我认为 Unity 决定在 Starter Assets 中包含移动支持是非常棒的;再次强调,如果我们想快速将移动游戏上线,这将节省我们大量的时间——以及之前提到的所有好处。

我们现在准备好对这个级别进行试玩测试了!

级别试玩测试

为了开始,我们必须将PlayerCapsule对象移动到我们的级别地图的起始位置,假设我们在解决了入口谜题后刚刚进入栖息地站。您可以在图 10.15中看到一个放置示例。

ProBuilder 模型包括用于绘制级别地面的碰撞器和平面,以及用于制作模块化墙壁预制件的立方体,因此级别已经设置好了!进入游戏模式并四处看看。

额外阅读 | Starter Assets 文档

Starter Assets 包的额外详细信息可以在包中包含的文档中找到。文档为 PDF 格式,可在Assets/StarterAssets文件夹中找到。

在本节中,我们学习了如何通过利用 Unity Starter Assets 快速将第一人称角色控制器添加到我们的游戏级别。

在下一节中,我们将回顾一些 2D 游戏项目中的可重用组件,以用于我们的 3D FPS 游戏项目。

将环境交互重构为 3D API 方法

从之前的 2D 游戏项目中,我们已经建立了一个小的组件库,但它们是 2D 的,我们现在处于 3D 状态,因此我们需要进行一些重构才能使用它们。让我们首先回顾一下在第四章中创建的TriggeredEvent组件。

回顾TriggeredEvent组件

我们用于TriggeredEvent组件的 Physics 2D API 方法是OnTriggerEnter2D()。它的 3D 对应物简单地省略了2D部分,因此它只是OnTriggerEnter()(Unity 底层是 3D,因此只有特定的 2D 方法被这样标记是有意义的)。

现在,考虑到上述内容,让我们看看我们将如何更新TriggeredEvent代码。假设您已经从之前的 2D 项目中复制了Assets/Scripts/TriggeredEvent.cs文件,只需进行少量更改。否则,您可以先回顾书中的早期代码,甚至可以从 GitHub 项目仓库中下载 2D 版本的脚本:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch4/Unity%20Project/Assets/Scripts.

这里是我们将进行的更改:

  1. 来自此处的 Unity 物理消息事件如下,但我们将进行更改:

    private void OnTriggerEnter2D(Collider2D collision)
    

    改变之后,它看起来是这样的:

    private void OnTriggerEnter(Collider collision)
    
  2. 更新原始的[RequiredComponent]属性,看起来像这样:

    [RequireComponent(typeof(Collider2D))]
    [RequireComponent(typeof(Collider))]
    

    我认为这一点是显而易见的——我们不需要将碰撞体设置为Collider2D类型。

如果您现在保存脚本,您可能会在控制台窗口中注意到显示了一个错误,表明找不到Tags类型:Tags.Player)。

我们可以通过再次从 2D 项目复制Assets/Scripts/Tags.cs文件、手动创建Tags.cs文件并输入常量变量声明,或者使用 IDE 的重构工具生成Tags.cs来解决这个错误。无论如何,我们最终都会得到以下内容:

internal class Tags
{
    // Ensure all tags are spelled correctly!
    public const string Player = "Player";
}

关于我们的Player标签,Unity Starter Assets 中的PlayerCapsule对象已经被标记为Player。多么方便啊!

TriggeredEvent脚本全部整理好之后,让我们现在通过向内部栖息地关卡添加触发交互来实现它。

在我们的关卡设计中实现 TriggeredEvent

我们已经看到如何利用编辑器工具和可重用组件来构建环境,并为我们的游戏添加行为或功能。当前的实现将不会例外。所以,让我们看看我们如何使用 ProBuilder 和TriggeredEvent组件向关卡设计中添加一个门,当玩家靠近时它会自动打开:

  1. 我们将首先创建一个触发体积,使用 ProBuilder 立方体。以图 10.16为参考,在玩家开始房间的前门前面画出一个立方体——别忘了你的网格吸附

  2. 一旦创建了立方体,选择它,然后在 ProBuilder 的OnTriggerEnter()消息事件将被触发。

这样,我们在设计时快速创建了一个可见的触发体积,但在运行时是隐藏的——这是 ProBuilder 提供的另一个优秀功能。

让我们继续添加重构后的TriggeredEvent组件到我们的触发体积 ProBuilder 立方体对象中。现在我们可以使用TriggeredEvent组件来连接并触发一个动画。

门打开的动画

现在我们有一个UnityEvent,当玩家进入门口的触发体积时可以触发,让我们添加一个关闭的门,它会动画打开。

首先,我们将按照以下步骤创建门:

  1. 创建一个新的 ProBuilder 立方体。

  2. 使用面编辑和增量吸附来使立方体变薄(你知道,就像一扇门)。

  3. 将立方体放置在门口以阻挡入口(就像它关闭了一样)。

  4. 重命名对象Door

  5. 通过分配颜色,我们可以快速区分门网格和我们的灰色盒子组件。我们可以使用 ProBuilder 窗口中的Vertex Colors命令来完成此操作。选择Door对象后,点击Vertex Colors将打开一个显示默认颜色调板的窗口。点击颜色相关的Apply按钮将为对象设置新颜色。简单易行。(哦……就像在图 10**.16中看到的那样,我选择了蓝色。)

我们现在可以向门添加一个可以由TriggeredEvent组件触发的动画。

使用.anim),我们将命名为Door-idle并将其保存到新的Assets/Animation文件夹。在相关的Animator中,这会将空闲动画设置为默认状态。由于我们不想在空闲状态下让门做任何事情,所以我们已经完成了。现在,我们想要创建开门的动画。

现在,按照以下步骤创建开门动画:

  1. Animation窗口中,选择Door对象,点击Animation Clip下拉菜单(当前设置为Door-idle),然后选择Create New Clip…

  2. 当提示时,保存新的动画剪辑并将其命名为Door-open

  3. 点击红色0.5秒。

  4. 使用Move工具,将Door移动到打开的位置(或更改检查器中的Transform位置值以改变打开方向轴)足够宽,以便玩家可以通过。这将创建时间线上的两个关键帧:一个在开始处,一个在当前时间。

  5. 再次点击Record按钮以停止动画。

  6. 现在,通过转到Window | Animation | Animator打开Animator窗口。你会看到已经存在的Door-idleDoor-open节点(动画),以及从EntryDoor-idleDefault State Transition线——这正是我们想要的。我们不希望门在游戏开始时做任何事情。

  7. 双击Door-open动画,然后在检查器中取消选中Loop Time。我们希望门打开并停止播放动画,而不是连续循环并重复执行。

最后一件要做的事情是在TriggeredEvent组件上连接UnityEvent

  1. 选择TriggeredEvent组件,点击小加号(+)图标以添加新的事件监听器。

  2. Door拖到Object字段。

  3. 在函数选择下拉菜单中,在提供的字段中选择Door-open,并确保拼写正确!这里的拼写必须与Animator中动画节点的名称匹配。

以下截图显示了前面的TriggeredEvent监听器分配、Door-open动画时间线、蓝色Door对象以及 ProBuilderCube触发体积对象:

图 10.16 – 触发门开启动画

图 10.16 – 触发门开启动画

让我们的触发/动画门作为最终步骤成为一个 Prefab:

  1. 通过选择两个对象,将层次结构中的CubeDoor对象设置为一个新的空 GameObject。

  2. 然后右键点击并选择创建 空父对象

  3. 将新的 GameObject 命名为Door_Triggered

  4. 然后从层次结构拖动它到Assets/Prefabs文件夹。

注意,您可能需要修复门开启动画,因为对象现在是一个子 GameObject,其局部位置相对于父对象偏移。如果是这样,选择,打开动画窗口,并从下拉菜单中选择门开启动画。现在您可以手动重置时间轴上每个关键帧的值。使用预览按钮播放动画并相应地调整,直到解决问题(您能行)。

现在,您可以在场景中复制Door_Triggered Prefab(Ctrl/Cmd + D),从项目窗口拖动它,并将其放置在适合所需游戏玩法的地方。例如,有一个巡逻敌人躲在关闭的门后总是很有趣。

在本节中,我们学习了如何轻松重构 2D API 方法以重用一些现有代码,并继续使用它,通过使用 ProBuilder 快速创建 3D 对象来为关卡实现新功能。接下来,我们将探讨更多的代码重用。

实践中的代码重用 – 向玩家添加预制组件

除了重构一些现有代码以在新项目中工作外,我们还可以引入现有的系统代码——这可以被认为是预制组件,随时可以使用。因此,现在回到 GDD,我们将使用 2D 冒险游戏项目中的HealthSystem来减少和充电玩家的动力装甲(即健康)。

如果您还没有本地 2D 冒险游戏脚本,您可以从 GitHub 仓库下载项目源代码:github.com/PacktPublishing/Unity-2022-by-Example

现在,从 2D 冒险游戏项目,将以下文件复制到 3D FPS 游戏项目(在相同的位置):

  • Assets/Scripts/Systems

    • HealthSystem.cs
  • Assets/Scripts/Interfaces

    • IHeal.cs

    • IDamage.cs

    • IHaveHealth.cs

现在健康系统已经添加到项目中,首先要做的是重构任何 2D API 方法和类型到非 2D 对应物,就像我们在上一节中做的那样,重构环境交互到 3D API 方法。这包括将所有OnTriggerEnter2D的引用更改为OnTriggerEnter,将Collider2D更改为Collider

现在,让我们继续添加HealthSystem作为PlayerCapsule Prefab(再次提醒,在 Unity Starter Assets 第一人称角色控制器中,这是玩家对象)的组件。

为了满足降低玩家动力装甲等级(健康值)的 GDD 要求,让我们编写一些代码,扩展现有的 HealthSystem 代码,以在指定的速率缓慢减少健康值。我们将确保在检查器中公开减少健康值的数量和减少速率的字段。

持续伤害脚本

Assets/Scripts 文件夹中创建一个新的 C# 脚本名为 ConstantDamage 并打开它进行编辑。我们将用以下代码替换脚本模板代码,这个代码应该看起来很熟悉,因为我们将实现 IDamage 接口。区别在于,我们不会像之前的 ProjectileDamage 类那样从对象碰撞中触发伤害。相反,我们将直接在一段时间内对 HealthSystem 施加伤害。

让我们从 IDamage 的必需实现开始:

using UnityEngine;
using System.Collections;
public class ConstantDamage : MonoBehaviour, IDamage
{
    public LayerMask DamageMask => _damageMask;
    [SerializeField] private LayerMask _damageMask;
    public int DamageAmount => _damageAmount;
    [SerializeField] private int _damageAmount = 1;
    public void DoDamage
        (Collider2D collision, bool isAffected) {}
}

代码的快速概述如下:

  • ConstantDamage : MonoBehaviour, IDamage: 这个类继承自 IDamage 接口,这意味着我们必须实现定义的属性和方法(合同):DamageMaskDamageAmountDoDamage()

  • _damageMask: 这是一个封装的私有变量,可以在检查器中进行序列化和赋值,并通过 public DamageMask 获取器(以满足接口合同)被 HealthSystem 引用。

    _damageMask 是一个 LayerMask,它决定了哪些对象 可以被这个脚本造成的伤害。

  • _damageAmount: 这是一个封装的私有变量,可以在检查器中进行序列化和赋值,并通过 public DamageAmount 获取器(以满足接口合同)被 HealthSystem.TakeDamage() 引用。

    这决定了将在一段时间内应用于对象的伤害量(通过 协程)。

现在,我们将添加在设定的时间间隔内施加伤害的代码:

    [SerializeField] private float _damageInterval = 5f;
    private void Start()
        => StartCoroutine(ApplyDamageOverTime());
    private IEnumerator ApplyDamageOverTime()
    {
        var healthSystem = GetComponent<HealthSystem>();
        while (true)
        {
            healthSystem.HandleDamageCollision(null, this);
            yield return new
                WaitForSeconds(_damageInterval);
        }
    }

以下是前面代码的快速概述:

  • _damageInterval: 这是一个序列化和可赋值的私有成员变量。

    它确定了将应用于对象的伤害量(伤害速率)的时间间隔。

  • StartCoroutine(ApplyDamageOverTime()): 这是我们在 Start() Unity 消息事件中启动协程的方式,它将立即开始对玩家施加伤害。

  • ApplyDamageOverTime(): 这是一个 IEnumerator 协程方法,它使用 while (true) 无限循环,然后调用 HandleDamageCollision(),并通过 WaitForSeconds() 延迟 5 秒后再次循环。

    • HandleDamageCollision(null…: 在这里,我们为 Collider 参数传递了一个 null 值,因为,嗯,我们没有发生碰撞。我们只需确保执行 DoDamage()
  • DoDamage(): 这个声明是必需的,用于接口实现(以满足合同),但现在我们不会使用它。

呼,这让人感觉我们为了仅仅十几行代码就要做这么多!这正是重点。我们将良好的架构、模式和最佳实践引入我们的项目中,以更少的代码完成更多的工作。

ConstantDamage 作为组件添加到 PlayerCapsule 对象上(这将是一个与 HealthSystem 相同的兄弟组件)——这样玩家的健康值就会不断减少。在我们测试之前,可以在检查器中将以下值分配给字段:

  • Player(你可能需要首先将 Player 添加到项目的图层列表中,使用层次结构中的 PlayerCapsule 对象到 Player 图层)

  • 1

  • 5(秒)

现在你可以保存(Ctrl/Cmd + S)并在 Player 标题下输入 PlayerCapsule FirstPersonController 值,直到在关卡中感觉移动是正确的。

目前可能很难看到任何正在发生的事情,因为我们场景中没有任何视觉指示器,并且当前健康值变量在检查器中不可见。不用担心,Unity 有解决方案。

检查器调试

Unity 检查器有一个调试模式,可以查看我们的组件代码,并将私有成员变量字段作为只读值公开。在仍然处于播放模式时,点击垂直省略号(更多项目菜单(也称为咖喱菜单)按钮(A),然后点击调试以从正常模式切换,如以下截图所示。

图 10.17 - 对玩家施加伤害

图 10.17 - 对玩家施加伤害

现在,我们可以看到 _healthCurrent 私有变量字段作为 HealthSystem 组件,根据我们的 ConstantDamage 分配,每 5 秒将 _healthCurrent 减少 1 的值。确保在检查完值后切换检查器回正常模式(你通常不需要看到所有额外的调试信息)。

附加阅读 | Unity 文档

了解更多关于在检查器中工作的信息:docs.unity3d.com/Manual/InspectorOptions.xhtml

还在 图 10.17 中看到,控制台警告信息是(C):PlayerCapsule 上的 HealthSystem 需要一个继承自 IHaveHealth 的兄弟组件

这个控制台输出是由我们在原始 HealthSystem 代码中实现的空值检查产生的,当调用 HealthChanged() 方法时。它只是确保在调用 HealthChanged()Died() 方法之前,存在一个实现 IHaveHealth 接口的对象,如下所示:

    private void HealthChanged()
    {
        if (_objectWithHealth == null)
        {
            Debug.LogWarning(…
            return;
        …

这样就处理了玩家不断减少的健康值。现在,我们该如何为这个 Kryk’zylx 力量战服充电呢?

充电即治疗

我们将创建一个可以充电(治疗)玩家的拾取物,这可能是我们向游戏中添加新功能最快的方式!这是因为我们又将使用之前编写的代码和类似的方法来处理我们已经制作过的事物。

从之前的 2D 冒险游戏项目复制 PickupHeal.csDestroyer.cs 脚本。我们将在整个级别中放置用于为玩家充电/治疗的拾取物——这是我们之前制作过的,所以也许你可以先自己尝试制作?唯一的区别是,现在我们将创建一个 ProBuilder 对象作为 3D 拾取物,而不是我们之前制作的 2D 精灵拾取物。

别担心;我们仍然会按照以下步骤进行,以便你可以对照检查你的工作。所以,让我们开始吧。要将一个 3D 对象作为治疗拾取物,请遵循以下步骤:

  1. 创建一个 ProBuilder 对象作为拾取物(一个立方体、球体、圆锥体等)。启用网格吸附后,你将想要使用增量吸附(按住 Ctrl/Cmd)来使其大小与玩家相匹配。

  2. 在你为拾取物创建的新对象上,将 MeshCollider 设置为 凸面,将 IsTrigger 设置为 启用(不要使用 ProBuilder 中的 SetTrigger 选项,因为我们希望网格保持可见)。

  3. 使用 ProBuilder 的 顶点颜色 为拾取对象设置新的颜色。

  4. 在 ProBuilder 对象上添加 PickupHealDestroyer 作为组件。

    1. Player 作为 Destroyer.DestroyMe 的监听器分配给 OnHealEvent(确保设置为 仅运行时 而不是 关闭)。
  5. 将对象重命名为 Heal Pickup(或 Recharge Pickup),并将其从层次结构拖动到 Assets/Prefabs 文件夹以使其成为可重用的 Prefab。

你自己做得怎么样?随着我们的治疗拾取物 Prefab 完成,我们现在可以以战略方式将它们散布在整个级别中,为玩家创造良好的、具有挑战性的游戏体验。现在进入游戏模式,并在检查器中的 调试模式 中验证,当拾取物被收集时,当前的健康值是否已恢复。一如既往,你将继续进行游戏测试——或者更好的是,在观察的同时让其他人进行游戏测试——并调整数值以提供最佳的玩家体验!

在本节中,我们学习了如何重用现有组件以快速向玩家添加健康系统功能,并通过添加一个应用恒定伤害的新组件来扩展功能。我们还学习了如何在检查器中查看和调试私有成员组件值,然后通过更多的代码重用来快速创建一个为玩家恢复健康的 3D 拾取物对象。

摘要

本章首先对我们的 GDD 进行了快速补充,以添加我们新 3D 第一人称射击游戏的具体内容,包括 3D 级别设计的考虑。我们使用更新的 GDD 从模块化部件的灰色盒子套件中构建内部栖息地级别环境,绘制出级别地图,所有这些操作都使用 Unity ProBuilder 完成。

我们继续学习如何通过利用 Unity Starter Assets 中的第一人称角色控制器快速将 FPS 玩家角色添加到我们的游戏中。我们还通过代码复用为玩家添加了健康系统和治疗拾取的行为。代码复用和将 2D 组件重构为 3D API 方法对应者,也使我们能够快速制作一个当玩家触发时开启的动画门。

在下一章中,我们将继续环境级别的设计,通过用艺术资源替换模块化灰盒套件 Prefabs,并使用更多 Unity 提供的工具进行装饰:Polybrush 和 Decals。然后,我们将通过烘焙 3D 环境照明来最终确定环境设计过程,以确保高性能渲染,同时将其与实时照明进行比较,以评估各自的优缺点。

第十一章:继续开发 FPS 游戏

第十章中,我们对我们的新 3D 第一人称射击游戏的 GDD 进行了一些更新。我们添加了关于关卡设计的具体细节,这有助于我们使用模块化部件的灰色盒子套件创建栖息地的内部关卡环境。我们还使用了 Unity ProBuilder来绘制关卡地图。

我们通过学习如何利用Unity Starter Assets First Person Character Controller快速添加 FPS 玩家角色到我们的游戏来结束。我们通过重用之前的系统和代码添加了健康系统和治疗拾取,以激发玩家的能力。我们还重构了 3D API 方法对应物,制作了一个当玩家触发时可以打开的动画门。

在本章中,我们将使用抛光资产更新和最终确定 3D 环境,添加散布对象以增加真实感,并融入磨损和磨损效果。此外,我们将改进光照以创造一个让玩家沉浸其中的体验,并确保最佳游戏性能。

在本章中,我们将涵盖以下主要主题:

  • 装饰 3D 环境

  • 使用 Polybrush 和贴图让玩家沉浸其中

  • 灯光设计 – 探针、贴图、光照烘焙和性能

到本章结束时,你将具备通过替换Prefab和材质来增强 3D 环境、通过使用 Unity 艺术家工具如Polybrush贴图来装饰环境以增加真实感和沉浸感,以及烘焙光照以及克服场景中动态对象光照和阴影限制的技术。此外,你还将了解与光照相关的性能考虑因素。

技术要求

要跟随本书中为项目创建的相同艺术品,请从 GitHub 下载资源:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch11/Art-Assets

您可以从 GitHub 下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

要跟随自己的 3D 艺术品,你需要使用 3D 建模和纹理软件(例如,Blender、Maya、3ds Max、Cinema 4D、ZBrush、Silo、Substance 3D Painter、Quixel Mixer 或 3DCoat)创建类似的艺术品。

装饰 3D 环境

我们的 3D 第一人称射击游戏正在稳步发展,有望成为一款真正的游戏,但如果我们希望它对潜在玩家有吸引力,它必须首先从其灰色盒子环境中毕业。本节将探讨 Unity 2022 的功能,用于用经过抛光和纹理处理的 3D 网格 Prefab 替换灰色盒子工具包模块的 Prefab,以正确地表示我们栖息地的内部环境。

我们不仅将替换现有的灰色盒子预制件,还会引入新的资产来装饰环境,使其感觉更加完整和有人居住。这将是 3D 模型和纹理的组合,我们将以不同的方式应用它们,再次使用 Unity 的艺术师功能工具(特定于Universal RP)。

这里是一个例子,展示了我们的室内栖息地环境——它已经经历了本章概述的过程——将是什么样子:

图 11.1 – 栖息地内部场景照明

图 11.1 – 栖息地内部场景照明

我们的首要任务是替换那些无聊的灰色盒子预制件。让环境看起来正确将有助于我们在装饰和细化过程中的以下步骤。

更新和替换预制件

替换预制件是一种处理艺术资产的结构化方法;由于我们使用的是固定模块化部件,艺术创作在于创建精炼的 3D 资产。在这种情况下,我们将使用来自 Polypix Studios 的朋友制作的 3D 资产(polypixstudios.com/)。

感谢信

Miguel Dumars 非常慷慨,提供了他们霓虹街道风格化科幻模块化走廊Unreal 套件中的部分资产供这个 Unity 项目使用,我很高兴能与这些资产合作!

Polypix Studios 允许仅用于学习目的使用提供的游戏艺术资产;商业用途是严格禁止的。Polypix Studio 的精选作品可以在 ArtStation 上查看,网址为www.artstation.com/polypixcc,以及 Unreal Marketplace 上的www.unrealengine.com/marketplace/en-US/profile/Polypix+Studios

要开始,让我们导入 Polypix 艺术作品。

导入和审查资产

到现在为止,你应该已经精通将资产导入 Unity 项目的过程,所以我们不会浪费时间详细说明每个小步骤。从本书的 GitHub 仓库(技术要求部分中的链接)下载 3D 艺术资产文件3DArtwork.zip到临时目录,然后将.unitypackage文件导入到当前的 3D FPS 项目中。

图 11.2中,我们可以看到包中提供的Polypix 模块化套件场景中的新模块化部件:

图 11.2 – Polypix Studios 模块化套件

图 11.2 – Polypix Studios 模块化套件

由于这是我们第一次使用第三方 3D 资产,让我们看看导入到Assets/Polypix 3D Assets/Modular Kit文件夹中的文件(如图 11.2 所示):

  • 材质:这些材质应用于并共享同一类别的 3D 模型(墙壁、门廊等)。相同的材质在多个模型之间共享,以保持事物更加优化,因为这减少了渲染器的绘制调用(也就是说,工作量更少,从而提高了 FPS)。

  • 模型:这些是构成表示物体形状的 3D 几何形状的优化多边形网格(顶点、边和面)。

  • 预制件(是的,我知道你们知道预制件是什么):这些是已经应用了材质并添加了碰撞器的 Polypix 模型,作为可以直接在我们的游戏中使用的完成对象。

  • 纹理:纹理是通过分配给应用于 3D 几何形状的材质来映射到 3D 几何形状上的图像文件,以给其上色和添加细节。

我们将主要关注预制件,因为我们将使用它们来替换我们为构建关卡而制作的初始灰盒套件模块化部件。在 /Prefabs 文件夹中,我们有相同的灰盒墙壁资产,但都添加了纹理和装饰。

现在,让我们看看我们如何快速用这个新艺术作品替换模块化灰盒套件预制件。

替换预制件实例

将替换的艺术资产导入到项目中后,我们可以开始替换预制件。Unity 2022 引入了一些新的预制件工作流程功能和 搜索,这将极大地帮助我们在这个任务中。

额外阅读 | Unity 博客

Unity 的新预制件工作流程功能远远超出了简单的预制件替换、传输覆盖、重新连接预制件和检查预制件变体关系。您可以在以下 Unity 博客文章中了解这些附加功能:2022.2 中预制件的新功能是什么? blog.unity.com/engine-platform/prefabs-whats-new-2022-2

Unity 博客是学习内容的绝佳资源!我强烈推荐定期阅读 Unity 的博客文章,以快速拓宽知识面并提高对 Unity 能力的理解。

我们将尽可能使用以下过程来大量替换我们关卡中的预制件。然而,我们无疑仍需要对布局的一些细节进行一些手动调整——由于艺术方向的一些变化(这可能或可能不是我的错)。

我们已经添加了行为的某些预制件是必须手动更新且不能简单地用新艺术作品替换场景中的预制件的例子,但因为我们仍然保持了将 图形 作为预制件中独立的子对象来维护,所以我们仍然能够仅替换预制件中的艺术作品(如您所回忆的,我们到目前为止一直在使用这种做法,这又是我们方法一致性的另一个例子)。

让我们开始,制作我们的第一个预制件替换。我们将用 Polypix /Prefabs 文件夹中的 Wall 1 预制件替换场景中的 Wall 1 灰盒预制件。我们将通过以下步骤使用新的 搜索 功能以及 预制件替换 工作流程:

  1. 通过转到 窗口 | 搜索 | 新窗口,点击 层次 窗口顶部的 在搜索中打开 按钮,或按 Ctrl/Cmd + K 来打开 搜索

  2. Wall 1 中使用引号来显式搜索此字符串(移除引号将搜索所有出现;请注意,搜索 不区分大小写)。

  3. 选择 层次结构 选项卡以过滤搜索,仅限于打开场景的 层次结构 中的对象。

  4. 现在,通过单击第一个项目,然后滚动到列表底部并按住 Shift 并单击最后一个项目来选择结果列表中的所有项目。

  5. 右键单击并点击 选择(或按 Enter 键):

图 11.3 – Unity 搜索轻松场景预制件选择

图 11.3 – Unity 搜索轻松场景预制件选择

现在场景中所有 Wall 1 预制件都已选中,我们可以轻松地使用 预制件替换 来替换它们 – 这只是一个两步的过程:

  1. 层次结构 区域右键单击选定的 Wall 1 预制件,然后选择 预制件 | 替换…

图 11.4 – 预制件 – 替换实例选择

图 11.4 – 预制件 – 替换实例选择

  1. Assets/Polypix 3D Assets/Modular Kit/Prefabs 文件夹中选择替换预制件(使用 表 11.1 作为参考):

图 11.5 – 选择替换预制件

图 11.5 – 选择替换预制件

您可以使用下表中指示的替换内容作为灰盒套件预制件和相应 Polypix 导入预制件的指南:

灰盒预制件 Polypix 预制件
Wall 1 Wall_01 Variant
Wall 2 Wall_02 Variant
Wall 4 Wall_04 Variant
Doorway 1 Doorway_01 Variant

表 11.1 – 替换灰盒预制件

新增于 Unity 2022 – 3D 模型是预制件

您可能想知道为什么 表 11.1 中列出的 Polypix 替换预制件名称中都带有 Variant。嗯,那是因为您现在不能再从 3D 模型(例如,FBX 文件)中创建原始预制件了。Unity 现在将 3D 模型导入为 模型预制件,因此当您从 3D 模型创建预制件时,它必须是 预制件变体。这一变化在很大程度上有助于确保我们不会破坏 3D 模型资产的内容。

由于所有 Polypix 资产都已预先在 Unity 中制作,并立即用于场景中,具有适当的材质、纹理、比例等,并重新保存,因此它们成为原始 3D 模型预制件的变体。

图 11.6 展示了我对表中 11.1 列出的墙体和门道进行简单预制件替换步骤的努力成果。更新艺术作品的工作已经完成了一半以上!轻而易举:

图 11.6 – 场景中替换的墙体预制件

图 11.6 – 场景中替换的墙体预制件

小贴士

注意,在我的关卡设计中,我发现我无法直接使用所有替换内容与完成的艺术作品,而是将一些墙体模块更换为不同的模块。因此,您的使用效果可能会有所不同。这都是设计过程的一部分;就像编码一样,它是一个迭代的过程。

只需重复前面的步骤来替换所有静态灰盒模块化组件 – 静态意味着这些是结构性的,不包含任何行为、交互或动画。我们将首先处理具有动画的 Door_Triggered 预制件,因为它不会直接替换。

这些对灰盒组件包的额外更新将在预制件编辑模式下进行,但我们仍然会简单地用更新的艺术品替换掉图形

更新现有的模块化预制件

一些预制件,如 Doorway_Trigger,不能简单地替换,因为它们具有我们添加的行为,即通过碰撞触发动画。以这个为例工作流程,让我们通过以下步骤更新 Doorway_Trigger

  1. 预制件编辑模式下打开 Door_Triggered 预制件以进行直接编辑。

  2. Door_Trigger /``Prefabs 文件夹添加到项目中。

  3. 重新连接替换 Door 对象动画的新 Sliding_Door_01 对象的动画 – 确保你禁用或删除 Door 对象,因为我们正在替换它,不再需要它。

  4. TriggeredEvent.OnTriggered() Animator.Play(string) 更新为引用替换 Door 对象的新 Sliding_Door_01 对象,以便我们的 Door-open 动画在新门网格上仍然有效。记住,Animator.Play() 函数的字符串值与动画的名称相同:Door-open

图 11.7 展示了重构后的设置:

图 11.7 – 更新门触发预制件

图 11.7 – 更新门触发预制件

我认为这仅剩下需要更新的连接器预制件。Polypix 资产不包括连接器的直接替换预制件,所以请自己制作!你可以通过复制原始灰盒资产并替换子图形(作为嵌套预制件)来创建新的连接器预制件。

我不会提供这个过程的步骤,因为现在你已经拥有了完成这个任务所需的所有知识。不过,如果你遇到困难,你始终可以参考本书 GitHub 仓库中完成的项目文件中的新连接器预制件。你行得通!

这样,我们就有了来自 Polypix 的替换预制件和一些我们可以应用的新材质纹理。

应用新材料

当我们在进行一些艺术更新时,我们还有一些新材料可以用来更新我们的地板平面的外观(我们的 /Materials 文件夹,我们有一个FloorPlate材质,所以让我们将其分配给我们的地图,使其成为我们的纹理地板。

图 11.8中,你可以看到我已经将层次结构区域中Map根的 GameObject 重命名为Floors。选择Floors后,按下Shift + H将进入隔离视图A)——暂时隐藏层次结构区域中的所有其他对象,这使得我们可以通过点击并拖动FloorPlate材质(B)到场景视图区域中的平面上来轻松地为选中对象分配新材料:

图 11.8 – 更新地板材质

图 11.8 – 更新地板材质

切换可见性快捷键

注意,你也可以使用Alt/Option + Shift + A来切换当前选中对象的可见性。

或者,由于这些平面是 ProBuilder 对象,我们可以打开ProBuilder 窗口区域(工具 | ProBuilder | ProBuilder 窗口),从工具栏中选择材质编辑器,并将FloorPlate材质分配给下一个可用的槽位(见图 11.9):

图 11.9 – ProBuilder 材质编辑器分配

图 11.9 – ProBuilder 材质编辑器分配

分配了材质后,你现在可以选中层次结构区域中的所有地板平面,并一次性按下分配给FloorPlate材质的快捷键——在我们的案例中,那就是Alt + 3。很简单!

当我们处理地板时,让我们也给我们的栖息地内部添加一个天花板!现在这对我们来说很简单:

  1. 使用ProBuilder创建一个新的平面。

  2. 将其缩放到包含我们级别中的所有房间,然后将其高度定位在墙壁预制件顶部——正好是天花板的位置(在我的级别中,这对应于 Y 变换位置的值4)。

  3. 从 Polypix /Materials 文件夹,将CeilingPlate材质分配给天花板平面。

  4. 最后,为了使纹理从平面的底部而不是顶部可见,这是默认设置,但不是我们想要的,因为我们的玩家在天花板下方向上看它,在ProBuilder窗口中,点击翻转法线

表面法线

法线——或表面法线——描述了多边形表面的方向(即其可见侧面):

左侧的表面其法线(橙色线条)向上指向,面向摄像机,这样我们就可以看到纹理。相比之下,右侧的表面其法线向下并远离摄像机,所以我们看不到纹理。

有了这个,我们的灰色盒子级别已经全部更新,艺术作品已经打磨完成!

在本节中,我们看到了如何使用 Unity 的新Prefab 工作流程搜索替换用于原型设计的灰色盒子资产,并使用导入的打磨完成的艺术作品。然后,我们通过应用一些新材料完成了内部 3D 栖息地环境的更新。

接下来,由于我们的 3D 栖息地环境看起来还是有点无聊,我们可以通过在周围散布一些预制件来快速解决这个问题。

使用 Polybrush 和 Decals 让玩家沉浸其中

与之前使用固定、模块化部件(如预制体替换)的结构化方法相比,一种更自由的形式方法为艺术方法提供了不同的视角。因此,我们不会依赖于预制体对象,而是将从零开始(好吧,随机化)使用 Polybrush 创建动态、自发的部件。

使用 Polybrush 绘画对象

Polybrush为我们提供了一种不受限制的方式来装饰环境,并随机散布预制体以进行放置 – 因此,根本不是模块化的。考虑到这一点,我们只将介绍 Polybrush 的散布对象功能,但要知道它还有更多功能。

额外阅读 | Unity 文档

你可以在这里了解更多关于 Polybrush 的信息:unity.com/features/polybrush

在准备写作的过程中,我使用 Polybrush 在 ProBuilder 化网格上绘画对象时遇到了一些问题,因此在我等待 Unity 对我的关于此问题的错误报告做出回应时,我们将继续使用一个简单的解决方案,即在首选项 | Polybrush中启用使用额外的顶点流。如果你在使用 ProBuilder 对象时遇到任何问题,请记住这一点!作为替代方案,你可以通过选择它们并转到工具 | ProBuilder | 动作 | 在所选内容中剥离 ProBuilder 脚本来将 ProBuilder 对象网格化,但这也意味着你将无法再使用 ProBuilder 编辑网格。

现在我们已经学会了如何在特定对象网格上绘画时避免问题,让我们开始绘画吧!

绘画/散布对象

我们需要一些对象来散布到我们的环境中,所以如果你还没有做,请从 GitHub 艺术作品下载中导入Sci_Fi_Assets.unitypackage。如果你之前没有做,现在就做吧。

现在,在参考 图 11.10 的同时,按照以下步骤设置预制体绘画:

  1. 通过转到工具 | Polybrush | Polybrush 窗口来打开Polybrush

  2. 点击在网格上散布预制体按钮 (A图 11.10).

  3. BarrelsTrashcan预制体从项目窗口中的Assets/Polypix 3D Assets/Prefabs文件夹拖到当前调色板部分 (B图 11.10):

图 11.10 – Polybrush 预制体绘画设置

图 11.10 – Polybrush 预制体绘画设置

默认情况下,击中表面是父对象Polybrush 窗口区域中是启用的,使得所有绘制的预制对象成为被绘制网格的子对象。在图 11.10 中,你可以看到我已经禁用了击中表面是父对象,这样所有绘制的对象都可以收集到层次结构区域的单个父GameObject中(这是我的偏好,但你可能更喜欢将绘制的对象作为它们被绘制网格的子对象)。如果你保持此选项禁用,一旦你在整个环境中绘制了对象,请确保从层次结构区域的根处选择所有绘制的对象,并将它们移动到一个新的、空的散布对象根 GameObject 中。

现在,我们可以通过使用添加到当前调色板区域的资产来装饰环境(戴上我们的室内设计师帽子?)。首先,将当前调色板区域中的刷子预制对象添加到刷子配置选择中(如图 11.10 中的图 B所示),通过勾选项目(在当前调色板区域中选择项目也会在其下方产生一个下拉部分,你可以调整项目在绘制时应如何考虑的设置)。

在环境中绘制预制对象现在就像在场景视图中将鼠标光标悬停在地板网格上,然后按住鼠标左键拖动到你想要散布对象区域一样简单——如图 11.10 中的图 C所示。在绘制时按住Ctrl/Cmd键将作为橡皮擦使用,并移除你不喜欢的任何放置。祝您玩得开心!

小贴士

Polybrush 也可以在垂直表面上工作,所以你可以在墙上散布预制对象(只是要知道你可能需要重新定位或调整预制对象的锚点,以便对象按预期绘制;我已经提供了一个带有Exhaust_01 Pb的示例预制对象)。

散布对象以打破环境是环境设计的一部分,可以使玩家的沉浸感更好,而另一部分也是打破视觉重复模式。我们可以通过 Unity 艺术家工具解决后者,该工具在 2022 版本中得到了一些关注。

使用贴图进行表面故事

就像我朋友米格尔在 Polypix Studios 说的那样,表面故事很重要,我完全同意!除非你追求超级纯净的未来清洁外观,否则你希望确保你的环境扎根于现实世界。这意味着这些环境将通过其纹理细节传达其使用历史——磨损和损坏。比如说,我们栖息地站的维护机器人不太擅长清洁工作,所以环境应该表明这一点。此外,你知道应该有一些迹象表明植物实体对环境的影响!

这是一个很好的机会,要么回顾 GDD,要么扩展支持我们通过环境设计讲述的故事的细节。

作为设计师(或者戴着设计师帽子的开发者),我们不需要太多的 3D 艺术技能就能利用环境设计中的表面故事。我们可以使用 Unity 提供的工具(惊讶吗?)来添加环境中的表面细节,我们通过贴图来实现这一点。

额外阅读 | Unity 文档

你可以在这里了解更多关于贴图渲染器功能(URP)的信息:docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4014.0/manual/renderer-feature-decal.xhtml

在我们可以在项目中使用贴图之前,我们必须启用该功能。

在 URP 中启用贴图功能

要在Universal RP设置中启用贴图,参考图 11.11,按照以下步骤操作:

  1. Assets/Settings文件夹中,选择URP-HighFidelity-Renderer资产(图 11.11 中的A)。请注意,除非你已经在项目****设置区域更改了默认质量值,否则这是默认设置。

  2. 检查器区域点击添加渲染器功能按钮(图 11.11 中的B),然后从列表中选择贴图

图 11.11 – 在 Universal RP 中启用贴图

图 11.11 – 在 Universal RP 中启用贴图

现在,我们需要一些贴图纹理来用于我们的贴图!

贴图纹理

首先,我们需要通过纹理文件将一些详细的纹理导入到我们的环境中。我已经找到了一些可以用于我们项目的免费纹理。从本章的 GitHub 仓库github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch11/Art-Assets下载cgtrader_2048986_Damage.zip文件。

免费贴图纹理 | cgtrader

我们在项目中使用的贴图纹理来自 cgtrader 的Decals Damage 48 Texture文件(免费许可),可在www.cgtrader.com/free-3d-models/textures/decal/decals-damage-48-texture找到。

解压文件并将图像导入到新的Assets/Textures/Decals文件夹中。在你创建文件夹的同时,也可以创建一个新的Assets/Materials/Decals文件夹,因为我们在下一步创建贴图材质时需要它。

创建贴图材质

贴图功能基于分配给Shader Graphs/Decal着色器的材质。

按照以下步骤创建我们的第一个贴图材质:

  1. 项目窗口中的Assets/Materials/Decals文件夹中,创建一个新的材质(通过创建 | 材质)。

  2. 将新材质命名为我们将使用的损坏纹理相同的名称;在这个第一个例子中,我们将使用DecalsDamage0032_1_S

  3. 在顶部的着色器下拉列表中选择Shader Graphs/Decal着色器,并选择新材质。

  4. Assets/Textures/Decals 文件夹中,将 DecalsDamage0032_1_S 图像拖到 Base Map 字段。

    注意,如果我们包含了与损伤纹理一起的 Normal Map 图像,那么将其分配给 Normal Map 字段(我建议使用法线图来为你的贴图提供更多细节;然而,我们提供的纹理中并没有包含一个)。

有了这个,我们的第一个损伤贴图材质就准备好了!我们几乎准备好开始使用贴图投影组件对我们的环境应用损伤了。

贴图的渲染层

贴图是投影器!由于场景中投影的性质,一些对象可能会在投影器和目标网格之间穿过,并被投影击中,产生不理想的结果。所以,尽管我们可以在这里采取更自由的艺术指导方法 – 因为我们不受限于在环境中放置贴图的位置 – 我们仍然可能需要控制贴图的投影并保护一些对象免受贴图纹理的影响。幸运的是,使用 Rendering Layers,我们可以限制贴图影响的网格。

新的 Unity 2022

渲染层 | 如何使用渲染层与贴图:docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4014.0/manual/features/rendering-layers.xhtml#how-to-rendering-layers-decals

例如,我们希望我们的贴图影响墙壁,但不影响我们从 Polybrush 绘画中散布在环境中的对象!让我们确保我们现在启用了贴图的渲染层。所以,让我们重新访问 URP-HighFidelity-Renderer 资产(参见图 11.11*)并启用 Use Rendering Layers(默认情况下是关闭的):

图 11.12 – 启用 Use Rendering Layers

图 11.12 – 启用 Use Rendering Layers

一个功能性的贴图 Rendering Layers 设置的下一个要求是确保为我们的贴图案例指定了一个层。我们可以通过编辑 Rendering Layers (3D) 层列表来完成:

  1. 选择 Assets/ 文件夹。

  2. 图 11.13 所示,我们将通过重命名 Receive Decals 来创建我们的贴图层:

图 11.13 – 贴图渲染层

图 11.13 – 贴图渲染层

现在我们有了可分配的层,我们将返回并修改所有我们希望能够接收贴图投影的预制件 – 确保只有选择了 Receive Decals 层的对象才会显示我们的损伤纹理。与这个设置一起工作很简单 – 也就是说,一旦你明白了层是粘合所有部件在一起的东西。

图 11.14 所示,我们将修改 Wall_02 Variant 预制件(位于 Assets/Polypix 3D Assets/Modular Kit/Prefabs 文件夹中),使其包含 Mesh Renderer 组件中的 Receive Decals 层:

图 11.14 – 将贴图渲染层分配给预制体

图 11.14 – 将贴图渲染层分配给预制体

您需要将所有打算投射贴图的预制体更新为带有 接收贴图 层。如果您的贴图没有显示在您认为应该出现的位置,您需要检查层分配(当然,还要确保在渲染器设置中启用了贴图)。

现在,我们可以在场景中创建第一个 贴图投射器 并开始充实环境的故事。耶!

使用贴图投射器组件

要添加贴图,您可以选择创建一个新的 Decal Projector 游戏对象,或者将 Decal Projector 组件添加到场景中现有的对象上。由于场景中没有适合添加 Decal Projector 组件的对象,我们将创建一个新的:在 层次 窗口中,转到 创建 | 渲染 | URP 贴图投射器

图 11.15 所示,我们必须将之前为 DecalsDamage0032_1_S 纹理制作的贴图材料分配到 材质 字段,并在 渲染 字段中选择 接收贴图 层:

图 11.15 – 贴图投射到墙面预制体上

图 11.15 – 贴图投射到墙面预制体上

图 11.15 所示,我已使用 变换 工具定位和旋转了 贴图投射器 立方体辅助工具(它显示了其边界)。辅助工具的底部有一条较粗的线,投影方向由从旋转中心锚点(在 Z 轴上)发出的白色箭头指示。

您可以使用 变换 工具进行投影的初始定位和旋转。不过,您可能还想使用 场景 视图编辑工具进一步微调贴图 – 这需要您进行进一步的探索和实验,因此我建议阅读更多相关内容。

阅读更多 | Unity 文档

贴图投射器组件:docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4014.0/manual/renderer-feature-decal.xhtml#decal-projector-component

继续装饰 – 哎 – 进一步设计关卡环境,并添加 表面故事 细节,通过利用我们可用的不同损伤纹理来向玩家推销您的环境。再次强调,测试和反馈在这里至关重要,有助于指导艺术方向。

当您开始将纹理投射到各个地方时,您可能会想,这对我的游戏性能有什么影响?

贴图性能

好吧,Unity 也为我们提供了性能优化的解决方案。如果我们确保场景中具有相同损伤纹理的贴图使用相同的 材质,并且如果我们将 材质启用 GPU 实例化 功能打开,Unity 将使用一种称为 实例化 的技术来提高渲染效率。在 GPU 上实例化可以减少绘制调用次数(这就像一次性绘制所有贴图,而不是单独绘制每个贴图)。

此外,为了减少我们为贴图所需的材质数量(因为,同样,每个 材质 都被分组一次性绘制),我们可以将许多损伤纹理图像放入一个更大的图像中(即,纹理图集)。然后,贴图投影器组件允许我们使用 UV 偏移属性来选择我们想要显示的纹理图集的哪个部分 – 我们选择的单个纹理图像。这样,我们可以更有效地管理所有贴图图像,并保持游戏运行顺畅。

额外阅读 | Unity 文档

Decal 渲染器功能 | 性能: docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4014.0/manual/renderer-feature-decal.xhtml#performance.

在本节中,我们学习了如何通过使用 Unity 的新 Prefab 工作流程用完成的艺术资产替换灰盒 Prefab,并在 Prefab 模式下手动更新 Prefab 以使用新的图形,同时保持 Prefab 中现有的行为。然后,我们继续通过使用 Polybrush 绘制散布的对象来润色环境设计,并通过添加贴图纹理来让玩家沉浸在 表面故事 中。

在下一节中,我们将继续通过灯光来提升栖息地的内部环境。

灯光设计 – 探测器、贴图、光照烘焙和性能

让我立即为这一节设定一些预期 – 我们不会在灯光设计上花费太多时间。这是一个值得单独出一本书的主题。因此,我们将涵盖一些任何游戏开发者都应该熟悉的基本知识,当他们在 Unity 中处理 3D 场景的灯光时。

我首先想说的是,Unity 已经在 2022.3 LTS 版本中为 通用渲染管线URP)(URP)发布了一条新的渲染路径,称为 Forward+ 渲染

额外阅读 | Unity 文档

Forward+ 渲染路径: docs.unity3d.com/Packages/com.unity.render-pipelines.universal%4014.0/manual/rendering/forward-plus-rendering-path.xhtml.

Forward+ 渲染路径(Forward+)相对于之前的Forward 渲染路径(Forward)有几个优势,但它主要克服了场景中影响 GameObject 的灯光数量的对象限制(注意,每台相机的限制仍然适用)。让我们看看我们如何更新我们的项目 URP 设置以使用Forward+

设置 URP Forward+ 渲染路径

现在,让我们将我们的 URP渲染路径设置更改为使用Forward+ 渲染路径。我们可以在Universal RP 设置区域(我们在图 11.11中添加到Decal功能的同一资产)中这样做:

  1. Assets/Settings文件夹中,选择URP-HighFidelity-Renderer资产。

  2. 渲染部分,在渲染路径下拉菜单中选择Forward+

图 11.16 – 设置 Forward+ 渲染路径

图 11.16 – 设置 Forward+ 渲染路径

就像你将为你的 Unity 项目做出的大多数关于技术栈的决定一样,你将不得不进行实验和测试,以查看什么最适合项目的方向或目标平台(我们正在关注移动平台),渲染管线选择也不例外。这非常重要。因此,我们现在将把这个项目改为Forward+ 渲染路径。只需知道,如果我们需要改变我们的照明方法(例如,从实时照明到烘焙照明;更多内容请参阅烘焙那些照明?部分),我们可能会在未来撤销这个决定。

关于性能方面的共识——涉及Forward+Forward的比较——是,由于Forward+在聚类光线时引入的额外开销(Forward+将光线数据聚集成在片段着色器中计算的簇,而不是单独的光线数据),当光线数量超过六个时,可以实现增益。这对灯光设计师来说是个好消息,因为Forward+ 渲染正好提供了我们可能需要的——场景中更多的实时光线!

说到灯光,我们可以通过添加照明效果到我们的场景中,而不需要触摸任何灯光——这是通过代理照明实现的。

使用贴图的代理照明(是的,贴图)

在这里,贴图指的是照明贴图(即代理照明),而不是我们已熟悉的纹理贴图。为了实现这种效果,Decal Projector使用一种特殊的着色器来修改受影响表面的发射颜色,而不涉及任何实时光线。

我们将在场景中的代理灯光Decal Projector中使用的Material是基于来自Universal RP样本的Shader Graph着色器、Spotlight

额外阅读 | Unity 博客

你可以在这里探索 Universal Render Pipeline 的最新包样本:blog.unity.com/engine-platform/explore-the-latest-package-samples-for-the-universal-render-pipeline

这些不仅适用于Universal RP!你应该始终检查 Unity 包内容,以查看提供了哪些示例,这些示例可以作为补充 Unity 文档的学习资料,并可以在实现功能时提供快速入门。

要导入Universal RP示例,请按照以下步骤操作:

  1. 通过转到窗口 | 包管理器来打开包管理器

  2. 确保在下拉菜单中选择了项目内

  3. 从列表(左侧)中找到并选择Universal RP

  4. 在窗口的右侧,点击示例选项卡。

  5. 对于Assets/Samples/Universal RP/14.x.x/URP Package Samples文件夹。

样本导入完成后,找到并打开/URP Package Samples/Decals/ProxyLighting文件夹:

图 11.17 – Unity Universal RP ProxyLighting 示例

图 11.17 – Unity Universal RP ProxyLighting 示例

现在,前往Assets/Prefabs文件夹。将其命名为Decal Spotlight,这样我们就可以在我们的栖息地内部场景中使用它作为照明贴图聚光灯。它已经预先配置了聚光灯材质(以及默认的渲染层,以便它会影响场景中的任何对象),所以我们已经准备好了!

现在,回到我们的内部栖息地场景,将一个Decal Spotlight材质放置在墙的悬挑处(如图图 11.18所示),调整贴图投影器组件的不透明度设置(参见图 11.17),到一个漂亮的照明值,然后复制几次(本例中为三次;总共四次)。不用担心它们目前都堆叠在一起。

我们可以将聚光灯手动放置在墙的更远处,或者,为了使事情变得更容易,我们也可以在cos(a)sin(a)中使用一点数学知识!

额外阅读 | Unity 文档

编辑属性 | 数字字段表达式:docs.unity3d.com/Manual/EditingValueProperties.xhtml

选择我们刚刚添加到L(-5.4,-17.3)的所有聚光灯,在L(a,b)是一个线性渐变表达式,其中选定的对象分布在这些值之间。

当你在字段中有表达式时,你可以调整值,直到聚光灯放置到你想要的位置(你的值可能因你的墙位置等因素而与我的不同)。

你最终应该得到类似这样的结果:

图 11.18 – 墙上的贴图聚光灯阵列

图 11.18 – 墙上的贴图聚光灯阵列

真的很酷,对吧?而且记住,这里没有涉及实时灯光!我们可以在享受环境设计中额外的照明效果的同时节省照明资源。只需注意,贴图材质的性能考虑仍然适用。

说到性能,我们除了使用实时照明之外,还可以考虑另一种照明方法,那就是烘焙照明。

需要烘焙这些照明吗?

350°F(175°C)持续 45 分钟……当然可以!

在 Unity 中使用烘焙光照与实时光照相比,在游戏中可能会有几个好处,但决定使用哪一个很大程度上取决于目标平台和您为特定硬件规格确定的游戏需要达到的目标帧率(您可能已经接触过有关您想在 PC 或移动设备上玩的游戏的相关信息——系统需求:最低和推荐配置)。

让我们看看烘焙光照的三个主要好处:

  • 性能:烘焙光照意味着场景中关于物体表面的所有光照交互都是预先(在编辑器中)计算并保存到光照图纹理文件中的。在运行时,使用光照图来确定场景中物体表面接收到的光照,这比实时计算要快得多。

  • 质量:烘焙光照通常能产生更好的光照保真度,尤其是在处理间接反弹光、柔和阴影以及更复杂的光扩散效果时。

  • 光照复杂性:烘焙光照可以处理具有大量灯光和复杂阴影交互的高级别光照复杂性(只是要做好更长时间烘焙的准备!)。

现在,让我们考虑一下烘焙光照的一些缺点:

  • 光照图:生成的光照图纹理文件的大小可能会对运行时的内存使用和游戏在磁盘上的大小(即移动和低端平台)造成关注。优化光照图通常是在性能和质量之间进行权衡(当然,这需要时间;参见长迭代时间)。

  • 静态:烘焙光照图仅适用于场景中不移动的物体(即静态物体;它们在检查器区域被指定为静态)。动态光照和移动物体更适合实时光照。然而,Unity 确实通过光照探针为具有烘焙光照的动态物体提供了解决方案,但它们也有自己的限制(例如不支持区域光照和体积光照,与某些材质配合不佳,且没有实时反射),因此,通常需要结合实时直接光照和阴影投射的技术来获得期望的结果。

  • 额外工作:必须进行额外的工作,例如将物体设置为静态,为场景中的动态物体设置光照探针,解决基本的阴影需求,以及平衡生成的光照图质量,仅举几例。

  • 长迭代时间:烘焙光照图的过程可能非常消耗资源,因此在低端开发系统 CPU/GPU 硬件上可能耗时较长,这可能导致环境设计迭代过程显著减慢。

毫无疑问,使用实时光照对环境进行光照更加容易上手。然而,如果你要针对移动或低端硬件规格(例如,任天堂 Switch)进行目标定位,你可能没有选择。与实时光照相比,烘焙光照通常在设备和平台上的性能更佳。

到目前为止,我们的方法一直是使用默认的光照设置,利用 标准(URP)场景模板 提供的 方向光 属性,并在场景中创建额外的灯光——这些灯光默认为 实时。现在,我们必须改变我们的方法,因为我们将针对低端平台,但仍然希望达到 60 FPS。因此,我们需要烘焙光照。

设置烘焙光照

要设置我们的烘焙光照,让我们复制当前的实时光照栖息地内部场景,这样我们就可以无损地实验将其转换为烘焙光照。

项目 窗口中选择你的 Assets/Scenes 文件夹,按 Ctrl/Cmd + D 进行复制,并使用 (baked) 后缀重命名。让我们打开复制的场景并开始设置:

  1. Light 组件的 通用 部分中,在 模式 字段下拉菜单中选择 烘焙 而不是 实时

    对于场景中可能添加的任何其他灯光,请执行相同的操作,以便 Unity 在烘焙时包含它们。请注意,我们不需要更改在 使用贴图的代理光照(是的,贴图) 部分中添加的 Decal 聚光灯——记住,这些不是灯光!

  2. 我们需要让 Unity 知道哪些对象不会移动,以便它们的照明可以烘焙。因此,对于构成墙壁、地板、门道以及场景中散布的 Prefab 对象的模块化 Prefab,在 层次 区域中选择它们。然后在 检查器 区域中,启用 静态(窗口右上角):

图 11.19 – 将 GameObject 标记为静态

图 11.19 – 将 GameObject 标记为静态

  1. 当询问你是否想将子对象也标记为静态时,点击 是,更改 子对象 按钮。

现在场景中的对象已经设置好了,我们将在烘焙之前配置 光照贴图设置

光照设置

光照贴图设置 配置光照烘焙计算及其如何应用于场景。我们需要使用 光照 窗口创建一个新的 光照设置资产 来存储我们的配置。Unity 将使用默认的只读光照设置来烘焙场景光照,直到我们创建资产。

额外阅读 | Unity 文档

光照窗口:docs.unity3d.com/2022.3/Documentation/Manual/lighting-window.xhtml

要创建一个新的 光照设置资产,请按照以下步骤操作:

  1. 通过访问 窗口 | 渲染 | 光照 打开 光照 窗口。

  2. 灯光窗口的顶部,在灯光设置部分下,点击灯光设置资产字段右侧的新建按钮,在项目窗口中创建一个新的灯光设置资产;它将被立即分配:

图 11.20 – 新的灯光设置资产

图 11.20 – 新的灯光设置资产

  1. 将资产命名为与场景名称相同,以便于跟踪 – 以供参考,在第十章中,我们创建了一个名为Habitat Interior 1的新场景;但请使用此处正在使用的当前场景名称。

附加阅读 | Unity 文档

灯光设置资产:docs.unity3d.com/2022.3/Documentation/Manual/class-LightingSettings.xhtml.

我们将在场景选项卡中工作,以优化我们的设置,以实现所需的质量、光照贴图纹理大小和烤制时间的平衡。使用默认值是烤制的基础良好起点。

好的,点击那个烤制按钮!遗憾的是,不是的。实际用于烤制灯光的按钮是位于灯光窗口底部的生成灯光按钮。当开始时,灯光过程将在编辑器窗口的右下角显示进度指示器,并显示完成估计时间。

经过一段时间(取决于您的系统硬件性能)后,生成的灯光过程将完成,场景视图将更新为新的烤制灯光。检查场景中烤制灯光的结果,如果它不符合您的期望,请调整光照贴图设置和/或场景中灯光的设置、位置或旋转,然后再次烤制;根据需要重复此操作。

让我们通过比较相同视图的截图来查看我们劳动的结果,这些截图显示了烤制和实时灯光:

图 11.21 – 烤制与实时渲染对比

图 11.21 – 烤制与实时渲染对比

小贴士

您还希望将烤制灯光的结果与实时灯光进行比较。分析灯光的质量、灯光功能、内存使用情况,以及最重要的是,与目标平台上的期望帧率相比,游戏运行的 FPS。

您可以在Unity 编辑器播放模式下运行时获取一些性能指标,但您将需要在目标设备上构建和测试以获得最佳结果。Graphy – Ultimate Stats Monitor & Debugger (github.com/Tayx94/graphy)是一个非常有用的免费工具,可以帮助分析您游戏的表现。Graphy 的输出可以在图 11.21 的右上角的屏幕截图中看到,其中烤制灯光在实时渲染上显示轻微的 FPS 提升(这是在编辑器窗口中,所以我预计在构建中会有更大的提升)。

你可能对默认光照贴图器设置为渐进式 CPU时烘焙光照生成所需的时间并不满意。幸运的是,如果你的硬件支持,我们可以使用基于 GPU 的光照贴图器来提高计算时间。

小贴士

如果你注意到烘焙光照中的模型上出现了图形伪影,那么可能是因为导入的模型 UV 对于烘焙光照贴图没有提供。在这种情况下,你可以告诉 Unity 自动生成它们以修复其外观。

要让 Unity 生成光照贴图 UV,请在项目窗口中选择有问题的模型,以在检查器区域查看模型导入设置。完成此操作后,确保已选择模型选项卡,然后在几何部分,启用生成光照贴图 UV选项。

Unity 文档 | 生成光照贴图 UV:docs.unity3d.com/Manual/LightingGiUvs-GeneratingLightmappingUVs.xhtml.

现在谈谈那个迭代时间。

提高迭代时间

光照贴图设置允许我们选择渐进式 CPU渐进式 GPU光照贴图器(后者仍在 2022.3.1f1 版本中处于预览状态)。渐进式 GPU可能生成光照的速度要快得多,但这完全取决于你系统中运行 Unity 的 GPU(即显卡)。在我的例子中,渐进式 CPU估计需要 3 个多小时,而渐进式 GPU(在中等 GPU 硬件上)估计需要 40 多分钟。

额外阅读 | Unity 文档

渐进式光照贴图器:docs.unity3d.com/2022.3/Documentation/Manual/progressive-lightmapper.xhtml.

现在我们已经涵盖了场景中静态对象(即我们标记为 Static 的对象,因为它们不会移动)的烘焙光照,我们只需要简要说明一下,当没有实时光照时,场景中的动态或移动对象会受到怎样的光照影响!

光探针

再次强调,Unity 为动态对象光照提供了支持。我们可以使用光探针将烘焙光照应用到移动对象上——如前所述;然而,这与实时光照(如“烘焙光照?”部分中“额外工作”项目所示)相比是额外的工作。请注意,光探针不会影响我们标记为Static的对象——移动对象不应该被标记为Static

光探针被放置在整个环境中,不仅是在移动对象将会出现的地方,还包括光线变化(尤其是显著变化)的区域。我们在场景中放置的光探针将在烘焙时捕捉光线信息,然后根据物体相对于探针的相对位置使用这些数据来照亮移动对象。

额外阅读 | Unity 文档

光探针:docs.unity3d.com/2022.3/Documentation/Manual/LightProbes.xhtml

让我们在场景中放置第一个 光探针组,方法是转到 创建 | 光照 | 光探针组。然后,使用 变换 工具,将新的 移动到级别中房间中央。由于这些房间的照明相当均匀,光线变化主要沿着墙壁发生,因此我们将探针分散开来,以覆盖更广的区域。

图 11.22 中,您可以看到我首先在 层次 区域使用了切换可拾取性功能(*图 11.22 中的 A),关闭了墙壁、地板和天花板的选中,这样我们就可以与探针一起工作,而不会意外选中场景中的其他任何东西(毕竟,这不会很愉快)。

您现在可以启用 光探针编辑图 11.22 中的 B)并在 场景 视图中拖动一个选择窗口(图 11.22 中的 C)来选择组右侧的探针。使用 复制所选 按钮 – 或者经过验证的 Ctrl/Cmd + D 快捷键 – 来复制所选探针,然后使用 变换移动 工具将复制的探针定位在墙壁附近:

图 11.22 – 场景中编辑光探针

图 11.22 – 场景中编辑光探针

额外阅读 | Unity 文档

选择和选择 GameObjects:docs.unity3d.com/Manual/ScenePicking.xhtml

场景可见性:docs.unity3d.com/Manual/SceneVisibility.xhtml

重复选择、复制和定位探针的过程,直到您拥有类似于 图 11.23 中显示的探针组设置:

图 11.23 – 光探针放置示例

图 11.23 – 光探针放置示例

小贴士

记住,在 图 11.23 中,尽管探针分组在这个场景中看起来很均匀,但您并不总是以这种方式设置,因为您希望将探针放置在光线变化的地方(再次强调,尤其是在光线发生剧烈变化的地方)以获得最佳效果。

一旦您在您的级别中设置了 光探针组,您将不得不再次烘焙(嗯,生成)光照,以便探针存储所有光照信息。然后,探针光照数据将在运行时用于照亮动态对象。

您可以通过在 光照 窗口中使用 光探针可视化 设置来可视化光探针对动态对象的影响。当您在场景中选择一个动态对象时,当设置为 仅选择使用的探针 时,将显示影响该对象的探针,与所选的 球体(临时添加到场景中,仅用于可视化光探针),如图 图 11.24 所示:

图 11.24 – 光探头可视化选择

图 11.24 – 光探头可视化选择

小贴士

Unity 还有一个有用的照明工具,称为反射探头。它们的使用可以通过贡献烘焙和实时照明的质量来显著提高场景的视觉保真度——也就是说,这些探头与静态和动态对象一起工作。

如其名所示,反射探头增加了环境中闪亮物体反射的效果。我建议进一步探索反射探头,以提升你游戏视觉质量!

Unity 文档 | 反射探头:docs.unity3d.com/2022.3/Documentation/Manual/class-ReflectionProbe.xhtml

关于烘焙照明,我们还需要讨论一个额外的主题,那就是动态对象的阴影。烘焙阴影是静态对象烘焙照明的一部分——阴影被烘焙到光照图中。场景中的灯光也被设置为烘焙,这样就没有提供阴影给我们的动态对象的方法了。

我们可以通过几种不同的方式实现烘焙光照的半动态阴影效果。我们将在下一节中介绍两种。

烘焙光照动态阴影

对于动态对象阴影,我们可以做的第一件事是为灯光使用混合模式。混合模式将为静态对象烘焙阴影(就像烘焙模式设置一样),并为动态对象计算实时阴影(就像实时模式设置一样)。

光探头不能解决这个问题,因为它们不表示任何直接光照,也不产生阴影;它们只影响应用于动态对象的照明,因此我们将与这种混合照明模式技术结合使用。

因此,要实现它,只需将方向光设置为混合模式并烘焙。完成。

我们可以做的第二件事是伪造(是的,伪造——通常,游戏开发者必须求助于创造性的技术,以视觉上或接近视觉上产生期望的结果,但在幕后以巧妙和创造性的方式)。不用担心——Unity 仍然为我们提供了支持。

Blob 阴影

阴影投射器可以用作在物体下方表示简单 blob 阴影的更优化方式。这项技术对性能受限的平台(例如,移动游戏和低端硬件)有益,在这些平台上,实时阴影投射可能过于消耗性能。

要使用 blob 阴影投射器,我们不需要查看 Assets/Samples/Universal RP/14.0.8/URP Package Samples/Decals/BlobShadow 文件夹。

在场景打开的情况下,在层次区域找到胶囊对象。BlobShadow子对象包含URP Decal Projector组件(这应该对你来说很熟悉),并分配了提供的BlobShadow_Mat材质。

创建一个 Prefab 并将其用于场景中需要动态阴影的动态对象。这 couldn’t be easier!(这真是太简单了!)

在本节中,我们学习了如何使用实时和烘焙光照来照亮室内环境场景,并权衡了这些方法之间的视觉差异和性能权衡。我们还学习了一些克服光照限制的技术。

基于这些以及本章中学到的知识和技巧,花时间完成你关卡的室内环境设计,使其看起来有人居住——对玩家来说既沉浸又吸引人。讲述一个故事。根据你的游戏测试小组的反馈进行必要的迭代。最重要的是,尽情享受吧!

摘要

在本章中,我们介绍了通过使用 Unity 新 2022 Prefab 工作流程导入和替换模块化 Prefab,以及使用 Prefab 模式保留具有额外行为的 Prefab,并应用新材料来完成关卡的结构视觉效果,从而将灰盒原型环境进行转换的过程。

我们继续通过使用Polybrush绘制散布的 Prefab,战略性地放置磨损贴图投影仪以增加独特性和细节,以及通过实时和烘焙光照设置的技术实现来最终确定我们的艺术愿景,其中我们针对动态对象在烘焙光照设置中考虑了额外的光照和阴影问题。

在下一章中,我们将继续通过添加一些急需的声音设计(到目前为止,我们还没有在这方面花费任何时间)来提升玩家的体验。我们将编写AudioManager并创建可重用的音频 播放器组件,使添加音乐、音效SFX)和环境声音变得简单直接(即使是对于艺术家和设计师来说也是如此)。

第十二章:使用音频增强 FPS 游戏

第十一章中,我们通过用高质量的 3D 模型替换模块化预制体、在预制体模式下保留具有额外行为的预制体以及应用新材料,将一个灰盒原型环境进行了转换。我们还通过绘制预制体添加了环境中的对象,通过放置磨损贴图讲述表面故事,并实现了实时和烘焙光照的灯光和阴影设置。

为了提升玩家体验,我们必须关注我们游戏的声音设计方面。到目前为止,我们并没有过多关注音频和声音效果SFX)。现在让我们通过开发音频管理器和可重用的音频播放组件系统来改变这一点。这将使我们能够轻松地将音频和 SFX 添加到我们的游戏中,即使对于可能没有太多编码经验的设计师和艺术家来说也是如此。

在本章中,我们将涵盖以下主要主题:

  • 使用音频混音器添加音频

  • 使用音乐、音效和氛围构建沉浸式声音景观

  • 使用脚步声和混响区域增强音频体验

  • 深入的 SOLID 重构

到本章结束时,您将能够编写音频管理器和其组件的代码,通过音频混音器播放游戏音乐和音效。您还将了解播放 2D 和 3D 声音之间的差异以及如何重用音频播放组件来创建新的声音行为,例如脚步声。此外,您还将学习如何为游戏关卡添加效果区域。

技术要求

您可以在 GitHub 上下载完整项目github.com/PacktPublishing/Unity-2022-by-Example

使用音频混音器添加音频

在任何交互式体验中,声音在将玩家包围在游戏世界中起着至关重要的作用。本节将介绍使用 Unity 的音频工具播放音频的不同方法。您将学习如何通过有效利用音乐、2D 和 3D 音效、环境噪声和混响区域来提升您游戏音频体验。

让我们从定义声音设计是什么开始。

游戏声音设计 101

优秀的声音设计可以显著增强玩家的游戏体验和沉浸感。玩您最喜欢的游戏,但关闭声音,您会很快发现体验完全不同!

几个音频概念结合在一起,构成了在创建沉浸式世界氛围时声音设计的基石。虽然我将介绍声音设计组件,但请知道这更多的是艺术而非技术,将所有概念结合在一起——这是声音设计师的工作!

我们将实现音频脚本,使我们能够添加以下声音设计元素:

  • 音乐/背景音乐:用音乐设定氛围!此外,在游戏玩法中的正确时刻改变音乐对玩家可能产生重大影响。

  • SFX:每当玩家挥舞剑、捡起硬币、撞到箱子或击败敌人时,SFX 对于玩家的沉浸感和增强游戏体验至关重要。

  • 环境或氛围:你听到的环境声音,或背景噪音,它设定了场景,让你知道你在一个真实的居住地。通常,3D 音频会根据玩家在环境中的位置和朝向音频源的变化而变化,这可以极大地增强沉浸感。

  • 角色对话:是的,对话也是声音设计的一部分。他们的声音风格可以进一步定义一个角色。声音也可以用来推动以故事驱动的叙事。

  • UI 反馈:当玩家与 UI 交互时,按钮按下和变化的点击声嘟嘟声哔哔声提供了一个令人满意的反馈体验,你不想从整体声音设计中省略。

将所有先前的声音设计元素汇集在一起,为游戏体验创建一个连贯的音频声音景观,这是声音设计师完成的另一项工作,这被称为音频混音。音频混音允许进一步工程化音频体验,并可以包括额外的音频工具,如过滤器、效果和混响区域。

额外阅读 | Unity 文档

当涉及到音频时,有许多因素需要考虑,例如格式、文件大小、压缩质量、循环能力和运行时性能——仅举几个例子。虽然深入探讨不同音频格式的技术细节超出了本章的范围,但请放心,我将在接下来的部分中 wherever necessary 指出关键格式细节。

你可以在这里了解更多关于音频的信息:docs.unity3d.com/2022.3/Documentation/Manual/Audio.xhtml

现在我们已经了解了声音设计,我们将戴上音频开发者的帽子,并将音频添加到我们的 3D 第一人称射击游戏中。

添加游戏音频

我们将首先添加一个音频混音器。尽管我在上一节中最后提到了音频混音,但我们将首先添加混合游戏音频的能力,并将每个音频组件都通过它进行连接。

使用音频混音器

音频混音器资产将使我们能够独立地为每个定义的声音元素设置音量级别——定制我们游戏的声音景观。它还允许我们在游戏过程中根据需要更改音量,并为玩家提供了一个方便的通过 UI 控件调整音量的方式。

额外阅读 | Unity 文档

你可以在这里了解更多关于音频混音器的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioMixer.xhtml

要添加音频混音器,我们首先必须确保我们在 Unity 中打开了我们的项目,并且打开了来自第十章的栖息地内部级别设计场景(例如,Habitat Interior 1)。

现在,我们可以在新的Assets/Audio文件夹中创建一个新的AudioMixer 1(使用创建 | 音频混合器菜单)。一旦创建了AudioMixer 1,双击它(在项目窗口中)将打开音频混合器窗口,如图所示:

图 12.1 – 音频混合器设置

图 12.1 – 音频混合器设置

在打开音频混合器窗口的情况下,我们现在可以为我们将要混合级别的音频通道添加组。

通过点击标题右侧的大加号(+)添加以下组(参见图 12.1以供参考)。请注意,当组首次出现时,字段会被高亮显示,这样你就可以立即给它命名:

  • 音乐:游戏音乐播放的级别是通过这个组设置的

  • SFX:游戏的 SFX 级别将通过这个组设置

  • 环境音[声音]:所有环境音和噪音将通过这个组设置

小贴士

播放模式中,音频混合器窗口中有一个名为在播放模式下编辑的切换按钮。当启用此切换时,它允许我们在游戏测试时调整或混合音频级别。

图 12.1中,你可以看到我将所有级别都设置为零,除了-16,以查看在游戏过程中这个级别听起来有多响——它应该更低,以免盖过 SFX。

好的,这很简单!我们将在接下来的章节中继续编码,创建具有播放不同声音元素组件的音频系统。一切从音频管理类开始。

创建一个简单的音频管理器

根据本书此阶段应能识别的代码架构,在第二章中介绍了 SOLID 原则和设计模式,我们将遵循 SOLID 原则创建一个音频管理类。具体来说,我们将依赖 SOLID 开闭原则OCP)和多态性,并为负责在游戏中播放不同类型声音的不同类型的音频播放器组件引入一个接口。

现在,让我们在新的Assets/Scripts/Audio文件夹中创建一个名为AudioManager的 C#脚本。我们将从添加必要的变量声明开始:

using UnityEngine.Audio;
public class AudioManager : MonoBehaviour
{
    [SerializeField] private AudioMixerGroup _groupMusic;
    [SerializeField] private AudioMixerGroup _groupSFX;
    [SerializeField] private AudioMixerGroup _groupAmbient;
    private AudioSource _audioSource2D, _audioSourceMusic;
}

在这里,我们可以看到实现我们在上一节中配置的音频混合器组的准备工作。我们还声明了一些AudioSource变量用于声音播放——这些将在我们添加代码并实现以下构建具有音乐、SFX 和 环境音 的沉浸式声音景观 *子节中的播放音频功能时进行解释。

重要提示

我现在假设你已经知道,如果我们有一个从MonoBehaviour继承的类,我们需要在 C#脚本顶部添加一个using UnityEngine;语句。正如你也应该知道的,这个using语句已经存在于默认脚本模板中。 😃

再次强调,AudioManager类将为我们处理的关键功能之一是设置我们想要播放的音频类型的音频混音器组。这将确保声音设计师可以使用音频混音器来设置游戏(即,设计声音景观)的初始音频播放级别。

现在,将以下代码添加到AudioManager类中:

public enum AudioType { Music, SFX, Ambient };
private AudioMixerGroup
    GetAudioMixerGroup(AudioType audioType)
        => audioType switch
    {
        AudioType.SFX => _groupSFX,
        AudioType.Music => _groupMusic,
        AudioType.Ambient => _groupAmbient,
    };

switch 表达式(C#)

你可以在这里了解更多关于使用switch关键字进行模式匹配表达式的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression

在这里,我们创建了一个AudioType枚举,我们将使用它将音频播放器组件映射到音频混音器组。我们已经添加了GetAudioMixerGroup()方法,通过使用switch表达式并传递一个AudioType枚举来获取适当的音频混音器组。所有这些将在我们创建第一个音频播放器组件的下一节中变得更加清晰。

抛弃(C#)

注意,如果前面代码中的 switch 表达式没有处理所有可能的输入值,编译器将生成一个警告。在这种情况下,我们可以使用以下抛弃模式来处理所有可能的输入值,从而避免控制台警告:_ => throw new ArgumentOutOfRangeException(nameof(AudioType), $"Not expected audioType value: {audioType}"),

下划线(_)是一个占位符变量,它不会保留任何值,也不打算使用。

你可以在这里了解更多关于抛弃的信息:learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/discards

在我们现在编写的AudioManager类中,我们准备开始制作音频播放器组件!但在我们这样做之前,我们需要一种方法来访问AudioManager以调用其方法。所以,我们将继续使用 Singleton 模式来处理我们的管理器 – 现在就添加所需的代码吧。

作为提醒,这里就是:

public static AudioManager Instance { get; private set; }
private void Awake()
{
    if (Instance == null)
        Instance = this;
    else
        Destroy(gameObject);
    DontDestroyOnLoad(gameObject);
}

要将AudioManager脚本添加到我们的项目中,创建一个新的名为AudioManager的 GameObject,并在AudioManager脚本中将其添加到该 GameObject 中。

现在我们可以通过点击和拖动从音频混音器窗口或使用字段的对象选择器窗口(字段右侧的小圆圈图标)来分配AudioMixerGroup字段,使用图 12**.2作为指南。

图 12.2 – 混音组分配

图 12.2 – AudioManager混音组分配

完成!嗯,实际上我们还没有在我们的音频管理器中添加任何播放声音的方法……

游戏的音频管理器基础已经就绪,因此我们现在可以开始编写单个组件来播放游戏中需要的不同类型的音频。

使用音乐、SFX 和氛围构建沉浸式声音景观

我们将涵盖为我认为最常见场景编写的音频播放器组件。我们将有播放音乐、音效和环绕声的组件。我们还将介绍在 2D 或 3D 空间中播放声音,根据需要或愿望,为每种类型的音频播放器组件。

在 Unity 中播放声音的所有内容都需要一个 AudioSource 组件(将其视为扬声器,您可以有多个),场景需要一个单独的 AudioListener(将其视为麦克风)。默认情况下,场景中的主相机已经添加了 AudioListener 组件——所以没问题;那里没有其他需要做的事情。

额外阅读 | Unity 文档

您可以在此处了解更多关于音频监听器的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioListener.xhtml.

如在 AudioManager 实现中所示,请注意,我们确保所有音频播放器组件都通过 AudioManager 进行音频播放,以便使用正确的音频混音器组。这种设计是有意为之的,因此任何添加音频播放器组件的人(开发者、设计师、艺术家等)都不必记住为特定组件类型设置正确的混音器组。这将非常简单,因为——您猜对了——我们将使用一个接口(正如我们将在编写音频播放器组件时很快看到的)。

项目音频文件

下文将使用的音频文件都是公共领域或Creative Commons ZeroCC0)(creativecommons.org/publicdomain/zero/1.0/deed.en),并且可以从 GitHub 仓库中的Audio-Assets文件夹、单个 Unity 项目资源文件夹或相应部分提供的原始下载链接中获取。

本章 GitHub 仓库中的音频资源可以在这里找到:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch12/Audio-Assets.

好的,接下来是我们的第一个音频播放器组件。是时候播放一些美妙的音乐了!

播放音乐

我们将深入研究将音乐融入您游戏的技术方面。音乐作曲家负责创建与您的观众产生共鸣并成为您游戏身份象征的音轨。因此,我强烈建议您与作曲家合作,以实现这一级别的质量。

然而,如果您正在寻找一个更经济实惠的选项,您可以使用来源音乐,作曲家为不同流派创建预制的配乐,这些配乐可以购买并在您的项目中使用(有时,就像我们在这里的情况一样,甚至可以免费使用)。

但是,在我们可以将任何音乐片段分配给音频播放器组件之前,我们首先必须创建一个。

警告!

下一个将要介绍的 AudioPlayerMusic 类不会遵循 SOLID OCP。这是故意的。我们将最初编写的这种方法作为示例,用于重构其余的音频播放组件。希望这能作为一个强化学习的机会,以巩固知识。

我们从播放音乐开始,因此在一个新的 Assets/Scripts/Audio 文件夹中创建一个名为 AudioPlayerMusic 的新 C# 脚本,并声明以下字段和方法:

public class AudioPlayerMusic : MonoBehaviour
{
    [SerializeField] private AudioClip _musicClip;
    [SerializeField] private bool _playOnStart = true;
    private void Start()
    {
        if (_playOnStart)
            Play();
    }
    private void Play()
        => AudioManager.Instance.PlayMusic(_musicClip);
}

现在,让我们看看我们在这里做了什么:

  • _musicClip:音频剪辑包含要播放的声音的音频数据,它支持许多流行的音频文件格式(.wav.mp3.ogg 是最常用的)。在 Inspector 窗口中,这个变量将被分配一个从 Project 窗口文件夹中音频文件资产的引用,用于播放我们游戏的音乐。

额外阅读 | Unity 文档

你可以在这里了解更多关于音频剪辑的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioClip.xhtml

  • _playOnStart:这个变量是一个简单的布尔标志,我们将在 Inspector 中设置,以告诉这个音频播放组件在游戏开始时开始播放或不播放。

  • Start():我们在 Start() Unity 事件方法中评估 _playOnStart 的值,如果为 true 则调用 Play()。很简单。

  • Play():这是魔法发生的地方,我们告诉 AudioManager 开始播放我们可爱的音乐音频剪辑。

如您所见,我们在 AudioManager 单例实例中调用了一个 PlayMusic() 方法。然而,这个方法还不存在。让我们现在修复它以完成播放音乐的功能。

将 PlayMusic() 方法添加到 AudioManager

现在,将 PlayMusic() 方法添加到 AudioManager 类中,如下所示:

public void PlayMusic(AudioClip clip)
{
    if (_audioSourceMusic == null)
        _audioSourceMusic =
            gameObject.AddComponent<AudioSource>();
    _audioSourceMusic.outputAudioMixerGroup = _groupMusic;
    _audioSourceMusic.clip = clip;
    _audioSourceMusic.spatialBlend = 0f; // 2D
    _audioSourceMusic.loop = true;
    _audioSourceMusic.Play();
}

AudioManager 将直接通过向其所在的 GameObject 添加 AudioSource 来播放音乐:

gameObject.AddComponent<AudioSource>();

然后,根据我们建立的架构,设置适当的混音组,将作为参数传入 _audioSourceMusic 音频源的音剪辑,其余属性设置音乐在 2D 空间和无限循环中播放。

_audioSourceMusic 播放音乐的属性如下:

  • spatialBlend = 0f:基于玩家在环境中的位置,我们不想让音乐被听到不同的效果,因此将空间混合属性设置为 0f 使其成为全 2D 声音。

提示

当向场景添加 2D 音频源时,请注意,它们在全局空间中的变换位置无关紧要;音频监听器总是听到它们。

  • loop = true:这个很简单——loop 是否等于 true?是的。那么就循环播放音频剪辑,永远循环!否则,只播放一次。

AudioSource 组件的其余属性将设置为它们的默认值,这对于背景音乐来说都很好。

额外阅读 | Unity 文档

您可以在此处了解更多关于 AudioSource 的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioSource.xhtml

最后,使用 _audioSourceMusic 上的 Play() 方法播放音乐。要让音乐在我们的游戏中播放,我们只需将 AudioPlayerMusic 添加到场景中即可。

实现 AudioPlayerMusic

实现我们的音乐音频播放器组件意味着将 AudioPlayerMusic 脚本添加到我们的场景中(这里没有惊喜)。

AudioPlayerMusic 作为 AudioManager 对象上的 AudioManager 组件的兄弟组件。图 12.3 展示了我们的最终音乐音频播放器组件设置。

图 12.3 – 将 AudioPlayerMusic 添加到 AudioManager 对象

图 12.3 – 将 AudioPlayerMusic 添加到 AudioManager 对象

我们可以在前面的图中看到,音乐音频片段已经分配给我们的项目中的 Arpent 音频文件,并完成该分配。

如果您尚未从项目 GitHub 仓库导入音频资源,请参阅 使用音乐、SFX 和氛围构建沉浸式声音景观 部分,并现在执行该操作。我们接下来需要音频文件以及以下部分。

我们将在游戏中使用的音乐文件是 Arpent.mp3,来自 FreePD (freepd.com/music/Arpent.mp3)。音乐受 CC0 许可,这意味着我们可以免费使用。

确保将 Arpent.mp3 文件导入到 Assets/Audio/Music 文件夹中。然后,在 项目 窗口中选中该文件,让我们在 检查器 窗口中调整导入设置,以适应通常较长的音乐片段——您可以在以下图中的 检查器 窗口中看到音乐文件几乎有三分钟长(确切地说为 2:42.064)。

图 12.4 – 音频片段导入检查器设置

图 12.4 – 音频片段导入检查器设置

重要提示

到现在为止,您可能已经注意到,当我们选择 项目 窗口中的资源时,检查器 会根据所选资源的类型进行调整,因此导入设置看起来会有所不同。

我们在此处更改的设置如下:

  • 后台加载:对于较长的或高质量的音乐文件,启用在后台加载音频文件可以确保游戏运行顺畅,因为异步加载(即不阻塞主线程)消除了帧降和卡顿的可能性

  • 加载类型:对于较长的音乐文件,建议将其设置为 流式传输,这样所有音频数据就不会一次性加载到内存中

  • 70% 通常对于音乐来说是一个良好的平衡点——文件大小与质量

音频片段导入设置 | Unity 文档

你可以在这里了解更多关于音频剪辑检查器选项的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioClip.xhtml

当你完成这些更改后,通过选择AudioManager对象并将Arpent音乐文件中的AudioPlayerMusic组件拖到音乐剪辑字段,我们就完成了!

保存你的场景并进入播放模式,以在游戏开始时听到音乐播放。如果你什么也听不到,你可能需要切换游戏视图中的静音音频图标,如下所示:

图 12.5 – 游戏静音音频切换

图 12.5 – 游戏静音音频切换

我们已经在 Unity 中开始了音频沉浸和探索之旅!听到音乐播放是一种很好的方式,可以确立游戏氛围并为我们的游戏设定舞台。游戏音效设计师会仔细选择音乐曲目,以创造所需的情感和主题基调,唤起惊奇、神秘或未知,从而营造一种广阔而引人入胜的氛围。当我听到Arpent音乐曲目时,我意识到它非常适合科幻环境和玩家在这个关卡中对栖息站进行探索。

但尽管播放音乐很棒,我相信你已经知道,没有音效的游戏什么都不是!

播放 SFX

告诉我,一部特定的科幻电影系列(设定在遥远的星系中)如果没有其标志性的激光剑嗡嗡声和切割空气以及碰撞的声音会完整吗?答案是肯定的……不会的。

SFX 是游戏开发中的无名英雄——没有它们,游戏只有 50%是完整的(不,你不能争辩这一点)。那么,我们还在等什么呢……这些 SFX 不会自己播放!

使用 IPlaySound 接口进行 SOLID 重构

现在,我们将根据 SOLID 原则进行重构。具体来说,AudioManager将关闭修改,遵循 OCP 原则。对于开放部分,我们将传入音频播放器对象的类型——这也是多态的部分——每个都有其自己的实现(即修改)来播放声音,并且音频播放器类型实现了一个接口:IPlaySound。该接口确保我们有一个一致的公共方法可以调用我们实现的每个不同的音频播放器对象类型。

我们通过允许使用AudioType枚举来对关闭修改原则进行例外处理。这个决定是基于让开发者和设计师更容易添加音频播放器组件。通过简化流程并防止潜在的错误,我们旨在节省时间并消除手动分配音频组的需求。权衡是,每次添加新的混音组时,我们都需要修改AudioManager,但我对此表示接受。

在解释清楚这一切之后,让我们来看看IPlaySound接口。在Assets/Scripts/Audio文件夹中创建一个新的 C#脚本,命名为IPlaySound,代码如下:

using AudioType = AudioManager.AudioType;
public interface IPlaySound
{
    AudioType PlayAudioType { get; }
    void PlaySound(AudioSource source);
}

下面是界面声明的分解:

  • using AudioType: 有时,我们必须区分跨命名空间共享的类型名称。我们定义了AudioManger.AudioTypeAudioType存在于UnityEngine中。因此,我们需要告诉脚本我们想要哪一个。为此,我们使用别名:using AudioType = AudioManager.AudioType;

using 别名 (C#)

你可以在这里了解更多关于using别名的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive#usingalias

  • PlayAudioType: 此属性将获取我们从AudioType枚举分配的默认值。我们将根据此值设置AudioMixerGroup,以针对我们制作的特定类型的音频播放组件——并在检查器中避免任何分配错误。

  • PlaySound(): 正如其名所示,实现类将使用此方法来播放音频剪辑。

那就是我们所需要的接口。现在,我们的音频管理类可以通过接口类型传递每个不同的音频播放类而不做任何修改,遵循 SOLID OCP 原则。通过接口类型传递每个不同的音频播放类,实现接口的音频播放类将提供独特的播放功能。

让我们通过编写播放 SFX 组件的示例来看看。在Assets/Scripts/Audio文件夹中创建一个名为AudioPlayerSFX的新脚本,并使用以下代码:

using AudioType = AudioManager.AudioType;
public class AudioPlayerSFX : MonoBehaviour, IPlaySound
{
    [SerializeField] private AudioClip _audioClip;
    [Range(0f, 1f)]
    [SerializeField] private float _volume = 1f;
    public void Play() =>
        AudioManager.Instance.PlayAudio(this);
}

首先,我们在AudioPlayerSFX类声明中添加了IPlaySound接口,以符合我们的音频播放组件设计。

接下来,我们有这些核心变量和方法,它们将适用于所有音频播放组件,因为它们都被认为是播放声音所必需的:

  • _audioClip: 每个音频播放组件都需要一个剪辑来播放。此变量指的是通过检查器分配并播放的音频剪辑。

  • _volume: 并非每个音频文件在播放时都会拥有相同的音量级别,或者,对于游戏中的一些声音,它们可能通过降低音量来满足音效设计;通过此变量在检查器中设置。

  • Play(): 我们将调用的公共方法,用于使用AudioManager单例实例的PlayAudio()方法(我们将在下一节中添加)开始播放分配的音频剪辑。

当调用音频管理器的PlayAudio()方法时,请注意我们传递的是this作为参数。this关键字指的是当前类实例——在这种情况下,AudioPlayerSFX,但我们将使用的参数类型用于声明PlayAudio()将是IPlaySound。这就是多态的魔力。我们将this作为类型参数传递,代表任何数量的不同类,以实现独特的音频播放功能,但所有这些都可以作为IPlaySound访问。

this (C#)

你可以在这里了解更多关于this关键字的用法:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/this

现在,实现IPlaySound接口的公共属性和方法(记住,接口声明必须是public):

    public AudioType PlayAudioType => AudioType.SFX;
    public void PlaySound(AudioSource source)
        => source.PlayOneShot(_audioClip, _volume);

在这里不再重复接口的细节,让我们深入了解分配:

  • PlayAudioType: 这里是我们为想要使用的音频混音组预分配AudioType值的地方,以便这个音频播放器组件使用。

  • PlaySound(): 这里是我们实际上使用针对此类音频播放器组件的特定音频播放代码以及引用的音频源来播放声音的地方。在音效的情况下,我们将使用PlayOneShot()方法,因为它允许在单个AudioSource组件上播放多个声音。

注意这里传递的AudioSource组件将被添加到用于 2D 音效的AudioManager对象中。因此,没有必要在添加了此音频播放器组件的对象上添加AudioSource组件。你将在下一节中看到它是如何工作的,当我们向AudioManager添加内容时。

额外阅读 | Unity 文档

你可以在这里了解更多关于PlayOneShot()的信息:docs.unity3d.com/2022.3/Documentation/ScriptReference/AudioSource.PlayOneShot.xhtml

为了提供一些关于这个架构的额外清晰度,既然你已经看到了代码和方法调用是如何设置的,让我们看看AudioPlayerSFXAudioManager之间的关系:

图 12.6 – 音频架构 UML 类图

图 12.6 – 音频架构 UML 类图

代码调用音频管理器 – AudioManager.PlayAudio() – 来分配正确的音频混音组。然后它使用接口调用回其特定的播放声音功能(带有负责播放声音的音频源) – IPlaySound.PlaySound()。随着我们在后续章节中实现额外的音频播放器组件,每个组件都有独特的播放声音功能来播放 3D 音效和环境声音,这会变得更加清晰。

这完成了我们的 SFX 播放音频播放器组件,但还没有完成,因为如果没有AudioManagerPlayAudio()方法,它将无法播放任何声音。让我们现在通过更新我们的音频管理器来解决这个问题。

更新 AudioManager

现在将所有这些放在一起,我们正在添加AudioManager.PlayAudio()方法来设置分配的混音组,并回调到传递的组件的特定PlaySound()功能来播放音频 – 在这种情况下,是音效。

现在,打开AudioManager脚本并添加以下方法:

    public void PlayAudio(
        IPlaySound player,
        AudioSource source = null)
    {
        if (source == null)
            source = AudioSourcePlaySFX;
        source.outputAudioMixerGroup =
            GetAudioMixerGroup(player.PlayAudioType);
        player.PlaySound(source);
    }

PlayAudio()将关闭修改,因此其结构适合任何数量的实现IPlaySound接口的音频播放组件。因此,我们在方法签名中的第二个参数source具有默认值null

对于播放 2D 声音,我们不会要求场景中每个将要播放声音的对象都附加AudioSource组件。场景中过多的AudioSource组件可能会导致与内存使用、处理开销和音频削波相关的性能问题,如果同时播放太多的这些音频源(请参阅随后的优化注意事项信息,因为 Unity 限制了可以同时播放的音频源数量)。

对于具有null值的音频源,我们首先假设它是一个将要播放的 2D 声音 - 3D 声音将会有自己的音频源传入。我们将使用一个private属性来获取播放音频片段的AudioSource实例(使用属性获取器)而不是使用private方法来返回(即get)类中所需的AudioSource引用。

让我们继续添加AudioSourcePlaySFX属性到AudioManager中,这样我们就有了一个有效的音频源组件添加到场景中用于播放我们的 2D 声音:

private AudioSource AudioSourcePlaySFX
{
    get
    {
        if (_audioSource2D == null)
        {
            _audioSource2D = new
                GameObject().AddComponent<AudioSource>();
            _audioSource2D.spatialBlend = 0f; // 2D
        }
        return _audioSource2D;
    }
}

对于这个属性,我们可以看到我们定义了一个获取器。它评估了private成员_audioSource2D(之前已添加)以查看它是否已经分配了一个AudioSource组件,如果没有,则使用AddComponent()方法将一个组件添加到场景中(作为与AudioManager相同的 GameObject 的兄弟节点)。

然后它将spatialBlend值设置为确保声音的 2D 播放,并返回新的AudioSource以便为其分配混音组,然后将其传递回音频播放组件以播放声音。哇!

优化注意事项

注意,对于播放 SFX 声音的AudioSource组件,我们对可以同时播放的音频片段数量有限制。AudioSource支持 32 个声音,每个音频片段播放需要 2 个声音。使用PlayOneShot()方法,我们可以播放多达这个限制的音频片段,此时它将开始削波。你可能已经猜到了,我们可以通过通过AudioManager组件对象池化AudioSource对象来支持更多的音频片段。请参阅第六章

太好了,到目前为止,我们根据我们的架构满足了所有的音频播放需求。现在让我们看看如何在添加额外的音频播放组件之前,在我们的场景中实现播放 SFX。

实现 AudioPlaySFX – Unity 事件

要将我们的第一个 SFX 添加到游戏中,让我们回顾一下您已经熟悉的内容:我们的健康恢复。在跟随前几章(例如 第十章充电即恢复 部分)之后,您应该在场景中已经有了它。找到并选择它。现在,让我们将 AudioPlayerSFX 组件添加到根 GameObject。如图所示,它被添加在 Destroyer (Script) 组件的下方。

图 12.7 – 健康恢复 SFX

图 12.7 – 健康恢复 SFX

AudioPlayerSFX 分配为在从前面的 PickupHeal 组件触发 OnHealEvent 时播放声音。

  1. PickupHeal (Script) 组件上,点击加号(UnityEvent 字段)。

  2. 点击并拖动 AudioPlayerSFX 组件(通过其标题栏)到新条目。

  3. 在函数选择下拉菜单中,选择 AudioPlayerSFX | Play()

好的,所有线路都已经连接好了,但我们还缺少一些东西……我们将播放什么声音?!

分配音频剪辑

我们将用于健康恢复的声音文件是 item-pickup-v1.wav。它来自 Freesound 网站(https://freesound.org/people/DeltaCode/sounds/678384/),并受 CC0 许可,这意味着我们可以免费使用它。

确保 item-pickup-v1.wav 文件已导入到 Assets/Audio/SFX 文件夹。与音乐音频文件不同,我们将为此声音文件使用默认的导入设置值。返回到健康恢复的 AudioPlayerSFX 组件,然后点击并拖动 item-pickup-v1 文件到 Audio Clip 字段。在 Inspector 的顶部,点击 Prefab | Overrides 下拉菜单中的 Apply All,以确保关卡中所有健康恢复实例都更新为包含播放 SFX。

测试关卡,根据需要调整 AudioPlayerSFX 组件,并注意 2D 声音播放。接下来,我们将在 3D 空间中播放 SFX,以便您能听到区别。

播放 UI 反馈 SFX

将 SFX 添加到按钮点击也是游戏声音设计的一个重要方面。它显著提升了游戏的整体品质并提高了玩家的满意度。这看似是一个小细节,但它可以极大地影响玩家对您的游戏 UI 的感知和交互。对于 UI 按钮,使用 AudioPlayerSFX 组件,并将 Play() 方法连接到 Inspector 中的按钮的 On Click() 事件。

现在我们已经使用重构方法创建了一个音频播放器组件,其余部分将遵循相同的设置,所以我们只需完成剩下的部分。

玩转 SFX 3D

AudioPlayerSFX 组件不同,我们没有为播放 2D 声音的 GameObject 添加 AudioSource 组件,这里我们将添加一个,因为我们想要一个“3D 空间中的扬声器”来发出声音。玩家将通过 Player 对象上的 AudioListener 组件听到声音,就像在现实世界中自然听到的那样——无论哪个“耳朵”朝向声音源,都会听到。在 3D 空间中听声音进一步增强了玩家对游戏世界的沉浸感,所以我们肯定想利用 3D 音频!

注意到关于 AudioSource 的先前信息,我们将确保添加“播放 3D 音效”组件到对象的开发者/设计师配置了用于此声音的音频源,通过要求添加 AudioSource 组件作为兄弟组件。

现在,在 Assets/Scripts/Audio 文件夹中创建一个名为 AudioPlayerSFX3D 的新脚本,并从以下音频源要求代码开始:

using AudioType = AudioManager.AudioType;
[RequireComponent(typeof(AudioSource))]
public class AudioPlayerSFX3D : MonoBehaviour, IPlaySound
{
    [SerializeField] private AudioSource _audioSource;
    private void OnValidate()
        => _audioSource = GetComponent<AudioSource>();
}

这就是我们在这里所做的工作:

  • [RequireComponent]:通过将此属性装饰到类上,我们要求在此 GameObject 上存在一个兄弟组件。具体来说,我们需要一个 AudioSource 组件,因为这将是一个 3D 声音。

  • OnValidate():我们可以使用这个 Unity 消息事件来预先分配 _audioSource 变量,该变量由 RequireComponent 属性添加的 AudioSource 实例。OnValidate() 只在编辑器中运行,当脚本被加载或值发生变化时在 Inspector 中调用。

  • IPlaySound:别忘了添加接口!我们需要为所有音频播放器组件实现接口。

额外阅读 | Unity 文档

你可以在这里了解更多关于 OnValidate() 的信息:docs.unity3d.com/2022.3/Documentation/ScriptReference/MonoBehaviour.OnValidate.xhtml

让我们继续借鉴 AudioPlayerSFX 类;我们还将有 _audioClip_volume 序列化的私有成员变量。对于这些,我们将添加一个额外的字段来分配 2D 到 3D 声音比率的 _blend2Dto3D 变量(我们将用它来分配给 AudioSource.spatialBlend 属性)。

因此,添加以下代码:

public class AudioPlayerSFX3D : MonoBehaviour, IPlaySound
{
    …
    [SerializeField] private AudioClip _audioClip;
    [Range(0f, 1f)]
    [SerializeField] private float _volume = 1f;
    [Tooltip("0 = 2D, 1 = 3D"), Range(0f, 1f)]
    [SerializeField] private float _blend2Dto3D;
    …
    public void Play() =>
        AudioManager.Instance.PlayAudio
            (this, _audioSource);
}

这里 Play() 方法与之前 AudioPlayerSFX 实现的 Play() 方法略有不同,我们现在将音频源引用传递给 AudioManager。提醒一下,我们需要这样做,因为我们想在播放声音之前将播放分配给正确的音频混音组。

说到播放声音,现在,实现 IPlaySound 接口的公共属性和方法:

public class AudioPlayerSFX3D : MonoBehaviour, IPlaySound
{
    public AudioType PlayAudioType => AudioType.SFX;
    …
    public void PlaySound(AudioSource source)
    {
        source.spatialBlend = _blend2Dto3D;
        source.PlayOneShot(_audioClip, _volume);
    }
}

我们将 PlayAudioType 设置为使用 SFX 混音组播放声音,而 PlaySound() 方法保持基本不变——除了我们在使用 PlayOneShot() 播放音频剪辑之前,设置了 source.spatialBlend 的值,以在对象的变换位置(在 3D 空间)和设定的音量级别播放音频。

PlayClipAtPoint() | Unity 文档

对于熟悉 Unity 脚本 API 的你来说,可能会 wonder 为什么我没有直接使用 AudioSource.PlayClipAtPoint(_audioClip, transform.position, _volume) 静态方法。

好吧,原因归结于实现 AudioManager 类的主要目标——那就是确保音频混音组被用于所有不同的音频播放组件。虽然 PlayClipAtPoint() 方法确实可以在 3D 世界空间中的某个位置播放音频剪辑,但它不与音频混音器协同工作,因此排除了这个选项。

你可以在这里了解更多关于 AudioSource.PlayClipAtPoint() 的信息:docs.unity3d.com/2022.3/Documentation/ScriptReference/AudioSource.PlayClipAtPoint.xhtml

AudioPlayerSFX3D.Play() 现在调用 AudioManager.Instance.PlayAudio() 并添加 _audioSource 参数。传入的音频源在 AudioManager 中被修改,并传递回实现 PlaySound() 方法的接口,以使用音频播放组件的特定功能来播放声音。

关于代码架构的说明

由于我们使用接口而不是在 AudioManager 中实现播放声音,因此 AudioManagerAudioPlayerSFX3D 类之间的关系可能看起来是循环的。在这里,我认为这种架构上的权衡是可以接受的,因为我优先考虑了组合、易用性和消除 Unity 中 Inspector 分配的错误。在 Unity 中做事有时意味着开发新颖的方法和妥协,以适应其他“标准方法”的 C# OOP 软件开发。我早就接受了这一点。 😃

现在,我们将通过再次查看实现来跟进新的 3D 音频播放组件。

实现 AudioPlaySFX3D – 动画事件

就像我们回顾了之前创建用于播放 SFX 的对象(健康恢复)一样,我们在这里也会做同样的事情,并将 3D SFX 添加到我们在 第十章 中创建的开门动画中。以下图显示了滑动门 Prefab——我们将在它滑动打开时添加 3D 声效。

图 12.8 – 滑动门 3D 特效

图 12.8 – 滑动门 3D 特效

由于门没有具有公开 UnityEvent 的组件被调用,就像健康恢复一样,我们必须以不同的方式触发声音播放。我们仍然会使用一个事件,但这个事件是我们将直接添加到开门动画中的。

按照以下步骤将 3D SFX 添加到滑动门动画中:

  1. 打开 Door_Triggered Prefab 进行编辑。

  2. AudioPlayerSFX3D 组件添加到与 Animator 组件相同的对象上。如图 *12**.8 所示,它被添加在 Sliding_Door_01 子对象上的 Animator 组件下方。

  3. 现在,在 Hierarchy 窗口中仍然选择 Sliding_Door_01,通过转到 Window | Animation | Animation(或按 Ctrl/Cmd + 6)打开 Animation 窗口。

  4. 使用以下 Animation 窗口图作为参考,将时间轴向前滚动一帧或两帧,然后点击 Add Event 按钮。这将向时间轴添加一个 Animation Event 复选标记并选择它(当选中时为蓝色,未选中时为白色)。

图 12.9 – 添加动画事件

图 12.9 – 添加动画事件

当选择 Animation Event 时,Inspector 窗口将显示一个 Function 下拉菜单,以便我们可以选择用于事件的调用方法。

  1. Function 下拉菜单中选择 AudioPlayerSFX3D | Methods | Play()

动画事件 | Unity 文档

AudioPlaySFX3D 组件必须与 Animator 组件位于同一对象上,才能在 Animation 时间轴的 Function 下拉菜单中选择 Play()

额外阅读 | 使用动画事件: docs.unity3d.com/2022.3/Documentation/Manual/script-AnimationWindowEvent.xhtml

  1. 保存 Prefab(或应用覆盖),因为我们已经完成了滑动门播放 3D SFX 的接线!

等一下,不要急于跳过并开始进入 Play 模式来测试它……我们还需要分配门滑动打开时将播放的声音!

分配音频剪辑

我不知为何想要抓取 《星际迷航:下一代》 企业号 NCC-1701-D 的滑动门开启声音,但好吧,版权法和其他一切。 😉

不必担心,Freesound 再次伸出援手!我们将用于滑动门的音频文件是 cua-ien-tu-mo.wav(在越南语中,của điện tử mở 或“打开电子”),您可以从这里下载:https://freesound.org/people/SieuAmThanh/sounds/511540/。它也获得了 CC0 许可。

确保 cua-ien-tu-mo.wav 文件已导入到 Assets/Audio/SFX 文件夹,然后通过再次打开 Door_Triggered Prefab 返回到 AudioPlayerSFX3D 组件。点击并拖动 cua-ien-tu-mo 文件到 Audio Clip 字段,然后点击 Save

测试关卡,根据需要调整 AudioPlayerSFX3D 组件,并注意 3D 声音播放。

3D Sound Settings | Unity 文档

使用 AudioSource 组件的 3D Sound Settings 部分来调整声音,以实现所需的空間效果。本节中的参数按比例应用于 Spatial Blend 参数。

你可以在这里了解更多关于音频源属性的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioSource.xhtml

我们将要处理的下一个音频播放器组件也是用于 3D 声音——在下一节中,让我们制作一个用于环境声音的音频播放器。

播放环境声音

在游戏环境中添加环境 3D 声音对于定义场景、使其生动起来以及增强玩家的感官体验至关重要。在向我们的环境中添加环境声音时,让我们记住,匹配声音与场景至关重要,分层不同的声音元素以创建声音景观,并改变或变化声音——无论是交互式还是非交互式。

我们首先需要我们的环境音频播放器组件来开始向我们的级别添加环境声音。因为环境声音本质上是 3D 的,所以我们再次采取与我们的 3D SFX 相同的方法,并依赖于添加到我们的对象中的音频源来产生其声音。

我们这次将直接在AudioSource组件上使用Play()方法,因为其他方法,如PlayOneShot(),无法触发循环声音。然而,使用AudioSource.Play()也有一些限制。它只允许一个音频剪辑与音频源同时播放。在已经播放的音频源上调用Play()将停止音频源并重新开始剪辑。但我们对在环境中播放环境声音的循环播放能力更感兴趣,所以这些限制在这里不是问题。

让我们立即处理设置AudioSource组件。在Assets/Scripts/Audio文件夹中创建一个新的脚本名为AudioPlayerAmbient,并添加以下代码:

[RequireComponent(typeof(AudioSource))]
public class AudioPlayerAmbient : MonoBehaviour, IPlaySound
{
    [SerializeField] private AudioSource _audioSource;
    private void OnValidate()
        => _audioSource = GetComponent<AudioSource>();
}

接下来,我们需要检查器字段来分配音频剪辑,以便在这个对象上播放环境声音以及所需的播放方法。添加以下代码:

public class AudioPlayerAmbient : MonoBehaviour, IPlaySound
{
    …
    [SerializeField] private AudioClip _audioClip;
    private void Start() => Play();
    public void Play() =>
        AudioManager.Instance.PlayAudio(this, _audioSource);
}

与添加环境声音相关的方法的具体细节如下:

  • Start():环境声音总是播放!(这是我刚刚制定的一条规则。)因此,当级别开始时,我们将开始播放环境声音。请注意,因为这是一个 3D 声音,并且配置了特定的音频源附加到环境中的 3D 对象,所以玩家只有在范围内才能听到它。

  • Play():与之前所做类似,我们调用音频管理器的PlayAudio()方法,并传入环境声音的AudioSource组件来修改它,并分配正确的音频混音组进行播放。

再次,实现IPlaySound接口的公共属性和方法,以便音频管理器知道使用哪个音频混音组:

using AudioType = AudioManager.AudioType;
…
public class AudioPlayerAmbient : MonoBehaviour, IPlaySound
{
    public AudioType PlayAudioType => AudioType.Ambient;
    …
    public void PlaySound(AudioSource source)
    {
        source.clip = _audioClip;
        source.spatialBlend = 1f;   // 3D
        source.loop = true;
        source.Play();
    }
}

这是一个环境声音,因此我们必须将PlayAudioType设置为Ambient,然后无限期地播放音频剪辑,以便为这个音频播放器组件的特定PlaySound()方法功能,如下所示:

  1. source.clip设置为_audioClip值(在检查器中设置)。

  2. spatialBlend 设置为 1f,就像我们在 AudioPlayerMusic 组件中强制音乐在 2D 中播放(0f)一样。在这里,我们将强制音频以完全 3D 声音播放。

  3. source.loop 设置为 true。这是因为环境声音会无限循环!

  4. 使用 source.Play() 播放声音,因为我们不能为循环声音使用 PlayOneShot()

好的,这些各种音频播放组件真的已经整合在一起了!在下一节中,我们将继续为每个组件提供示例实现,包括环境声音。

实现 PlayAmbientSound

首先,我们需要在我们的环境中识别一些内容,用作示例环境声音实现的例子。让我们看看 Polypix 工作室提供的模型以获得答案……我看到我们有一个名为 通风 1 的 3D 模型预制件资产被导入到 Assets/Polypix 3D Assets/Prefabs 文件夹中。

让我们在我们的场景中某个位置使用这个作为通风管道入口。这类事物通常会有一些东西在运行——制造噪音——这会拉动并循环空气。这是一个完美的环境音频添加!

艺术资源

本节中使用的艺术资源可以从 GitHub 项目文件仓库获取。特别是,通风 1 预制件可以从包含在 3DArtwork.zip 文件中的 Art-Assets 文件夹(或直接从 Unity 项目文件中)获取:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch11/Art-Assets

Assets/Polypix 3D Assets/Prefabs 文件夹中的 通风 1 预制件拖放到场景中的某个位置,以给居住站的一个房间增添一些氛围。在 图 12.10 中,我已经将它添加到一个看起来相当空旷的房间中,需要一些趣味性。

图 12.10 – 通风环境声音

图 12.10 – 通风环境声音

在场景中添加通风管道入口后,让我们添加环境 3D 声音音频播放器组件。

  1. 在预制件编辑模式下打开 Ventilation 1 预制件。

  2. 使用您首选的方法将 AudioPlayerAmbient 组件添加到根对象。

  3. 当我们添加音频播放器组件时,会自动添加 AudioSource 组件(由于 RequireComponent 属性)。

  4. 与之前提供的音频播放器组件不同,我们为 AudioSource 组件提供了字段,以根据其位置的环境音频效果调整声音。

在我们继续配置音频源之前,有一个可以播放的声音会很有帮助,这样我们就可以做出适当的调整。

我们将用于通风管道入口的声音文件是 metro-subway-hallway-corner-noise-heavy-ventilation-rumble.flac(不,我没有命名它!)它来自 Freesound (freesound.org/people/kyles/sounds/455811/),并授权 CC0(仍然可以免费使用)。

确保将metro-subway-hallway-corner-noise-heavy-ventilation-rumble.flac文件导入到Assets/Audio/Ambient文件夹。我们将为此声音文件调整一些导入设置值。

参考图 12.4 的导入设置,设置以下内容:

  • 强制单声道 = true,归一化 = true:忽略声音文件的 L/R 立体声通道(将它们合并)并将音频级别设置为归一化值。

  • 在后台加载 = true:加载较大的音频文件而不会导致主线程延迟。一旦文件加载(可能不在加载的场景开始时),声音就会开始播放。

  • 加载类型 = 流式传输:从磁盘以最小内存使用量解码音频,并使用单独的 CPU 线程。

  • 30:通过调整压缩滑块来平衡播放质量和文件大小,以压缩剪辑。为了分发而保持文件小,同时保持播放质量。

返回通风管道入口处的AudioPlayerAmbient组件,然后点击并拖动metro-subway-hallway-corner-noise-heavy-ventilation-rumble文件到Ventilation 1预制件(在预制件编辑模式中点击保存,或在检查器顶部,在预制件 | 覆盖下拉菜单中点击应用所有)。

在我们播放带有环境声音的关卡测试之前,让我们回顾一下影响环境声音在环境中听到的 3D 声音设置。

3D 环境声音设置

是时候戴上我们的声音设计师帽子了!当我们向通风管道入口预制件添加音频播放组件时,我提到我们将直接使用音频源属性来调整声音。

参考图 12.4 中的270来影响声音在 3D 空间中的分布。较低的值创建更多方向性声音(仅在源前方听到),而较高的值产生更多全向声音(可以从更宽的角度听到)。360的值会使声音似乎从听众周围的所有方向传来。

小贴士 | 打开属性窗口

检查器中,右键单击AudioSource组件标题,然后单击属性…将在浮动属性窗口中打开它。您可以在 Unity 编辑器的大多数窗口中为此执行对象、组件和文件资产。

播放测试关卡,根据需要调整音频源的扩散值,并注意环境声音的播放。

在本节中,我们学习了如何创建组件以播放不同的听觉体验,并将它们应用于我们环境中的对象。在下一节中,我们将通过为玩家添加脚步声来增强环境的沉浸感。

通过脚步声和混响区域增强音频体验

创建一个沉浸式和令人愉悦的游戏体验需要密切关注音效设计。即使是看似简单的元素,如脚步音效,也在将角色定位在环境中以及传达一种物理感和存在感方面发挥着重要作用。

尤其是在脚步声中,拥有多种脚步音效以随机化它们,并根据玩家的速度调整节奏或拍子,以防止听觉疲劳和重复(你甚至可以进一步到为玩家行走的每种表面类型提供不同的声音)。

优化连续随机播放的音效剪辑也是确保这个过程不会对游戏性能产生负面影响的关键。

通过我们的脚步音效实现,我们将解决所有这些因素。考虑到我们已经编写的音频播放器组件和管理器代码,实现起来比你想象的要简单。

重复使用音频播放器代码

没有什么比从我们现有的音频播放器组件开始播放脚步音效更简单了!所以,我们将依赖AudioPlayerSFX来处理脚步音效,而不是通过新的IPlaySound接口的直接实现来创建脚步音效。我的意思是,我们可以这样做,但那只会重复AudioPlayerSFX已经提供的功能。对于那些跟踪的人来说,这里的牌是代码重用、不要重复自己DRY)、保持简单,傻瓜KISS),我们还可以声称单责任牌。

这次,我们不会要求使用 Unity 游戏引擎提供的内置组件,而是要求将我们自己的AudioPlayerSFX组件添加到我们添加新播放脚步音效组件的 GameObject 中。

让我们通过在Assets/Scripts/Audio文件夹中创建一个新的AudioPlayerFootsteps脚本来查看这个实现,以下是其初始代码:

[RequireComponent(typeof(AudioPlayerSFX))]
public class AudioPlayerFootsteps : MonoBehaviour
{
    [SerializeField]
    private float _walkInterval = 0.5f;
    private AudioPlayerSFX _playerSFX;
    private float _timerStep;
    private void OnValidate()
        => _playerSFX = GetComponent<AudioPlayerSFX>();
    private void Start()
        => _timerStep = _walkInterval;
}

记住,RequireComponent属性通过组合组件来强制执行组合模式,以实现所需的功能。因此,正如之前提到的,我们已经要求了AudioPlayerSFX组件,并在OnValidate()方法中预先分配了_playerSFX变量的引用。

我们还声明了以下变量:

  • _walkInterval:这个变量的值应该与玩家的步伐或速度相匹配——换句话说,就是脚步音效播放之间的时间

  • _timerStep:这个变量将保存播放脚步音效的当前间隔(剧透:我们将根据玩家是行走还是冲刺来分配不同的值)

好的,我们已经有了基本的模板代码。现在让我们添加我们的脚步音效剪辑列表变量和一个更新循环,以便在指定的时间间隔内从列表中播放一个随机的脚步音效:

public class AudioPlayerFootsteps : MonoBehaviour
{
    …
    [SerializeField]
    private AudioClip[] _footstepSounds;
    …
    private void Update()
    {
        float currentStepInterval = _walkInterval;
        _timerStep -= Time.deltaTime;
        if (_timerStep <= 0)
        {
            _playerSFX.Play(_footstepSounds[
                Random.Range(0, _footstepSounds.Length)]);
            _timerStep = currentStepInterval;
        }
    }
}

在这里,我们正好做了需要做的事情:

  • _footstepSounds:在这里,我们有我们的脚步声文件资源AudioClip[]数组,这些声音将在设定的时间间隔内随机选择并播放。参考图 12**.11查看此检查器分配的预览。

  • Update():更新循环将保持我们的_timeStep变量与新的时间同步。然后,我们将评估_timeStep是否已过期以播放下一个随机选择的剪辑。最后,我们将重置_timeStep以在间隔中延迟下一次播放。

看起来不错!可选地,我们可以通过快速重构将获取随机脚步声的代码提取到本地函数中,如下所示:

    AudioClip GetRandomFootstepClip()
        => _footstepSounds[
            Random.Range(0, _footstepSounds.Count)];

播放 SFX 的代码行现在看起来是这样的:

    if (_timerStep <= 0)
    {
        _playerSFX.Play(GetRandomFootstepClip());

这又是一个快速的可选重构,它不会改变功能。然而,它使代码可读性更好——任何有助于代码可读性的东西都值得花一点额外的工作来提高清晰度(无论是别人查看你的代码还是六个月后的你自己),如果需要更多,可以添加代码注释!

音频播放组件的完整代码

不要忘记,在任何时候,如果你需要查看这些部分的完成代码,你可以在 GitHub 仓库中找到它:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch12/Unity-Project/Assets/Scripts/Audio

AudioPlayerFootsteps组件本身不播放声音。正如我们所知,它使用PlaySoundSFX.Play()并将音频剪辑作为参数传递。唯一的问题是Play()目前不接受参数!现在让我们通过添加方法重载来修复这个问题。

为 AudioPlayerSFX 添加方法重载

因此,现在,我们将重载AudioPlayerSFX中的Play()方法,为当前要播放的脚步声添加所需的AudioClip参数(好吧,那是我们随机选择来播放的)。

AudioPlayerSFX中添加以下方法和其代码:

    public void Play(AudioClip clip)
    {
        _audioClip = clip;
        AudioManager.Instance.PlayAudio(this);
    }

我们已经有了不带参数的Play()方法,所以现在我们通过声明另一个具有相同方法名但不同方法签名的Play()方法来重载Play()方法(因为我们已经添加了AudioClip参数)。现在,当调用Play()时,匹配方法签名的将是执行的具体方法——要么是带有传入的音频剪辑,要么不带。

提示

避免频繁更改剪辑:重要的是要记住,反复更改AudioSource剪辑可能不如使用多个音频源或AudioSource.PlayOneShot()方法高效。在这种情况下,建议使用PlayOneShot()方法,因为它允许你播放剪辑而不更改音频源的主要剪辑。

我们现在可以继续到下一节,为我们的玩家角色添加脚步声。

实现 AudioPlayerFootsteps

好的,这将是我们第五次实现音频播放组件,所以没有必要拖延。让我们直接添加到 PlayerCapsule。由于在下一节中我们将需要 PlayerCapsule 对象上的一些其他组件(例如 CharacterControllerPlayerInput),我们希望将 AudioPlayerFootsteps 直接添加到该对象的根目录。

现在我们已经添加了 AudioPlayerFootsteps,你应该已经看到了由于 RequireComponent 属性的添加,AudioPLayerSFX 也被添加了,这样就可以通过 SFX 音频混音器通道播放声音。剩下要做的就是将声音文件添加到 检查器 中的 _footstepSounds 数组。

我们这次使用的声音文件来自 Unity 资产商店。我们将使用 Matthew Anett 的 Classic Footstep SFX (Free) (assetstore.unity.com/packages/audio/sound-fx/classic-footstep-sfx-173668)。从其名称中,你可以看出我们可以在我们的项目中自由使用它。太好了!

确保将 Classic Footstep SFX (Free) 软件包导入到你的项目中。默认情况下,这将是在 Assets/Classic Footstep SFX 文件夹。与其他音频文件不同,由于这是一个软件包,导入设置已经由作者设置好了,所以我们已经准备好开始使用它们。

按照以下步骤将提供的脚步声文件分配给 AudioPlayerFootsteps 组件的 _footstepSounds 数组字段:

  1. 返回到 PlayerCapsule 上的 AudioPlayerFootsteps 组件,并锁定 检查器 窗口(使用 检查器 标签右上角的那个小 图标)。通过锁定窗口,无论我们选择什么,检查器 都将保持在当前窗口,这对于在 项目 窗口中选择多个对象以分配给组件字段是至关重要的。

  2. 项目 窗口中的 Assets/Classic Footstep SFX/Floor 文件夹。

  3. Floor_step0 内时,然后按住 Shift 键并点击最后一个声音文件,或者选择任何文件范围。

  4. 选择声音文件后,点击并拖动(从选择中的任何位置)到 脚步声 字段标签,你会看到鼠标光标从箭头变为带有框和加号的箭头。在悬停于字段名称上释放鼠标按钮,将填充数组中的所有选择的声音文件。

  5. 你现在可以解锁 检查器 窗口,通过展开数组(在 脚步声 字段标签左侧的箭头)来检查声音文件的分配,如图 图 12.11* 所示。请注意,为了简洁起见,我在图中只展示了五个分配的例子。

图 12.11 – AudioPlayerFootsteps 组件分配

图 12.11 – AudioPlayerFootsteps 组件分配

如果你尝试使用现在的 AudioPlayerFootsteps 代码,你将一直听到脚步声,因为没有条件语句告诉我们何时不应该播放脚步声!这样是不行的,所以让我们通过条件检查来修改 AudioPlayerFootsteps

首先,我们需要一个对玩家角色控制器的引用……因此,在 AudioPlayerFootsteps 中添加一个新的字段来保存这个引用(你将不得不手动在 Inspector 中设置这个字段的引用,所以别忘了——我知道我肯定会忘记):

    [SerializeField]
    private CharacterController _characterController;

我们现在可以通过添加以下 if 语句来评估“玩家是否在地面上或正在移动?”:

    private void Update()
    {
        if (!_characterController.isGrounded
            || _characterController.velocity.magnitude <= 0)
            return;
        …

使用 return,我们短路 Update() 方法,使其不会继续执行,因此不会播放任何脚步声。太棒了。

在这个阶段,测试游戏将只在行走时给我们脚步声。然而,我们也可以让玩家在向前移动时按住 Shift 键来冲刺。我们目前面临的问题是脚步声的间隔是一致的,导致冲刺听起来和行走一样。我们可以通过添加一个条件来快速解决这个问题,特别是为冲刺添加第二个间隔。

实现冲刺

好吧,这个设置听起来好像会很困难。其实并不难。这几乎只是不方便,因为我们将直接访问 Player Input 功能。

当我们提供输入——通过按键或使用游戏手柄——输入系统会通过 Player Input 组件处理这些键,该组件反过来会发送消息事件。所以,我们只需要添加一个方法处理程序(即监听器)来处理当玩家想要开始冲刺时发送的那个。在 *图 12**.12 中,那是 OnSprint,我们可以看到它在 Player Input 组件的 Behavior 下方的小框中列出。

图 12.12 – 向 GameObject 列表发送 Player Input SendMessage()

图 12.12 – 向 GameObject 列表发送 Player Input SendMessage()

添加对玩家冲刺的响应能力现在只是更新 AudioPlayerFootsteps 类所需的代码。添加以下内容:

using UnityEngine.InputSystem;
public class AudioPlayerFootsteps : MonoBehaviour
{
    …
    [SerializeField] private float _sprintInterval = 0.3f;
    private bool _isSprinting;
    …
    public void OnSprint(InputValue value)
        => _isSprinting = value.isPressed;
}

我们在 _sprintInterval 中添加一个新的变量来分配冲刺的时间间隔,并添加一个相关的布尔变量来评估玩家是否正在冲刺,即 _isSprinting。然后,OnSprint() 将根据从输入系统传入的 value.isPressed_isSprinting 设置为 truefalse

现在我们需要做的只是将适当的步进间隔分配给 _currentStepInterval 变量,该变量将与 _timerStep 变量一起用于在正确的时间间隔播放脚步声。通过修改 Update() 方法来实现这一点:

    private void Update()
    {
        if (!_characterController.isGrounded
            || _characterController.velocity.magnitude <= 0)
            return;
        float currentStepInterval =
            _isSprinting ? _sprintInterval : _walkInterval;
        …

这样就完成了脚步声的设置!现在进行测试应该会产生与玩家行走或冲刺时匹配的脚步声。这正是将您的游戏与其他游戏区分开来的细致入微之处——玩家会注意到并欣赏独立开发者在这类事情上的努力。

在您的关卡中,您可以通过快速添加混响区域来增加沉浸式声音细节,从而提高游戏声音景观的制作价值,下一节将展示添加混响区域的简单步骤。

添加混响区域

混响区域模拟不同空间中声音的声学特性,无论是大仓库中的回声还是小储藏室中的衰减,都能为声音景观增添深度,并在玩家在关卡的不同区域移动时增强游戏氛围。

此外,模拟声学特性可以帮助叙事并微妙地引导玩家的情绪和期望,这是游戏设计师可以利用的另一个工具,以构建更沉浸和逼真的游戏体验。

在您的关卡中找到一些关键区域,您觉得声学特性会受到空间规模的影响。使用图 12.13,我们将以下示例添加到关卡中较大的房间之一:

  1. 在场景中添加一个空的游戏对象并将其放置在大房间中央。

  2. 将游戏对象重命名为混响区域(可选,将其作为父对象以组织层次结构中的所有区域)。

  3. 将一个AudioReverbZone组件添加到混响``区域对象中。

额外的阅读 | Unity 文档

您可以在此处了解更多关于混响区域的信息:docs.unity3d.com/2022.3/Documentation/Manual/class-AudioReverbZone.xhtml

  1. 选择一个合适的混响预设选项。或者,选择用户并自定义下方的属性滑块以达到您想要的效果。在下面的图中,你可以看到我选择了机库预设。

图 12.13 – 混响区域放置和设置

图 12.13 – 混响区域放置和设置

这就是添加混响区域到我们关卡所需的所有内容——进行测试并调整区域属性以符合您的喜好。然而,我们希望确保音乐播放不受混响区域的影响(音乐受到环境变化的影响似乎并不合理,对吧?)。

我们可以在代码中非常容易地做到这一点——我们必须这样做,因为我们场景中没有驻留的AudioSource位于游戏对象上以播放音乐;我们是通过代码添加的。

因此,在我们的AudioManager脚本中,在PlayMusic()方法中,只需将此行添加到我们分配给音频源音乐设置的列表中即可:

_audioSourceMusic.bypassReverbZones = true;

就这样!我们的音乐播放将不再受混响区域的影响,而剩余的播放声音,尤其是脚步声,将会受到影响。

这就带我们来到了为游戏添加音频的结束。在本节中,我们学习了如何创建不同的音频播放组件,这些组件通过音频管理类路由,以建立如何使用此系统播放声音的规则。

现在我们有一个音频工具集,可以处理游戏所需的多种类型的音频播放用例。你现在可以回顾书中早些时候的项目,添加你自己的声音设计,并提升玩家的游戏体验。戴着你的声音设计师帽子,享受乐趣吧!

接下来,我们将快速查看我们如何重构我们的音频播放组件,以更好地遵循 SOLID 原则。

更深入的 SOLID 重构

我们可以通过使用所有音频播放类都从中派生的抽象基类来进一步扩展 SOLID 原则中的 OCP(开闭原则)。这可以添加更多必需的实现和默认行为,然后将成为一个经典的面向对象编程OOP)继承示例。

OOP SOLID 原则提醒

在 OOP 中,派生类继承所有基类成员,也可以添加自己的成员。然而,在使用派生类时,必须牢记 SOLID 原则中的Liskov 替换原则。L 原则指出,基类的对象应该可以用派生类的对象替换,而不会改变程序的正确性。用简单的话说,任何使用基类引用的程序都应该能够使用任何派生类,而无需知道它。对于 OOP,这是多态,它允许我们编写更通用的代码,该代码可以与任何音频播放类一起工作。

让我们看看我们如何编写一个抽象基类,我们的项目中的音频播放类可以从中继承:

using AudioType = AudioManager.AudioType;
public abstract class AudioPlayerBase : MonoBehaviour, IPlaySound
{
    [SerializeField] protected AudioClip _audioClip;
    public abstract AudioType PlayAudioType { get; }
    public virtual void PlaySound(AudioSource source)
        => source.PlayOneShot(_audioClip);
    public virtual void Play()
        => AudioManager.Instance.PlayAudio(this);
}

这里是我们所做的事情:

  • _audioClip: 该字段被标记为受保护的,这意味着继承类可以访问它。这使得事情变得相当简单,对吧?

  • abstract PlayAudioType(属性):这个抽象属性确保每个派生类都将定义自己的AudioType值。这是一个很好的使用抽象属性的方式,因为它强制每个音频播放类指定自己的AudioType值。

  • PlaySound(): 该方法为播放声音提供了一个默认实现,所有继承类都可以共享。因为我们包括了方法签名中的virtual关键字,如果派生类需要不同的行为,它可以覆盖此方法。我们还必须定义PlaySound()PlayAudioType,因为我们必须实现IPlaySound接口!

  • Play(): 该方法提供了一种播放指定AudioClip的方式。在这里,我们将播放功能委托给AudioManager实例,遵循我们的模式,并根据AudioType值分配相应的AudioMixerGroup组。注意它也是virtual的,因此如果需要不同的行为,继承类可以覆盖它。

AudioPlayerBase抽象基类为创建特定音频播放类提供了一个良好的基础——需要多少种就创建多少种。我们为我们的类强制执行了一致的接口,以提供行为默认实现,同时允许继承类进行定制。太棒了!

现在你已经看到了我们如何处理所有音频播放类都可以从中派生的抽象基类,那么就自己动手尝试重构吧!GitHub 链接上的项目代码已经提供了一个重构后的AudioPlayerSFX3D_Derived组件示例,该组件继承自AudioPlayerBase抽象基类,你可以将其作为参考(诚实地尝试,不要偷看)。

AudioPlayerBase 完整代码

要查看AudioPlayerBase类的完整代码和一个继承类示例,请访问以下 GitHub 仓库:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch12/Unity-Project/Assets/Scripts/Audio

在本节中,我们学习了如何重构我们的代码以更符合 SOLID 编程原则。虽然我们的代码已经相当完整了(我相信总有人会想到更多类型的音频播放器组件来扩展),但这次重构使得在无需修改管理器类的情况下扩展代码以添加额外的音频播放器组件类型变得更加容易。

摘要

在这个“音频章节”中,我们详细介绍了通过引入音频管理器和可重用的音频播放器组件来为我们的游戏添加音频。我们使用音频管理器作为对设计师和开发者都适用的错误预防实现,以便为目标游戏声音场景设置播放级别选择合适的混音组。

我们继续编写单个音频组件,用于播放大多数游戏中常见的不同类型音频——音乐、音效(SFX)和环境声音。我们遵循 SOLID 编程原则创建了这些可重用的音频播放器组件,这样我们就可以在不修改管理器类的情况下扩展额外的音频组件类型。我们以脚步声示例结束,展示了如何为简单实现组合组件,以及如何快速添加带有混响区域的环保音频效果。

在下一章中,我们将继续完善游戏,通过添加一个智能非玩家角色(NPC)来实现。我们将通过再次重构我们之前的 2D 代码以适应 3D 使用,以及引入利用传感器、行为树和机器学习(AI)技术的尖端技术来动态生成敌人。

第五部分:增强和完成游戏

游戏开发者可以通过使用基于高级 AI 的交互,例如NavMesh传感器(通过射线投射和音频实现,视觉和听觉)以及使用行为树实现的,以及 Unity 的机器学习解决方案ML-Agents,来获得令人瞩目的游戏体验。通过重构 2D 游戏的代码,你将了解在 2D 和 3D 中实现交互之间的区别。

你将学习如何轻松地将 FPS 游戏转换为使用XR 交互工具包的沉浸式混合现实MR)体验。在很短的时间内,你就可以为玩家创造在虚拟和真实世界环境混合中的存在感,因为他们了解如何生成和交互对象以促进游戏机制。你将带走如何保持流畅游戏体验,同时针对所需设备使用简单但沉浸式的游戏的理解。在战斗并击败 Boss 房间遭遇战后赢得游戏,将使所有系统协同工作,并为你提供一个工作游戏结构的全面理解。

Unity 游戏服务对于希望在其作为游戏即服务GaaS)运营的同时从发布游戏中获得收入的商业游戏开发者至关重要。Unity LiveOps 服务通过分析数据来响应玩家需求,并使用远程配置提供动态的玩家内容,同时功能丰富的版本控制系统Unity 版本控制构建自动化简化了 DevOps 流程。此外,你还将学习如何使用领先的游戏分发商店在全球范围内分发你的游戏。

本部分包括以下章节:

  • 第十三章使用传感器、行为树和 ML-Agents 实现 AI

  • 第十四章使用 XR 交互工具包进入混合现实

  • 第十五章以商业可行性完成游戏

第十三章:使用传感器、行为树和 ML-Agents 实现 AI

第十二章中,我们深入探讨了添加音频到游戏所需的所有细节。我们通过引入音频管理器和可重用的音频播放器组件来实现这一点,以便设计师和开发者可以轻松地添加不同类型的游戏音频,从而为玩家创造一个包含所有玩家的声音体验。我们强调了良好的编码实践,以确保我们编写的代码易于维护,并注重可重用性和可扩展性,以简化我们日常游戏开发者生活中的挑战。

现在我们已经解决了游戏的声音设计问题,我们可以继续完成 FPS 游戏关卡中敌人的非玩家角色(NPC)机制,通过实现一些基本的人工智能(AI)。我们将通过重用和重构先前的 2D 组件和代码到 3D 来实现简单的 AI 导航。我们将继续讨论 NPC 系统的提升和复杂化,使用行为树机器学习(ML)工具。

在本章中,我们将涵盖以下主要主题。

  • 使用 NavMesh 重构 2D 敌人系统到 3D

  • 带有传感器和行为树的动态敌人

  • 通过 ML-Agents 引入机器学习

到本章结束时,您将通过实现基于 AI 与 NPC 的交互、无缝导航关卡环境以及执行一系列自主行为,为您的玩家创造一个令人难忘的游戏体验。您将更好地理解 2D 和 3D 实现之间的差异,因为我们将在改进过程中重新审视 2D 方法并将其重构为 3D。

技术要求

您可以从 GitHub 下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

使用 NavMesh 重构 2D 敌人系统到 3D

本章的目标是指导您完成将这个悬浮对手激活的综合过程,它被邪恶的植物实体入侵我们的系统而对我们产生敌意。它的任务是巡逻栖息地站的走廊,以防止玩家根除感染并恢复 Kryk’zylx 的正常状态(无论那是什么)。

图 13.1 – 巡逻的敌人悬浮机器人

图 13.1 – 巡逻的敌人悬浮机器人

第八章中,为了我们的 2D 游戏,我们通过使用简单的巡逻航点行为来解决敌人 NPC 导航问题,其中敌人机器人在 2D 空间中的两个航点之间移动——一个向左和一个向右。

好吧,我们在这里要做的是类似的事情。然而,因为我们现在在 3D 空间中工作,并且有一个更复杂的楼层平面需要导航,我们仍然会使用航点设置巡逻路径。不过,我们现在将使用 Unity 的AI 导航包及其NavMesh组件来完成在它们之间导航的任务。NPC 导航对于创建沉浸式游戏环境至关重要,Unity 在 2022.3 版本中引入的更新 3D NavMesh 系统提供了一个高效的解决方案。

额外阅读 | Unity 文档

关于 AI 导航包的信息可以在这里找到:docs.unity3d.com/Packages/com.unity.ai.navigation%401.1/manual/

修订和重构先前编写的组件以节省开发时间是我们自从 3D FPS 项目开始以来已经讨论过几次的事情,这次也不会例外。每次从头开始都没有意义;让我们依靠我们工具箱中已有的资产,并在需要的地方进行修订(最终,这会丰富我们的工具箱)。

我们需要复习一下基本组件结构,以便理解在 3D 相关修订中,特别是对于 NavMesh 重构,需要在哪里进行。所以,让我们开始吧。让我们回顾以下代表 2D 项目中 NPC 移动行为的统一建模语言UML)图:

图 13.2 – 2D 巡逻航点参考 UML

图 13.2 – 2D 巡逻航点参考 UML

趣味的是,我们将重用前面图中大约 96%的内容(不,我没有做数学计算;不要对我这个百分比太苛刻)。主要需要修订的是PatrolWaypoints类,因为它实现了IBehaviorPatrolWaypoints接口,实际上是在航点之间移动敌人 NPC。我们只需要为 NavMesh 提供一个行为实现即可——因为我们事先按照良好的编程实践进行了工作,以扩展功能而不修改实现类。

首先,让我们收集 2D 脚本以供重用。

从 2D 项目中导入脚本

我们目前需要从之前的 2D 冒险游戏项目中获取脚本。这一次我们需要几个文件,而不同于我们在第十章的“将环境交互重构为 3D API 方法”部分中重用和重构 2D 组件用于 3D 的情况,那时我们只需要几个文件。因此,对于 3D FPS 项目所需的脚本,让我们使用 Unity 包导出器来获取脚本及其依赖项。

下载脚本

或者,从本书的 GitHub 仓库github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch13/Script-Assets下载patrol-behavior-and-dependencies.zip,其中包含已导出的脚本和所需的依赖项。

为了回顾这个过程,您可以重新访问第九章,其中我们导出了滑动拼图游戏资源。这个过程当然不是开发游戏中最有趣的部分之一,但您会发现您时不时地会做这些类型的事情。让我们开始吧。

现在在 Unity 中打开之前的 2D 冒险游戏项目,并按照以下步骤操作:

  1. 项目窗口中转到Assets/Scripts/Behaviors文件夹。

  2. 右键单击PatrolWaypoints.cs文件,并选择导出包…,如图 13**.3 (A)所示。

2D 巡逻路径行为是我们希望在 3D FPS 游戏中重用敌对 NPC 的基础,因此我们将以此为基础进行导出,并在过程中获取其依赖项和其他相关脚本。不用担心——在下一步中,我已经整理好了我们需要的脚本,以节省您自己找出这些脚本的时间和压力。

  1. 确保在Assets/Scripts/目录下已选择以下文件:

    • Bullet.cs

    • Enemy.cs

    • EnemyController.cs

    • PlayerShootingPooled.cs

    • ProjectileBase.cs

    • ProjectileDamage.cs

    • WeaponRanged.cs

  2. Assets/Scripts/Behaviors/:

    • PatrolWaypoints.cs
  3. Assets/Scripts/Data/:

    • EnemyConfigData.cs
  4. Assets/Scripts/ExtensionMethods/:

    • LayerMaskExtensions.cs
  5. Assets/Scripts/Interfaces/:

    • IBehaviorAttack.cs

    • IBehaviorPatrolWaypoints.cs

  6. IWeapon.cs:

    • IWeaponLaser.cs
  7. Assets/Scripts/Systems/:

    • EventSystem Example/:

    • ExampleListener.cs

    • ExampleTrigger.cs

    • EventSystem.cs

您可以从选定的依赖项列表中看到,我们将使用其中一些来快速实现弹射射击和事件。

  1. 一旦选择了文件,点击导出…按钮,并将导出的包文件(您可以选择任何名称)保存到我们可以轻松找到的位置,以便导入到当前的 3D FPS 项目中。

  2. 现在,如果尚未打开,请打开 3D FPS 项目,以便我们可以导入文件。

图 13**.3 (B)所示,当我们从系统文件管理器中将导出的包拖放到 3D FPS 游戏的项目窗口时,会弹出导入 Unity 包对话框。

  1. 点击导入

图 13.3 – 2D 项目导出和 3D 项目导入

图 13.3 – 2D 项目导出和 3D 项目导入

导入完成后,您可能会立即注意到控制台错误:

Assets\Scripts\ProjectileDamage.cs(4,48): error CS0535: 'ProjectileDamage' does not implement interface member 'IDamage.DoDamage(Collider, bool)'

我们首先——快速且容易地——修复这个编译器错误,而其余的更改将集中在 2D 到 3D 功能的重构上。所以,为了修复错误,我们只需要将Collider2DProjectileDamage类的DoDamage()方法签名中更改为Collider,以正确实现在此 3D 项目中定义的IDamage接口。

在你的 IDE 中打开Assets/Scripts/ProjectileDamage.cs,并将UnityEvent声明和DoDamage()方法签名更改为以下内容:

public UnityEvent<Collider, bool> OnDamageEvent;
public void DoDamage(Collider collision, bool isAffected)
    => OnDamageEvent?.Invoke(collision, isAffected);

简单。

继续进行剩余的重构需要额外一步来确保我们可以引用所需的 3D 导航类型。这仅仅意味着我们必须首先将AI 导航包添加到我们的项目中。

在搜索框中输入nav以过滤可用的包,并从列表视图中选择AI 导航。完成时,点击安装按钮。

图 13.4 – 安装 AI 导航包

图 13.4 – 安装 AI 导航包

好的,现在我们可以继续更新我们的代码重用,以使用 NavMesh 组件。

为 NavMesh 重构 PatrolWaypoints 类

Unity 的 NavMesh 系统极大地简化了设置复杂的 3D 航点导航,其中 NPC 可以更自然地移动并以可信的方式进行交互,这有助于增强玩家对游戏的沉浸感。我们坚持的模块化、可适应的代码设计(即 SOLID 原则和组合)也使得在 2D 和 3D 之间切换以及添加或交换组件变得容易。这就是为什么我们能够快速地对现有的 2D 组件进行少量更新,从而使 3D 第一人称射击游戏能够运行起来,并配备巡逻的敌方机器人 NPC!

主要的,如之前在 UML 图中所示,最重要的修改将是巡逻航点行为类。实际上,我们将替换掉大部分内容,所以现在在你的 IDE 中打开新导入的Assets/Scripts/Behaviors/PatrolWaypoints脚本,这样我们就可以开始了。我们首先开始的工作——从脚本顶部开始,向下工作——是航点变量。

更新航点变量

我们将简单地用以下单个航点变换声明替换旧的WaypointPatrolLeftWaypointPatrolRight变量声明——使用[SerializeField]属性序列化,以便在检查器中可分配:

[Header("Patrol Waypoints")]
[SerializeField] private List<Transform> _waypoints;

好的,我们已经有一个良好的开始;现在我们将能够为敌方机器人 NPC 导航添加任意数量的航点到_waypoints列表中。接下来,我们将处理剩余的变量。

我们将用以下两个私有成员变量替换与移动相关的私有成员变量 – _waypointCurrentIndex_navMeshAgent – 以保持对 NavMeshAgent 的引用,并跟踪代理将从我们添加到前面 _waypoints 列表中的变换列表中前往的当前航点。现在移除这两个 2D 私有成员变量,并用以下内容替换:

private int _waypointCurrentIndex = 0;
private NavMeshAgent _navMeshAgent;

由于 _navMeshAgent 变量将持有用于移动我们的敌人 NPC 的 NavMeshAgent 的引用(其访问器是私有的且未序列化),我们必须确保它通过使用 Unity 的 Awake() 生命周期消息事件来分配。现在通过向 PatrolWaypoints 类添加以下方法来完成此操作:

private void Awake()
{
    _navMeshAgent = GetComponent<NavMeshAgent>();
    Debug.Assert(_navMeshAgent != null,
        $"[{nameof(PatrolWaypoints)} NavMesh agent is null!]",
            gameObject);
}

当我们在 配置敌人 NavMesh Agent (Prefab) 部分设置其 Prefab 时,我们将添加 NavMeshAgent 组件到我们的敌人巡逻机器人。所以,如果我们忘记添加组件,GetComponent() 将返回一个 null 分配给 _navMeshAgent 变量,这绝对不行!为了让我们意识到这种潜在的疏忽,我们将使用 Debug.Assert() 语句——如果条件不满足,错误信息将被记录到 Unity 控制台。

额外阅读 | Unity 文档

关于 Debug.Assert 的信息可以在以下位置找到:docs.unity3d.com/2022.3/Documentation/ScriptReference/Debug.Assert.xhtml

这样就处理好了变量!现在我们可以继续到我们的第一个更新方法,这个方法涉及到为新巡逻行为设置好一切。

初始化巡逻行为

现在,让我们看看 Init() 方法。首先,我们将更改方法签名,特别是参数,因为我们不再需要刚体值或方向,因为 NavMeshAgent 将现在负责这些。我们只是保留 accelerationspeed 参数,因为它们仍然适用。

Init() 方法签名修改为以下内容:

public void Init(float acceleration, float speed) { }

对于 Init() 方法的具体内容,我们将移除所有之前的移动代码,并用以下内容替换——用于处理 NavMeshAgent

public void Init(float acceleration, float speedMax)
{
    _navMeshAgent.acceleration = acceleration;
    _navMeshAgent.speed = speedMax;
    if (_waypoints.Count > 0)
    {
        _navMeshAgent.SetDestination(_waypoints[0].position);
    }
    _waypointCurrentIndex = 0;
}

代码的快速概述如下:

  • _navMeshAgent.acceleration: 我们将敌人配置数据中传入的加速度赋值,以确定代理达到最大速度的快慢。

  • _navMeshAgent.speed: 我们将敌人配置数据中传入的速度赋值给设置代理在其路径上移动的最大速度。

  • _waypoints.Count > 0: 在尝试获取一个航点的位置之前,进行一个快速的保险检查,以确保我们确实有一个航点列表可以工作。

  • _navMeshAgent.SetDestination: 当行为初始化时,NavMesh Agent 将前往哪个航点?就是这一个。索引值 0 指的是添加到 _waypoints 列表中的第一个航点。

  • _waypointCurrentIndex:再次指定 _waypoints 列表中的第一个航点,通过其索引值 0 作为当前航点,代理将前往。

为了快速总结我们在这里所做的工作,就像在 2D 代码中一样,Init() 方法用于初始化 NavMesh Agent 的运动参数。它专门设计用于启动航点之间的导航,作为实现敌对 NPC 巡逻行为的一部分。

NavMeshAgent 参数可以是游戏平衡的重要部分。它们允许开发者调整 NPC 行为和游戏难度以匹配设计——创建挑战区域或引导游戏体验。提示:在游戏关卡测试过程中,你将调整这些值。

在完成行为初始化后,我们可以进一步向下移动到类中,以修订所需的方法。

修订更新方法

UpdateDirection() 方法在先前的 2D 实现中并非技术层面上负责移动对象(这个责任由 UpdateVelocity() 方法承担)。尽管如此,为了保持逻辑命名的统一性,我决定在这里重用它。

之前的 UpdateDirection() 代码仅负责将巡逻精灵对象朝向移动方向(翻转)——由于 2D 游戏是侧视图,我们实际上没有旋转。好吧,在一个 3D 视角游戏中,我们当然有要处理的物体旋转。幸运的是,NavMeshAgent 组件将在路径查找过程中自动计算并应用物体旋转。

小贴士

要对代理的旋转有更多控制,你必须手动管理它。记住,如果你选择这样做,你需要通过设置 _navMeshAgent.updateRotation = false 来禁用 NavMesh Agent 的自动旋转。

让我们看看新的 UpdateDirection() 方法现在是什么样子。用以下代码替换原有代码:

private void UpdateDirection()
{
    if (!_navMeshAgent.pathPending
        && _navMeshAgent.remainingDistance
            <= _navMeshAgent.stoppingDistance)
    {
        MoveToNextWaypoint();
    }
}

正如我们所见,对于 UpdateDirection() 方法,我们现在直接处理 NavMeshAgent。我们正在使用代理当前航向的 pathPendingremainingDistance 属性来评估进度值。确切地说,我们将执行对 MoveToNextWaypoint() 的调用,当代理的当前剩余距离小于停止距离时,设置移动到下一个航点。

重要提示

在这个示例中,我们依赖于 NavMeshAgent 组件的默认值,包括其停止距离、障碍物避让和路径查找值。

正如你所见,我们添加了一个新方法来处理从可用航点列表中设置下一个航点,当代理到达当前指定的航点时。如果你直接跟随代码,你可以自动使用你的 IDE 的重构工具来生成新的 MoveToNextWaypoint() 方法。无论如何,在创建新方法后,向其中添加以下代码:

private void MoveToNextWaypoint()
{
    if (_waypoints == null
        || _waypoints.Count == 0)
            return;
    _waypointCurrentIndex =
        (_waypointCurrentIndex + 1) % _waypoints.Count;
    _navMeshAgent.SetDestination
        (_waypoints[_waypointCurrentIndex].position);
}

这里没有发生什么太复杂的事情;它只是一个简单的空值检查和遍历航点。即便如此,这些行的简单分解如下:

  • if (_waypoints == null || _waypoints.Count == 0): 在这里对列表中可用的航点进行双重检查,我们确保在继续执行为代理分配下一个要前往的航点的语句之前,它不是null,并且列表中航点的数量不是0(零)。

  • _(_waypointCurrentIndex + 1) % _waypoints.Count: 模数运算符(或余数运算符)确保当_waypointsCurrentIndex值等于列表计数时,我们回绕到列表开头的索引。

  • _navMeshAgent.SetDestination: 这是NavMeshAgent方法,我们调用它来告诉代理开始向传入的位置移动——即前一行分配的索引。

模数或余数运算符 % (C#)

使用模数运算符的典型简写方式是确保通过确保当前项目索引线性地遍历每个列表项,并在达到列表末尾时仅回绕到0,以保证列表项的无缝无限循环。

更多阅读资料请见此处:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/arithmetic-operators

在本节中,我们已经整理了更新方法以与新的 NavMesh 系统一起工作。接下来,我们仍然会使用方法,但不是修订,而是移除不再需要的那些。

清理未使用的方法

仍然在PatrolWaypoints类中工作,我们有一些不再需要的方法。而不是仅仅将它们留在类中,让我们通过移除它们来清理它们——这样,如果我们以后需要重新访问该类,就不会被零引用的方法所困惑。

现在从PatrolWaypoints类中删除以下方法:

  • UpdateVelocity(): 速度现在由NavMeshAgent内部计算。

  • SetWaypoints(Transform left, Transform right): 在这个例子中,我们不会生成巡逻的敌人 NPC NavMesh 代理。请将此视为将来向您的游戏中添加巡逻敌人生成器的机会!

  • 哦,并且通过移除现在已无效的UpdateVelocity()方法的调用,快速修复“更新TickPhysics()”:

    public void TickPhysics() => UpdateDirection();
    

已删除的方法;检查!要比较您的修订与完成的PatrolWaypoints类,您可以在以下位置找到它:github.com/PacktPublishing/Unity-2022-by-Example/blob/main/ch13/Unity-Project/Assets/Scripts/Behaviors/PatrolWaypoints.cs

更新了行为接口?下一个重构项目检查!

更新行为接口

这将是一个简短的部分(非常简短)。我们需要对IBehaviorPatrolWaypoints接口进行的唯一更改——你可能已经注意到了从PatrolWaypoints类保存前一个更改时出现的错误——与实现类的更改直接相关。

IBehaviorPatrolWaypoints代码更新为以下内容以解决问题:

public interface IBehaviorPatrolWaypoints
{
    void Init(float acceleration, float speedMax);
    void TickPhysics();
}

最后,除了我们刚刚解决的错误之外,我们还有一些额外的错误需要清理,才能完全完成这个过程。

解决剩余的控制台错误

对于 2D 代码重构的最后一点家务事,当我们更改PatrolWaypoints类中PatrolWaypoints.Init()方法签名时,我们在实现类中引起了错误。因此,我们将简单地删除不必要的参数变量以修复它。

打开EnemyController.cs脚本并更新以下if块主体的部分:

private void Awake()
{
    …
    // Get behaviors and initialize.
    if (TryGetComponent<IBehaviorPatrolWaypoints>(
        out _behaviorPatrol))
    {
        _behaviorPatrol.Init(
            _config.Acceleration,
            _config.SpeedMax);
    }
    …

对于_behaviorPatrol.Init()调用,我们现在必须传递的唯一config变量是加速度和速度值。简单易懂。

完成的 2D 到 3D 重构代码

虽然变化并不多,但当我们逐个检查每个更改并触及多个脚本时,可能会给人一种很多变化的感觉。如果你对代码的最终状态感到有些模糊,可以参考 GitHub 仓库中的完成脚本:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch13/Unity-Project/Assets/Scripts

此外,由于我们不再使用刚体在航点之间移动敌人 NPC,我们可以从EnemyController类中删除所有对Rigidbody2D的引用,包括删除以下行:

// In class EnemyController, delete:
  private Rigidbody2D _rb;
// In Awake(), delete:
  _rb = GetComponent<Rigidbody2D>();
// In FixedUpdate(), delete:
  else
      _rb.velocity = Vector2.zero;

呼,我们完成了!在这个部分中,我们甚至没有在完成所有 2D 到 3D 重构时出汗,对吧?我们几乎可以测试游戏的新 NavMesh 设置,用于 3D FPS 敌人的巡逻。剩下两个步骤,包括配置巡逻敌人 Prefab,这对我们来说也不是什么难事。

配置敌人 NavMesh 代理(Prefab)

在 Unity 编辑器中设置用于航点巡逻的 NavMesh 代理需要技术和视觉设置,以及仔细考虑机器人的行为以获得最佳的玩家体验——这些都是我们以前做过的。但是,由于这个过程涉及多个步骤,让我们首先将其分解为高级任务:

  1. 设计敌人机器人:我们将选择用于 NavMesh 代理的 3D 模型并创建 Prefab 资产。

  2. 添加敌人基地类型和控制器组件:我们将添加所需组件以赋予敌人对象属性和状态。

  3. 配置巡逻行为:我们将添加实现巡逻航点的行为组件。

  4. 调整环境中的 NavMesh 代理:我们将设置敌人机器人的 NavMesh 代理类型和属性。

  5. 测试和调整设置:我们将进行游戏测试并调整所需的游戏值。

还不错,只有五步!现在,让我们开始,先完成第一步。

设计敌方机器人

这将非常简单,因为我们将利用更多 Polypix Studios 提供的 3D 模型,这些模型可用于我们的游戏。我们有一个可爱的悬浮摄像头无人机(装备了一些类型的武器)可用,我们之前将其添加到 3D FPS 项目中,在第十一章,这将非常适合巡逻敌人!

图 13.5 – 巡逻悬浮机器人报到!

图 13.5 – 巡逻悬浮机器人报到!

我们将遵循以下步骤来构建敌方巡逻机器人。在完成以下步骤时,请使用图 13**.6作为对象层次结构参考:

  1. 在层次结构中创建一个空的游戏对象,重置其变换,并将其重命名为Enemy Hover Bot A

  2. Assets/Polypix 3D Assets/Prefabs文件夹中,找到SM_Camera_Drone Variant预制件,并将其设置为新的Enemy Hover Bot A对象的父对象 – 如图 13**.5所示。

记住,始终建议将图形作为根对象的子对象,以便在需要时轻松进行更改。

  1. 将以下变换值设置为(0, 0, 0)

  2. (1.5, 1.5, 1.5)

  3. 然后,对于子SM_Camera_Drone对象,将其 Y 位置值设置为0.6,使其离地面有一定高度 – 这是一个悬浮巡逻机器人!我们更改子网格对象,因为我们希望父对象锚点保持其当前位置。

  4. 回到Enemy Hover Bot A根对象,向其中添加以下组件:

    • Enemy

    • EnemyController

    • PatrolWaypoints

    • NavMeshAgent

额外阅读 | Unity 文档

更多关于 NavMesh Agent 的信息可以在这里找到:docs.unity3d.com/Packages/com.unity.ai.navigation%401.1/manual/NavMeshAgent.xhtml

  1. 现在,通过将Enemy Hover Bot A从层次结构拖动到Assets/Prefabs文件夹来创建一个预制件。

这些设置步骤的结果可以在图 13**.6中看到。注意,在将航点分配给PatrolWaypoints组件时,我们将在添加航点到关卡和测试部分中添加航点。

图 13.6 – 敌方悬浮机器人设置

图 13.6 – 敌方悬浮机器人设置

这只是第一步。设置的第二步需要我们分配给EnemyController组件的Config字段的敌方配置数据,如图 13**.6中的(A*)所示。

配置敌方机器人属性

如果你还记得,在第七章中,我们使用一个ScriptableObject资产来保存不同的配置以拥有不同的属性值(例如,更改敌人的难度)。

按照这些步骤,我们将创建一个新的敌方配置资产以立即分配:

  1. 项目 窗口中创建一个新的 Assets/Data 文件夹。

  2. 在新文件夹内,使用 创建 菜单并选择 ScriptableObjects | EnemyConfigData

  3. 将新创建的文件资产重命名为 Enemy Bot A Config

  4. 设置以下起始值:

    • 10

    • 4

    • 0(正确,我们不会让巡逻机器人闲置)

    • Infinity(是的,infinity 是一个有效的浮点值!)

  5. 最后,将 Enemy Bot A Config 资产分配给 Enemy Bot A 预制件的 Enemy 组件的 Config 字段并保存。

奖励活动

将一个阴影投射器添加到敌方机器人上,并将光照探针添加到机器人巡逻的关卡区域的场景中。这将保持游戏的高质量视觉保真度。你可以参考 第十一章 作为如何实现这些功能的提醒。

这样就完成了创建 NavMesh 代理敌方巡逻机器人预制件的所有工作!只剩下两个任务需要完成,才能让机器人开始巡逻:定义导航表面和设置航点。

烘焙 NavMesh 表面

现在,我们的 NavMesh 代理敌方机器人预制件已经准备好并配备了所有必要的组件,下一步是仅烘焙所需的巡逻区域。我们通过 NavMesh Surface 组件来完成这个任务。通过在游戏关卡中选择性定义巡逻区域,我们可以影响关卡设计,以实现更具有战略性的游戏玩法。

附加阅读 | Unity 文档

更多关于 NavMesh Surface 的信息可以在这里找到:docs.unity3d.com/Packages/com.unity.ai.navigation%401.1/manual/NavMeshSurface.xhtml

图 13**.7 为参考,你可以看到我决定让敌方悬浮机器人巡逻主要走廊,这些走廊由蓝色表面指示,连接了栖息站的两个主要区域。我们使用层来实现选择性烘焙 NavMesh 表面。相比之下,我们之前在物理环境中使用层来识别和限制交互,所以,层在 Unity 中的用途不同。

图 13.7 – 烘焙后的 NavMesh 表面和添加了航点

图 13.7 – 烘焙后的 NavMesh 表面和添加了航点

我们首先需要添加一个新层来选择特定的表面进行 NavMesh 烘焙。使用第一个可用的编号 用户 字段中的 Floor

接下来,通过在检查器的 NavMeshSurface 组件中选择它,选择 Floor 层中的连续走廊地板部分。

配置 NavMesh Surface

在将地板部分分配到指定的层后,我们就可以添加 NavMeshSurface 组件并烘焙表面了——这就像嵌入无形路径,这些路径决定了代理的运动。这个烘焙过程将赋予我们设置的代理以速度和准确性导航简单和复杂路径的能力,确保我们的巡逻敌方悬浮机器人实现无缝的 AI 导航!

要现在烘焙 NavMesh 楼层表面,请按照以下步骤操作:

  1. NavMesh Surface中添加一个新的空游戏对象。

您可以将它放在组织对象“---- ENVIRONMENT ----”下的任意位置。

小贴士

场景层次结构中组织对象上的仅编辑器标签,以便这些对象不包含在构建中,节省一些资源。

  1. NavMeshSurface组件添加到NavMesh Surface游戏对象中:

    • 为了确保只有我们希望敌方机器人巡逻的楼层部分被烘焙,在对象收集 | 包含图层下拉菜单中,首先选择,然后选择楼层

    • 点击烘焙按钮。现在选定的楼层部分应该显示一个蓝色网格,代表烘焙的导航表面,如图 13.7 所示。

那就是设置我们的 NavMesh 代理导航表面的全部内容了。确保在每次更改并重新烘焙时都重新访问NavMeshSurface组件。在添加航点并测试之前,我们将通过代理类型进行最后的调整。

配置代理类型

在烘焙 NavMesh 表面时,我们还将考虑的一个额外因素是调整代理的大小,以确保其能力与我们的需求相匹配。我随意决定我们不希望机器人在从航点找到航点的过程中太靠近走廊的侧面。

在参考图 13**.8的同时,按照以下步骤创建一个新的导航代理类型:

  1. 通过访问窗口 | AI | 导航来打开导航窗口。

  2. 点击代理类型列表右下角的加号(+)图标以添加新类型。

  3. Bot

  4. 1以给机器人更多的走廊墙壁边缘的余地。

  5. 0.75

图 13.8 – 指定机器人代理类型

图 13.8 – 指定机器人代理类型

小贴士

您还可以通过在代理类型下拉菜单中选择打开代理设置…NavMeshSurface组件访问导航窗口。

现在我们有了与我们的导航网格表面一起使用的特定机器人代理类型,返回到以下两个组件以设置Bot

  • NavMeshSurface组件:在场景层次结构中的NavMesh Surface对象上。

  • NavMeshAgent组件:在Enemy Hover Bot A预设件上(如图 13.6中的(B)所示)。

NavMeshSurfaceNavMeshAgent使用相同的代理类型是 AI 导航系统的要求——如果它们不使用相同的代理类型,您将在控制台中收到奇特的设置目的地相关的问题,并且烘焙将无法工作!

在选择不同的代理类型后,我们将必须再次点击烘焙按钮来计算新的导航网格表面——确保在调整代理类型值时需要时返回重新烘焙。

我们只是触及了表面

对不起,我忍不住要打趣一下!Unity AI 导航系统的能力远不止我们在这里提到的。我鼓励您探索文档,并尝试将更多功能集成到您的游戏中,以进一步提升玩家体验:

docs.unity3d.com/Packages/com.unity.ai.navigation%401.1/manual/.

现在我们已经烘焙了导航表面,我们最终可以添加航点,让机器人边走边巡逻!

添加航点到关卡并测试

NPC 巡逻增强了游戏体验,使环境感觉生动,增加了玩家的沉浸感和挑战性。虽然让 NPC 巡逻一组航点本身可能不被视为 自发的行为,但遭遇仍然可能显得不可预测,保持游戏乐趣并测试玩家的适应性。

自发行为

自发游戏体验指的是由简单游戏机制在视频和桌面卡牌游戏中实施可能出现的复杂情况。

我们已经配置了添加敌人机器人巡逻能力的所有要求;现在我们只需要定义具体的巡逻点。因此,按照以下步骤将我们的航点添加到我们制作的导航网格表面内的特定位置:

  1. 场景 层次结构中添加一个名为 Patrol Waypoints 的空 GameObject,以将航点对象分组。

在添加对象后,别忘了将变换位置重置为 (0, 0, 0)

  1. 添加三个 GameObject,每个走廊的末端各一个——其变换位置代表实际的航点位置;参见 图 13.7* 作为位置参考。

  2. 选择航点对象后,使用检查器分配一个图标以便在 场景 视图中快速识别——图 13.7* 显示了一个选中的绿色标签图标。

现在我们已经在场景中添加了航点,我们可以将它们分配给 场景 层次结构中的 PatrolWaypoints 行为组件的 Enemy Bot A 对象,然后锁定检查器。现在,选择我们刚刚创建的所有三个航点对象,并将它们拖到 航点 字段中,一次性添加它们。如果我们没有锁定检查器,它会在我们多选层次结构中的航点对象时改变,从而阻止分配。或者,如果没有锁定检查器,您可以一次拖动一个航点来分配它们(真麻烦)。

强制保存提醒

您一直在定期按 Ctrl/Cmd + S 保存您的场景,对吧?现在就把它作为一个提醒去做这件事。航点分配到 Enemy Bot A 预制件是场景级别的保存;它们不会与基于文件的预制件资产一起保存。

测试导航航点。进入播放模式,切换回场景视图,调整视图,并观察机器人按照航点列表中出现的顺序导航路径——对加速度、速度或导航代理类型半径等配置值进行任何调整。根据需要重复进行测试,以使初始敌人机器人以良好的巡逻方式巡逻。

在本节中,我们学习了如何使用 Unity 的 NavMesh 系统将 2D 巡逻航点行为重构为其 3D 等效版本。接下来,让我们让敌人机器人具有检测或感知玩家的能力,并做出适当的反应。

带有传感器和行为树的动态敌人

在视频游戏中,传感器在创建交互性和动态 AI 行为中可以发挥关键作用。它们使 NPC 能够感知周围环境,使他们能够以更真实的方式对玩家以及其他环境因素做出反应。例如,在潜行游戏如合金装备固体中,敌人通常配备有视野FOV)传感器,当玩家进入他们的视线时可以检测到玩家。同样,最后一人等游戏结合了音频传感器,使敌人能够根据玩家发出的噪音来检测玩家。这些类型的传感器通过使玩家更仔细地改变移动和行动策略,为游戏增加了深度。

简而言之,传感器是我们对象中添加的能力,使它们能够感知周围环境和其他对象——特别是对于敌人 NPC 和玩家来说更是如此!

在本节中,我们将查看一些代码,这些代码可以用来实现我们刚刚识别出的两种传感器类型:基于 FOV 检测玩家和基于音频检测玩家——例如玩家的脚步声。

创建感官行为

我们将要查看的第一个传感器代码是用于检测巡逻的敌人 NPC 视野内的玩家。我们将使其成为一个基于事件类的类,不继承自MonoBehaviour。这样,我们就可以通过简单地创建一个新实例,直接在实现类EnemyController中使用它。这将非常直接。

首先,查看我们的传感器类的代码模板:

using UnityEngine;
using UnityEngine.Events;
public class SensorTemplate
{
    private readonly MonoBehaviour _context;
    public event UnityAction OnSensorDetected;
    public SensorTemplate(MonoBehaviour context)
    {
        _context = context;
    }
    public void Tick()
    {
        // Invoke only if detection occurred.
        OnSensorDetected?.Invoke();
    }
}

让我们将其分解:

  • class SensorTemplate:请注意,我们不是一个派生类,并且由于我们不会将此类用作 Inspector 中的组件,因此没有从MonoBehaviour继承。我们将在实现类中使用new关键字创建传感器类的实例(记住,我们也不能用newMonoBehaviour一起使用)。

  • MonoBehaviour _context:说到MonoBehaviour……我们可能仍然需要访问它来运行协程或获取实现类的Transform值或 GameObject。我们将使用类构造函数来分配此变量。

构造函数(C#)

每次创建一个类或结构的实例时,都会调用其构造函数。更多信息可以在这里找到:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/constructors

  • event UnityAction OnSensorDetected: 我提到传感器类将是基于事件的,所以这就是它。这是当传感器检测到我们设计它去检测的东西时我们将调用的UnityAction事件。event关键字强制只有声明类可以调用它。

  • SensorTemplate(MonoBehaviour context): 这里是我们的类的构造函数。与类名相同的方法是构造函数。我们在创建类实例时添加了一个参数来设置_context成员变量。

  • void Tick(): 这里是魔法发生的地方。通常,Tick()将从实现类的Update()方法中调用,以执行每个传感器所需进行的检测工作。我们仅在检测发生时调用OnSensorDetected事件。

现在,在继续查看我提供的两个传感器示例之前,让我们看看传感器类在实现类中的使用方式。我们将使用EnemyController作为这个例子,其中我们之前只是使用了一个简单的成员方法,IsPlayerInRange()。你可以看到我们如何使用传感器类作为构建 NPC 能力的一种方式,如下面的代码所示:

public class EnemyController : MonoBehaviour
{
    private SensorTemplate _sensor;
    private void Start()
    {
        _sensor = new SensorTemplate(this);
        _sensor.OnSensorDetected += HandleSensor_Detected;
    }
    private void HandleSensor_Detected()
    {
        Debug.Log("Sensor triggered!");
        ChangeState(State.Detected);
    }
    private void Update()
    {
        _sensor.Tick();
    }
}

这里是对实现的一个快速概述:

  • private SensorTemplate _sensor: 这个变量将持有我们创建的传感器类的实例,以便在整个类的实现中引用。

  • _sensor = new SensorTemplate(this): 使用new关键字,我们创建了一个新的传感器类实例以供使用,传递了this关键字,它代表EnemyController类(从MonoBehaviour基类派生)。

this (C#)

this关键字指的是类的当前实例。更多信息可以在这里找到:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/this

  • _sensor.OnSensorDetected += HandleSensor_Detected: 添加处理方法以监听传感器检测到某物时的情况。

在这个模板示例中,HandleSensor_Detected()方法简单地改变到另一个状态,控制器将根据传感器的检测采取行动。别忘了我们也可以通过事件将参数传递给处理方法;只需将事件类型声明更改为类似UnityAction<float>的形式。

  • void Update(): 我们将在传感器类中每帧运行Update()方法,以便执行其检测工作负载。

这就是为 NPC 添加感知能力而实现自定义类实例的全部内容。如果你想进一步巩固传感器类的架构,你可以使用一个基抽象类或一个接口。把这看作是你使用这些模式之一重构传感器模板代码的挑战!

抽象类或接口?

抽象类可以提供一个起点,提供一些预定义的做事方式,而接口只是声明必须遵循的要求。想象一下,你有一个已经定义了一些颜色的蜡笔盒——那就是抽象类。而一本有轮廓图的彩色书,你必须在这个轮廓内涂色——那就是接口。

在接下来的两个小节中,我提供了两个传感器类的代码示例,可以将它们添加到任何控制器类中,以检测 FOV 内的目标以及检测任何附近正在播放的玩家的音频源。

在 FOV 传感器内检测玩家

想象一下,有一副眼镜可以帮助你在捉迷藏游戏中找到你的朋友。这些眼镜在视频游戏中与 FOV 传感器中的玩家工作方式类似,你有一个有限的视距和视野范围,需要朝正确的方向看以识别你的朋友。如果玩家在视野角度和距离内且没有被遮挡,传感器就会检测到他们并触发警报。在视频游戏中使用这种类型的传感器,NPC 可以检测或感知玩家,就像眼镜在捉迷藏游戏中帮助一样。

我已经在可下载的 3D FPS 游戏项目代码中提供了完整的SensorTargetInFOV类,以及其在EnemyController类中的初始实现。脚本位于此处:Assets/Scripts/Sensors/SensorTargetInFOV.cs

下载完整的传感器代码

所有示例传感器代码可以在 GitHub 仓库中找到:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch13/Unity-Project/Assets/Scripts/Sensors/

类构造函数增加了额外的参数,用于指定 FOV 角度和视觉范围:

public SensorTargetInFOV(MonoBehaviour context,
    float fovAngle, float fovRange)
{
    _context = context;
    _fovAngle = fovAngle;
    _fovRange = fovRange;
}

这些额外的值随后被用于传感器在其每帧调用IsTargetInsideFOV()方法时的检测计算,该方法执行以下操作:

  1. 计算指定目标对象的方位。

在调用Tick()之前,通过调用SetTarget()设置目标对象。

  1. 如果目标角度的方向在指定的fovAngle内,它就会计算到目标的位置。

  2. 如果到目标的位置距离在fovRange内,我们最终会进行一次物理射线投射来检测玩家目标对象。

额外阅读 | Unity 文档

在游戏开发中,射线投射是一种常见的检测对象的方法。它涉及在指定方向上投射一个不可见的激光束,以报告它与任何相交的对象。

更多关于Physics.Raycast的信息可以在以下链接找到:docs.unity3d.com/2022.3/Documentation/ScriptReference/Physics.Raycast.xhtml

  1. 如果射线投射的击中对象是玩家——通过比较其标签确定——我们调用目标 检测事件。

探索并发现传感器代码及其实现,以进一步巩固你对我们可以创建代码架构的不同方式的了解。现在,我们将查看第二个传感器示例,即听觉传感器。

检测玩家的音频传感器

在视频游戏中,听觉传感器使你的 NPC 能够用超敏感的耳朵识别周围环境中的声音。这种类型的传感器可以帮助 NPC 根据它们发出的声音定位和识别对象,即使对象对 NPC 不可见。我最喜欢的例子是恐怖游戏Alien: Isolation,其中外星 NPC 使用复杂的听觉传感器不仅能够听到玩家发出的噪音,还能识别其严重性——声音在生存恐怖体验中扮演着至关重要的角色。

在你的游戏中使用听觉型传感器可能令人恐惧,也可能不会,但它无论如何都会提升游戏体验。我已经在可下载的 3D FPS 游戏项目代码中提供了完整的SensorHearing类示例,以及其在EnemyController类中的初始实现。脚本位于此处:Assets/Scripts/Sensors/SensorHearing.cs

类构造函数增加了额外的参数,用于指定听觉范围和更新频率(检查音频源的频率):

public SensorHearing(MonoBehaviour context,
    float hearingRange, float updateFrequency)
{
    _context = context;
    _hearingRange = hearingRange;
    _updateFrequency = updateFrequency;
    _context.StartCoroutine(
        PeriodicallyUpdateAudioSources());
    …

这些额外的值随后被用于传感器在其每帧调用IsAudioDetected()方法中的检测计算。此外,我们使用传入的MonoBehaviour上下文来启动一个协程:协程需要MonoBehaviour对象来运行——定期更新场景中音频源列表(对于我们的 3D FPS 游戏来说并不特别有用,但对于玩家会进入和退出游戏的多玩家游戏来说尤其相关)。

音频检测方法IsAudioDetected()执行以下操作:

  1. 我们将简单地通过遍历场景中找到的音频源列表开始。

  2. 如果有音频源正在播放并且我们能听到音频源,则调用目标 检测事件。

CanHearAudioSource()用于确定当前播放的音频源是否在hearingRange内,并对其播放音量进行调整。

玩家脚步 3D 音频源

注意,对于我们的玩家脚步声音频,我们使用 AudioPlayerSFX 组件实现了二维声音。对于敌人 NPC 感知玩家的脚步声,我们需要使用 SensorHearing 类,此时应使用 AudioPlayerSFX3D 组件。如果您想重构玩家脚步声以支持音频感知行为,请参阅 第十二章

类似地,对于这个传感器,探索并了解传感器代码及其实现,以进一步巩固您对我们能以何种不同方式创建代码架构的理解。

在本节中,我们学习了在游戏开发术语中什么是传感器,以及如何使用不继承自 MonoBehaviour 的类来实现 NPC 的感知能力。接下来,我们将看到行为树如何帮助管理 NPC 的 AI 复杂性。

使用行为树管理行为

行为树BT)是实现多种 AI 驱动的 NPC 的强大且灵活的工具,因为它能够实现比传统的 有限状态机FSM)更复杂的分层决策,后者只是简单地保持当前状态。BT 与预定义的传感器配合得非常好,例如我们讨论过的 玩家视野内传感器听到玩家音频 传感器,因为这些传感器可以作为图中的自定义节点集成到 BT 中。这种传感器集成将允许 NPC 根据感官输入条件做出决策,从而有利于增强敌对 NPC 的游戏行为。

由于 BT 是基于图的,包含序列、动作和其他节点,它们提供了一种更易于管理的可视化方式来展示正在实现的 AI 行为之间的关系。然而,BT 不是可视化脚本(尽管一些 BT 框架确实提供了跨入可能被视为可视化脚本的功能)。条件节点允许由 BT 驱动的 NPC 动态地响应玩家的动作,例如将 NPC 的行为从巡逻改为调查噪音或发现玩家。

这里是一个简单的 UML 图,展示了一个巡逻 NPC 的 BT,它可以感知玩家在其视野内或听到玩家,其中子节点代表触发传感器的结果状态:

图 13.9 – 一个简单的 BT 图

图 13.9 – 一个简单的 BT 图

如您所见,BT 的分层结构提供了设计 AI 行为的清晰且易于管理的方式。Unity Asset Store 中的工具,如 Behavior DesignerNode CanvasSchema,提供了创建 BT 的框架,大多数情况下无需编写任何代码。它们提供了直观的编辑器、许多预构建的节点,以及它们自己的自定义动作和针对 Unity 脚本 API 的特定动作。

BT 工具旨在使 Unity 开发者更容易为他们的 NPC 实现复杂的行为。它们还更容易集成自定义条件节点(例如,传感器),允许快速迭代配置以获得所需的行为。

免费行为树资产 | Unity 资产商店

模式被描述为“一个快速、简单的平台,用于使用行为树构建人工智能。无需编写任何代码,即可为您的游戏创建复杂和智能的行为。AAA 工作室广泛使用行为树将逼真的行为引入其人工智能中。

这是它的资产商店链接:assetstore.unity.com/packages/tools/behavior-ai/schema-200876

我已经提前将 Schema 行为树资产添加到书中 GitHub 仓库(技术要求部分中的链接)的 3D 第一人称射击(FPS)项目文件中,供您探索和实验。您可以使用 Schema 图形编辑器为上一节创建感官行为中创建的传感器创建自定义节点,并将它们应用于敌方悬浮机器人 NPC。

Unity Muse AI 工具 – 行为

Muse 行为是 Unity 内置的基于图形的行为树(BT)工具,具有直观的流程图和动作节点故事,适用于 AI 设计师。它目前处于预发布阶段,但看起来将成为 AI NPC 设计的全功能 BT 解决方案——包括使用文本输入生成式 AI 快速创建节点动作的能力。

这是 Muse 行为教程项目的链接:assetstore.unity.com/packages/templates/tutorials/muse-behavior-tutorial-project-269570

作为游戏开发者,你可能会倾向于某些技术而远离其他技术,并随着时间的推移,要么只采用代码方法,要么在技能集中添加一些可视化工具。最后,记住,关键在于有效地克服挑战,以实现项目范围内的所有功能,尤其是完成游戏!

在本节中,我们学习了创建传感器以增加 NPC 的检测能力,以及使用行为树作为可视化工具来创建、管理和设置动态 AI 驱动的 NPC 行为的价值。接下来,我们将通过调查 Unity 的机器学习工具来进一步探讨 AI 驱动的行为。

介绍 ML 与 ML-Agents

开发视频游戏的许多部分都涉及可能单独填满整本书的技术,机器学习(ML)无疑是其中之一!Unity 拥有自己的机器学习工具,特别适合游戏开发,它被称为ML-Agents

ML-Agents 是一个为 Unity 开发者提供的 AI 工具包,帮助他们使用机器学习技术在其游戏和模拟中创建高级和复杂的行为。与依赖于手工编写的规则集的 BT 不同,ML-Agents 利用机器学习使非玩家角色(NPC)能够通过与环境交互来 学习 和调整其行为。这是通过使用诸如 强化学习模仿学习 或其他自定义方法等机器学习技术训练智能体来实现的。这个过程使得经过训练的智能体 NPC 能够独立地确定最佳行动方案,不断变化并让你惊喜,希望是难以预测的游戏玩法。我之前简要提到了 涌现游戏玩法,这就是它!

Unity ML-Agents 适用于各种场景,从简单的游戏到更复杂的模拟。以下是 ML-Agents 工具包提供的总结列表:

  • 不同的训练环境:你可以创建与现实世界一样简单或复杂的环境来训练智能体。

  • 多种训练算法:这也与工具包的灵活性相关,它支持各种针对当前智能体训练任务可定制的先进机器学习算法。

  • Unity 集成:ML-Agents 设计为专门与 Unity 一起工作,这使得它对 Unity 游戏开发者来说非常易于访问。

Unity 还提供了丰富的资源来学习 ML-Agents,包括 ML-Agents: Hummingbirds 项目,这是一个包含大约 10 小时内容的综合示例项目!

额外的阅读材料 | Unity 文档

更多关于 ML-Agents 的信息可以在这里找到:unity.com/products/machine-learning-agents

更多关于 ML-Agents 工具包的信息可以在这里找到:github.com/Unity-Technologies/ml-agents/tree/latest_release

更多关于 ML-Agents: Hummingbirds 的信息可以在这里找到:learn.unity.com/course/ml-agents-hummingbirds/?tab=overview

ML-Agents 如何为 Unity 游戏工作有三个基本步骤:

  1. 集成:这一步骤涉及将 Unity ML-Agents 包集成到为训练设计的 Unity 项目中。

  2. 训练智能体:这一步骤涉及将 Unity 项目连接起来,以训练智能体学习所需的行为。

  3. 嵌入:这一最终步骤涉及将训练好的智能体模型嵌入到你的游戏项目中。

让我们记住这个简化的、高级的三步流程,并在此基础上构建我们之前使用 Unity AI 导航包的工作。这样,我们可以使用 NavMesh 智能体来优化 ML-Agents 智能体训练的效率,使其专注于行为训练而不是导航。

使用 NavMesh 提高训练效率

当使用 ML-Agents 开发游戏时,我们可以使用 NavMeshAgent 组件来处理代理导航的任务,同时将 ML-Agents 训练集中在更高层次的决策过程,如巡逻、调查和攻击玩家角色。

这种将智能 AI 行为与既定的导航设置相结合的方法,导致训练结果更加流畅和有效。以下图显示了具有 NavMesh Surface 设置的简单训练场景:

图 13.10 – 示例 ML-Agents 训练场景

图 13.10 – 示例 ML-Agents 训练场景

注意到此效率后,让我们看看下一个通用 ML-Agents 训练设置的样子。

检查 ML-Agents 设置

下面是我们在 Unity 中设置 ML-Agents 训练和实现的典型步骤的更详细分解:

  1. ML-Agents 包及其依赖项。

最新安装说明可在 ML-Agents GitHub 仓库中找到:github.com/Unity-Technologies/ml-agents/blob/latest_release/docs/Installation.md

  1. 我建议通过 GitHub URL 使用包管理器安装最新版本:git+github.com/Unity-Technologies/ml-agents.git?path=com.unity.ml-agents#release_21

  2. 依赖项包括 ML-Agents 扩展、mlagents Python 包、PyTorch(Windows)、Visual C++ RedistributableUnity Sentis

  3. Unity 项目设置:

    1. 创建一个新的 Unity 项目,选择一个3D Core模板。

    2. 使用烘焙的 NavMesh Surface 设计环境,并在场景中添加代表玩家角色和应观察玩家的 NPC 对象。请参阅图 13.10以获取简单的训练场景表示。

  4. 设置代理:

    1. NavMeshAgent 和 ML-Agents Agent 组件附加到 NPC 对象上。

    2. 配置 NavMeshAgent 属性,如速度、角速度和加速度。

  5. 实现代理方法:

    1. 创建一个新的类,从 Unity.MLAgents.Agent 继承,例如命名为 AgentController

    2. 覆盖 Agent.CollectObservations() 方法,向代理提供有关环境或玩家数据的传感器信息:

    public override void CollectObservations(VectorSensor sensor)
    {
        // TODO: Implementation of sensor observations.
    }
    
    1. 覆盖 Agent.OnActionReceived() 方法以应用 NPC 动作,例如为 NavMesh Agent 设置目的地或转向面对玩家:
    public override void OnActionReceived(ActionBuffers actionBuffers)
    {
        // TODO: Implementation of actions.
    }
    
  6. 定义奖励和训练:

    1. OnActionReceived() 方法中定义奖励:

      1. SetReward() 方法并传入一个正浮点值,例如成功到达巡逻点或面对玩家。请注意,此函数替换了在当前步骤期间给予代理的任何奖励。您也可以使用 AddReward() 来逐步更改奖励而不是覆盖。

      2. 使用 SetReward() 方法并传入一个负浮点值,以传递不希望的行为,例如撞墙或失去对玩家的视线:

    public override void OnActionReceived(ActionBuffers actionBuffers)
    {
        …
        SetReward(0.1f); // Positive reward.
        SetReward(-1);   // Negative reward.
    }
    
    1. 创建一个训练配置文件(即 trainer_config.yaml),在其中定义指定的训练算法(例如,强化学习算法)及其配置。

    2. 在玩家对象代理上配置一个 BehaviorParametersDecisionRequestor 组件,用于观察和决策设置。

  7. 运行训练过程:

    1. 使用命令提示符或终端导航到您的 Unity 项目文件夹,运行训练命令(例如,mlagents-learn trainer_config.yaml --run-id=firstNPCPatrol – 这里,run-id 是会话的任何唯一名称),并使用 TensorBoard 监控代理的训练过程。
  8. 集成训练结果:

    1. 训练完成后,推理模型将保存为 .nn 文件。将其导入到您的 Unity 项目中的一个文件夹中。

    2. 在代理上,将模型分配给 BehaviorParameters 组件的 Model 属性,并将 Behavior Type 设置为 Inference Only 以在场景中测试行为。

  9. 进行游戏测试和设计迭代:

    1. 在 Unity 中播放场景并观察 NPC 代理的行为(例如,在巡逻、感知或检测玩家时)。

    2. 根据需要调整奖励并重新运行训练,以逐步细化 NPC 行为。

通过遵循这些步骤,您可以设置一个使用 NavMesh Agent 进行路径查找的训练环境。同时,ML-Agents 只负责训练复杂的 NPC 行为,例如在我们的示例中的巡逻和玩家检测。

ML-Agents 样例环境

ML-Agents 工具包的 GitHub 仓库包含一系列示例环境,这些环境展示了工具包的各种功能。这些环境也可以用作新环境的起始基础或测试新机器学习算法的预定义设置。

一些示例学习环境可以在以下位置找到:github.com/Unity-Technologies/ml-agents/blob/latest_release/docs/Learning-Environment-Examples.md

如果您希望为玩家创造真正卓越的游戏体验,请考虑使用 ML 和 ML-Agents。这是一个强大的工具,可以帮助您取得显著成果!

Unity Muse | 额外阅读

在 Unity 2022 技术流发布过程中,Unity 在 Unity Muse 的名称下为开发者发布了新的 AI 工具。这些工具目前作为订阅服务提供,包括免费试用期。

关于 Unity Muse AI 工具的更多信息,请参阅以下链接:unity.com/products/muse

在本节中,我们探讨了如何使用 Unity 的 ML-Agents 工具包通过机器学习算法训练 NPC。结果是拥有可适应行为的 NPC,这些行为超出了我们为 AI 预先编写的脚本(例如,使用 BT)。我们还进一步了解了在 Unity 项目中使用 ML-Agents 所需的知识,以通过自适应 NPC 行为提升玩家体验。

摘要

在本章中,我们在保持使用航点的同时,将我们的游戏敌人 NPC 从 2D 环境重构到 3D 环境,并利用 Unity 的 NavMesh 系统进行 AI 导航。我们还通过使 NPC 能够通过使用传感器更真实地与玩家和环境互动来提高 NPC 的行为复杂性——这种效果使得玩家在挑战中更加投入。

我们通过将传感器作为 BT 中的条件来整合,继续讨论动态敌人行为。我们通过介绍 Unity 的 ML-Agents 来结束高级 AI 讨论,使 NPC 能够学习和进化,使我们能够将基于高级 AI 的游戏玩法集成到我们的游戏中,为玩家带来非凡的体验。

在下一章中,我们将通过创建一个经典的混合现实(MR)中的混合现实老板房间战斗来结束 3D FPS 游戏。此外,你将学习如何通过应用之前学习到的所有开发游戏系统的课程,快速设计一个具有挑战性的老板房间及其机制。

第十四章:使用 XR Interaction Toolkit 进入混合现实

第十三章中,我们对 3D FPS 游戏的敌人 NPC 进行了一些修改。我们将它们从 2D 组件升级到 3D 组件,同时仍然使用航点进行导航,但利用 Unity 的 NavMesh 系统快速实现巡逻行为。我们还通过添加传感器增强了 NPC 行为的复杂性,这些传感器允许它们以更真实的方式与玩家和环境交互。

我们继续讨论如何使用我们的传感器作为行为树中的条件来创建动态敌人行为。然后,我们通过介绍使用 Unity 的 ML-Agents 的机器学习ML)来完成我们的 AI 讨论,这使得 NPC 能够学习和进化。通过集成基于高级 AI 的游戏玩法,我们可以为我们的玩家创造惊人的体验!

在本章中,我们将通过创建混合现实MR)中的最终老板房间遭遇战来完成从 3D FPS 游戏开始的旅程。我们将通过使用Unity XR Interaction Toolkit以及从先前努力中积累的资产、可重用组件和系统来实现这一点,所有这些都将汇集在一起,在你的房间里创造一场战斗!

在本章中,我们将涵盖以下主要主题:

  • 混合现实和开发框架简介

  • 设计老板房间

  • 与 AR 飞机(AR Foundation)合作

  • 在世界中放置可交互对象

  • 实现老板房间机制

到本章结束时,你将能够制作一个融合玩家物理空间(如墙壁、地板和桌子)的 MR 游戏或体验,为玩家创造新颖的体验。你还将学习如何创建可交互对象并管理它们的实例化,特别是关于定义物理空间边界和对象的检测表面平面。本章完成了制作游戏时快速构建功能和行为的知识积累。

技术要求

要跟随本章内容,你需要一个 Meta Quest 2 或 3 头戴式设备以及一根 USB-C 线缆来将其连接到你的电脑。这条线缆让你可以将 Unity 项目构建推送到你的设备,并在 Unity 编辑器的播放模式下直接测试一些功能。

如果你没有 MR 头戴式设备

即使没有 MR 头戴式设备,你也可以跟随本章内容——通过使用从 Unity Asset Store 提供的 Meta XR Simulator:assetstore.unity.com/packages/tools/integration/meta-xr-simulator-266732

你可以从 GitHub 下载完整项目:github.com/PacktPublishing/Unity-2022-by-Example

混合现实和开发框架简介

混合现实最近才成为可能,利用最新的头戴式显示器HMDs)创造物理世界和虚拟世界融合的环境,使数字和物理对象共存,并看起来相互交互。混合现实游戏、教育、医疗保健或工业应用结合了虚拟现实VR)和增强现实AR)的方面,提供一种沉浸式体验,其中虚拟内容锚定在现实世界中。

只需看看像Skyrim VRResident Evil VR这样的流行 PC 游戏 VR 改编,就能理解基于 VR 的技术对未来虚拟娱乐的强大前景。此外,像Minecraft VRRoblox VR这样的游戏,尽管玩家基数巨大且参与度高,也能提供沉浸式体验,将原本静态的环境转变为动态的世界,以前所未有的方式允许互动和探索。

原始 VR 游戏标题Beat Saber的突破性成功也展示了该平台多样化的潜力,不仅限于娱乐,还包括身体参与的玩法。VR、AR 和 MR 的未来将继续吸引我们的兴趣,因此让我们确保我们拥有在这个领域取得成功的工具。

在本节中,我们将回顾我们将用于构建我们的 BOSS 房间游戏的技术。技术栈包括 Unity XR Interaction ToolkitAR Foundation框架和OpenXR Meta 包。这些技术本身很强大,但结合在一起,就能创造出新的东西。它们使开发者能够更快地创建令人印象深刻的 MR 体验。

让我们对每个部分进行简要概述,看看它们是如何协调一致的。

XR Interaction Toolkit (XRI)

Unity 的 XRI 是一个适用于 VR/AR 的多功能交互系统,它简化并加速了跨平台创作。它为各种交互提供了通用框架,例如戳、注视(即射线)和抓取控制器和手。它还包括虚拟手、触觉反馈以及使用缩放、动画甚至混合形状进行选择时的响应。

额外阅读 | XR Interaction Toolkit (XRI)

XRI: docs.unity3d.com/Packages/com.unity.xr.interaction.toolkit%402.5/manual/index.xhtml.

XRI 示例: github.com/Unity-Technologies/XR-Interaction-Toolkit-Examples.

XR: docs.unity3d.com/Manual/XR.xhtml.

XRI 工具包通过提供一套全面的交互组件和系统,极大地简化了开发交互式 VR 和 AR 体验的过程,降低了开发者进入这一领域的门槛。它允许轻松实现常见的功能,如头部跟踪、移动(即运动)、对象交互以及虚拟空间内的用户界面。该工具包也是灵活和模块化的,为创建 MR 游戏提供了一个优秀的基础。

特别针对 Unity 2022,Unity 为 Meta Quest HMDs 提供的跨平台 MR 开发工具已从实验预览状态转移到 2022 LTS 版本中的完全支持状态!

XRI 提供了交互部分;现在,让我们看看这些技术的环境部分。

AR Foundation

Unity 的 AR Foundation 是一个跨平台框架,它提供了一个统一的 API,用于简化为移动和头戴式 AR/MR 设备构建应用程序。该包旨在与 XRI(以及 XR Hands)原生工作,显著降低开发者访问特定设备功能以支持构建 AR 应用程序的障碍。

额外阅读 | AR Foundation

AR Foundation: unity.com/unity/features/arfoundation

Unity 文档:docs.unity3d.com/Packages/com.unity.xr.arfoundation%405.1/manual/index.xhtml

更具体地说,AR Foundation 是统一ARCore(谷歌)和ARKit(苹果)API 的层,将其整合为一个单一的更高层次的 API。这个单一的 API 允许开发者编写一次代码,而底层平台的特定功能实现则自动处理。

AR Foundation 简化了将空间感知构建到应用程序中的过程,使数字对象看起来可以与真实世界交互。这对于创建无缝融合虚拟世界和真实世界的 MR 体验至关重要。

我们将专门针对Meta Quest HMD平台进行工作。我们的老板房间游戏将与 Quest 2 和 Quest 3 设备兼容。AR Foundation 对 Meta Quest 的支持是使用一个熟悉的行业采用的标准接口来构建的,这个接口被称为OpenXR

AR Foundation 提供了视觉部分;现在,让我们看看这些技术的平台支持部分。

OpenXR:Meta 包

OpenXR是一个开放且无需支付版税的标准,它通过统一的界面,在多个 AR 和 VR 硬件和软件平台及设备上实现高性能访问,这些平台和设备统称为 XR。

使用 OpenXR 进行开发简化了开发过程,因为它允许开发者针对任何支持 OpenXR 的系统进行开发,无需担心特定平台的具体细节。自 Unity 2022.3.11.f1 版本以来,Meta 包(包含Meta 特定的 OpenXR 扩展和 Meta 为其 Quest 设备提供的AR Foundation 提供插件)确保了软件和硬件之间的兼容性和互操作性,以支持其特定的输入设备、头戴式显示器和其他外围设备。

额外阅读 | OpenXR

Kronos Group: OpenXR: www.khronos.org/openxr/

总结来说,OpenXR 是连接交互和视觉系统与任何支持硬件设备的粘合剂——特别是那些具有更好图形性能和传感器的全新设备。这三项技术的结合使用,使开发者能够快速创建原型并部署生产就绪的 MR 游戏和体验——XRI 为交互元素提供基础,AR Foundation 建立在融合数字和物理世界视觉的能力之上,而 OpenXR 确保了体验可以在不同设备上访问。

图 14.1 – Unity XR 技术堆栈

图 14.1 – Unity XR 技术堆栈

在本节中,我们学习了 Unity MR 技术有哪些可供我们使用,以及这种基于 MR 技术的组合不仅简化了开发过程,而且能够创建复杂、引人入胜的 MR 应用程序,从而拥有广泛的终端用户覆盖范围。这直接引出了下一节,我们将着手设计我们的 MR 老板房间。

设计老板房间

设计老板房间遭遇战是游戏制作的关键部分,它结合了叙事、机械和环境考虑因素,为玩家创造一个引人入胜且具有挑战性的体验。

设计老板房间遭遇战时需要考虑几个关键区域,我们将简要探讨其中几个:

  • 叙事元素:遭遇战应该感觉像是一个自然的进展,甚至是故事的高潮。

  • 老板机制:玩家与老板元素的战斗应该是一个独特的体验,与玩家主要使用的机制分开,要求玩家调整策略以克服攻击模式和其它行为。

  • 环境设计:老板房间的布局应该与正在实施的叙事和机制相辅相成。这对于 MR 来说是一个特殊的考虑因素,因为我们将在玩家自己的房间(即他们的物理空间)中构建游戏环境并放置交互元素,为每位玩家创造一个新颖的挑战。

  • 平衡:老板遭遇战提出的挑战应该是具有挑战性的,同时感觉相当平衡,以避免不必要的挫败感,同时仍然为玩家提供一个可解决的挑战。

通过将这些元素纳入我们的 Boss 房间,我们旨在为玩家提供一段愉快且难忘的体验。克服 Boss 挑战将给他们带来满足感和成就感。此外,在我们的案例中加入 MR,体验变得更加非凡和有回报。

让我们暂时回顾一下我们的 GDD,以获取关于即将添加的 Boss 房间战斗的快速更新,这将为我们设置场景提供上下文。

什么是栖息地内部的 Boss 遭遇? 在游戏的高潮部分,玩家必须潜入一个严密守卫的中心控制室,重新启动被邪恶的外星植物实体关闭的反应堆——其晶体模块已被弹出。这场战斗的结果将决定这个星球上 Kryk’zylx 种族的未来。

表 14.1 – GDD 片段设置 Boss 战斗场景

很好。上下文已经设定,我们有一些关于 Boss 战斗目的的故事背景。你不是来自拖车公园的某个孩子;你是一名 Kryk’zylx 侦察兵!因此,你装备了最先进的基于能量的武器,比如这把激光手枪:砰砰!

图 14.2 – XR 交互式枪

图 14.2 – XR 交互式枪

让我们先定义我们的物理空间,然后继续创建 Unity 项目并测试我们的 MR 设置。

设置物理空间

在设备上正确定义 MR 游戏的物理空间设置至关重要,因为它直接影响沉浸式体验的可能性。它无缝融合了虚拟内容,例如为墙壁、地板、天花板、桌子、座椅定义的水平 AR 表面平面,以及如门和窗户这样的垂直 AR 表面平面。这些虚拟表面对象与它们的现实世界对应物相匹配,增强了游戏玩法,确保了安全,并最大化了玩家的参与度。物理空间环境设置还充当了游戏开发者讲述故事和探索的互动画布。

对于 Meta Quest 3,头戴式设备包括一个深度传感器来扫描你的房间环境并检测地板、墙壁和天花板,以建立你的物理空间设置的起点。一旦完成房间扫描,你可以手动确认墙壁并添加家具。

对于 Meta Quest 2,你必须完全手动设置你的物理空间。

Meta Quest 房间设置

为了确保 Meta Quest 设备上的平面检测功能正常工作,你必须首先在 Quest 头戴式设备上的设置 | 物理空间 | 空间设置中完成新的房间设置功能,然后再进入 MR 游戏。为了确保最佳性能,还建议至少包含一件带有水平表面的家具,例如桌子。

请注意,我们将创建的 MR 游戏依赖于提供由物理扫描或手动空间设置建立的不同的表面平面示例。您必须确保您的房间至少有四面墙和一张桌子。

您可以在运行 MR 游戏或体验之前随时进行房间设置,因此您可以随时进行。但就目前而言,我们将继续创建和设置我们的 Unity 项目,以开始我们的老板房间战斗。

创建 Unity 项目

如果您还没有安装,请安装最新的 Unity 2022.3 LTS 版本——这样我们才能在 Unity Hub 中访问新的 VR 和 MR 模板。我们还需要确保 Android Build Support 模块可用,因此请确保您已安装该模块,以及 OpenJDKAndroid SDK & NDK Tools 模块。

图 14.3 – 安装 Android Build Support 模块

图 14.3 – 安装 Android Build Support 模块

现在已安装所需的最小 Unity 编辑器版本和 Android 依赖模块,我们可以使用 Unity 的新 混合现实(核心)模板快速设置我们的 MR 老板房间项目。它基于 MR 和开发框架简介 部分中概述的核心 MR 技术。多么方便啊!

MR 模板项目通过简化高级功能的实现(如平面检测、设备穿透和空间 UI 创建)以及设计友好的 XR 可交互组件,简化了 XR 开发。它预配置了必要的包,如 XRI、AR Foundation、Unity OpenXR Meta 和 XR Hands,使得项目设置和包管理变得轻松。这种 MR 项目模板方法针对 MR 创作者对更丰富内容的需求,并减少了开发者访问高级 MR 功能的摩擦。

额外阅读 | Unity MR 模板

混合现实模板快速入门指南:docs.unity3d.com/Packages/com.unity.template.mixed-reality%401.0/manual/index.xhtml

在 Meta Quest 3 上探索跨平台 MR 开发:blog.unity.com/engine-platform/cross-platform-mixed-reality-development-on-meta-quest-3

打开 Unity Hub 并点击右上角的 新建项目 按钮,以创建一个新项目。然后,参照 图 14.4,按照以下步骤操作。

  1. 确保在顶部的 编辑器版本 下拉菜单中选择了之前安装的 Unity 2022.3 LTS 版本。

  2. 在中间的模板列表中,向下滚动并选择 混合现实(核心)。

  3. 在右侧面板中,如果您看到 下载模板 按钮,请点击以下载模板。

  4. 一旦模板下载完成,提供以下选项:

    • MR Boss Room

    • 位置:选择存储项目文件的文件夹路径。

    • Unity 云组织: 您必须选择此项目所属的组织。当您创建一个新的 Unity ID 账户时,Unity 会为您生成一个与您的用户名和 ID 关联的组织。Unity 组织提供的基本功能是组织您的项目、服务和许可证。

    • 连接到 Unity 云: 只有当您希望利用游戏服务来支持您的项目时(通常,您会希望这样做)才启用此选项。

    • 使用 Unity 版本控制: 如果您想使用 Unity Cloud 的版本控制系统VCS)将项目备份到云中,并允许其他团队成员协作,请启用此选项(我们将在第十五章中介绍 Unity 版本控制)。

图 14.4 – 从模板创建的新混合现实项目

图 14.4 – 从模板创建的新混合现实项目

  1. 要开始创建项目,请点击创建项目按钮,并放松几分钟。

一旦项目在 Unity 编辑器中打开,让我们完成一些基本设置步骤。

打开文件 | 构建设置…,按照以下步骤配置平台以支持构建到我们的 Meta Quest 设备:

  1. 平台列表中选择Android

  2. 纹理压缩下拉菜单中选择ASTC

  3. 点击切换平台按钮。

完成!

ASTC

可伸缩纹理压缩ASTC)是一种使用可变块大小而不是单个固定大小的纹理压缩方法,它取代了旧格式,同时也提供了额外的功能。

现在,您可以打开位于Assets/Scenes文件夹中的SampleScene场景,以检查场景设置,包括以下负责管理 XR 功能(包括控制器和手部追踪、与 UI 和虚拟对象的交互以及 AR 功能,如表面平面检测和透视)的 GameObject:MR 交互设置UI环境

通用 RP 渲染器设置

请注意,Meta Quest 头显对 Unity 渲染器设置的正确配置非常敏感。因此,我建议将 URP 渲染器和质量设置保持在 MR 项目模板提供的默认值(除非您真的知道自己在做什么)。

还请注意,独立 VR 硬件,如 Quest,需要额外的性能优化考虑,以维持最低 FPS(通常,不低于 72 FPS):这是为了防止视觉运动(当身体静止时的视觉感知),这可能会让人感到恶心。

将您的 Quest 头显通过 USB-C 线缆连接到系统后,您可以通过选择Android平台,在运行设备字段中,点击当前显示默认设备的下拉菜单,来验证 Unity 是否识别了该设备。您的 Meta/Oculus Quest 3(或 2)设备应该列在那里。

使用 Quest Link 在播放模式中进行测试

为了彻底测试和玩我们的游戏,我们必须在设备上构建,因为当在 Unity 编辑器中进入播放模式时,Quest Link 目前不支持平面检测和 passthrough。我仍然建议利用 Quest Link 来快速迭代设置对象交互,独立于游戏玩法,然后构建到设备上进行完整游戏测试。

要使用 Quest Link,请确保你用 USB-C 线缆连接到你的系统,并且 Oculus 应用(Meta Quest Link)正在运行,然后点击 Quest 头戴设备的快速设置菜单中的 Quest Link 按钮。一旦建立连接,你就可以在 Unity 中进入播放模式来测试你的场景。

现在,仍然在构建设置窗口中,点击构建并运行,或者按Ctrl/Cmd + B,然后戴上头戴设备!

小贴士

当你开始 MR 环境时重新居中或重置你的方向至关重要。这确保了虚拟对象被正确地放置在你的当前位置和面向方向周围。这样做将增强你的体验,尤其是在设备方向检测技术持续改进的情况下。

MR 模板配置为使用控制器或双手。不过,我们仍将专注于使用控制器进行我们的游戏,所以我建议你在示例项目中尝试使用控制器作为输入。

这验证了你的 Quest 头戴设备和 Unity MR 项目设置已准备好进行 XR 开发。那么,让我们开始构建老板房间吧!

布局老板房间场景

根据我们的 GDD,我们必须清除中央控制系统中的邪恶植物实体感染。因此,为了做到这一点,我们需要为受损的控制台供电并重启反应堆(是的,相信我,这会起作用)。因此,老板房间的布局需要在我们的场景中包含与此相关的对象。

这里是我们为老板房间设置所需的对象:

  • 控制台:维护居住站所有主要系统的状态,包括主反应堆。它有三个晶体模块插槽来为系统供电。

  • 反应堆:为中央系统提供电力,特别是负责环境控制和清除外来实体的系统。

  • 走廊:居住站由几个房间和连接走廊组成——这应该已经从 3D FPS 项目中熟悉了。

这些是我们场景中所需的主要对象,再次强调,这是你的房间,为老板遭遇提供背景。我们将构建布局,使控制台靠近玩家,并从房间中心延伸出虚拟走廊,为动作提供一个中心焦点。

在这个布局的考虑下,在下面的截图中——来自一个带有 passthrough 可见的 Quest 3——我们可以看到以这种方式实例化的对象在现实世界房间中的样子。

图 14.5 – 在物理空间中生成的虚拟对象

图 14.5 – 在物理空间中孵化的虚拟对象

让我们先复制提供的 MR 模板示例场景,开始设置场景:

  1. Assets/Scenes 文件夹中找到 SampleScene 场景。

  2. 通过选择并按 Crtl/Cmd + D 复制它。

  3. 将其重命名为 Boss Room

现在,打开场景,我们将关闭场景中提供的某些示例内容,这些内容我们不会使用。选择并关闭以下对象(使用位于对象名称左侧的检查框):

  1. 禁用 MR 交互设置 的这两个子对象:

    • 目标管理器

    • 对象孵化器

  2. 禁用 UI 的这两个子对象:

    • 教练界面

    • 菜单设置

  3. 保存场景。

我们完成了!我们现在有一个空场景,所有 XR 设置都已准备好供我们创建我们的 MR 游戏。简单易行。

在本节中,我们介绍了设计老板房的基本原则和设置我们物理空间所需的步骤。此外,我们还学习了如何使用 Unity 的 MR 模板创建基本起始项目,并对其进行配置以供我们使用。

当创建构建我们老板房间的虚拟对象时,我们使用代表我们物理空间中真实世界对象的 AR 表面平面来动态地孵化它们。接下来,让我们探索如何使用检测到的 AR 平面孵化虚拟对象。

与 AR 平面(AR 基础)一起工作

AR 平面是代表水平和平面表面的虚拟表示,无论是水平还是垂直,由尺寸和边界点表示,并由 AR 基础技术检测。这些平面为准确放置数字对象和与表面交互提供了基础。

如前所述,这些平面代表墙壁、地板和天花板、桌子等,我们将在这个示例老板房中特别使用墙壁、地板和桌子,以无缝地将游戏玩法与玩家的物理环境融合在一起。

小贴士

AR Plane Manager 允许您指定用于平面可视化的预制件。由 MR 模板提供的 AR Plane 预制件使用一个着色器,它会遮挡分配了透明材料的对象,因此如果您想要在 AR 平面之后可以看到的对象,请确保您不使用透明材料。

现在,让我们开始处理我们的第一个水平平面类型,即桌子,看看我们如何检测平面类型并使用其属性来孵化一个对象。

使用 AR Plane Manager 在飞机上孵化

位于 XR Origin (XR Rig) 对象上作为 MR Interaction Setup 根对象的子对象的 AR Plane Manager 组件负责检测物理空间中的水平和垂直表面,并创建我们的虚拟内容可以放置和与之交互的虚拟平面对象(AR Plane 预制件)。

优化提示

使用AR Plane Manager,除了指定AR Plane Prefab 外,您还可以在检测模式中选择水平、垂直或两者都选。如果您只需要检测水平平面,建议关闭垂直平面检测。

我们首先必须做的一件事是启用AR Plane Manager组件,因为它在 MR 示例场景中默认未激活。我们将通过我们的第一个脚本,即游戏管理器来完成此操作。

Assets/Scripts文件夹中创建一个名为GameManager的新脚本,并从以下代码开始:

using UnityEngine.XR.ARFoundation;
public class GameManager : MonoBehaviour
{
    [SerializeField]
    private ARPlaneManager _planeManager;
    private IEnumerator Start()
    {
        yield return new WaitForSeconds(2f);
        EnablePlaneManager();
    }
    public void EnablePlaneManager()
        => _planeManager.enabled = true;
}

现在,在场景中创建一个 GameObject,并将此脚本附加到它上。如您所见,我们已经将Start()方法的签名更改为IEnumerator(是的,您可以这样做),并且我们延迟了EnablePlaneManager()方法调用的执行 2 秒钟(以便给 XR 组件初始化时间)。

通过将XR Origin (XR Rig)对象拖入GameManager组件的字段中,在检查器中分配_planeManager。我们像这样在EnablePlaneManager()方法中启用组件:

    _planeManager.enabled = true;

您现在可以通过确保场景已添加到构建设置窗口中的构建场景,通过 USB-C 线缆将您的 Quest 设备连接到系统,并点击构建并运行Ctrl/Cmd + B,即构建 并运行)来测试老板房间场景。

您应该看到以下类似的图像,其中墙壁、地板和天花板以及任何水平表面(如桌子)都有淡化的虚线材质。请注意,我已经手动添加了洋红色线条以更好地显示平面表面(包括桌子、墙壁和地板)。

图 14.6 – 房间中检测到的表面平面

图 14.6 – 房间中检测到的表面平面

现在我们已经验证了我们可以使用平面,让我们开始为老板房间实例化我们的虚拟对象。

在桌面上实例化

哇,等等……我们需要将对象放入房间中。我们可以再次感谢Polypix Studios为这些资源提供 3D 艺术:控制台、模块、反应器、走廊、枪和悬浮机器人。

老板房间虚拟对象

您可以从本书的 GitHub 仓库中下载VirtualObjects-start.zipgithub.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch14/Art-Assets

解压文件以获取**.unitypackage**文件,并将其导入到您的项目中 – 您可以通过将文件从系统文件管理器拖放到 Unity 的项目窗口中来实现这一点。

我们将要处理的第一个资源是作为Reactor Prefab 导入到Assets/Prefabs文件夹中的反应器模型,如下截图所示:

图 14.7 – 老板房间 3D 资产 Prefab

图 14.7 – 老板房间 3D 资产 Prefab

然后,我们将Reactor Prefab 实例化到房间中检测到的第一个桌子上。

AR Plane Manager提供了一个planesChanged事件,我们将订阅它,当PlaneClassificationTable类型时,我们知道我们应该生成反应堆。所以,让我们在我们的GameManager类中添加监听器和处理生成对象的处理方法:

public void EnablePlaneManager()
{
    _planeManager.enabled = true;
    _planeManager.planesChanged += OnPlanesChanged;
}

不要忘记在OnDisable()OnDestroy()中取消订阅监听器。

现在,添加处理方法:

private void OnPlanesChanged(ARPlanesChangedEventArgs args)
{
    foreach (var plane in args.added)
    {
        switch (plane.classification)
        {
            case PlaneClassification.Table:
                if (!_hasSpawnedPrefab_Reactor)
                {
                    SpawnPrefab(plane, _prefabReactor);
                    _hasSpawnedPrefab_Reactor = true;
                }
                break;
        }
    }
}

在这里,你可以看到我们使用foreach语句遍历处理程序的args参数提供的所有检测到的平面(ARPlane类型)。然后,switch语句允许我们根据我们的需求处理特定的平面分类,这再次是Table。我们使用一个辅助的SpawnPrefab()方法,该方法执行实例化——传递特定的平面和 Prefab:

private void SpawnPrefab(ARPlane plane, GameObject prefab)
{
    Instantiate(prefab,
        plane.transform.position,
        plane.transform.rotation);
}

你可以看到我们正在使用常规的Instantiate()方法和平面的positionrotation值作为实例化的点。

为了确保场景中只生成一个反应堆,如果物理空间定义了多个桌面表面,我们将使用_hasSpawnedPrefab_Reactor布尔值将其限制为一个,并调用SpawnPrefab()方法,指定ARPlaneReactor Prefab 作为参数。在调用生成方法后,将_hasSpawnedPrefab_Reactor设置为true确保只生成一个 Prefab。

你必须确保你已经声明了变量来分配Reactor Prefab 和has spawned布尔值。然后,从检查器中的GameManager字段分配Reactor Prefab。

如果你现在在你的设备上构建并运行项目,你应该会在你的桌子上看到反应堆出现!

图 14.8 – 在桌子上生成的反应堆 Prefab

图 14.8 – 在桌子上生成的反应堆 Prefab

启用穿透

如果你想要暂时跳过,看看虚拟对象坐在你的真实世界环境中的样子,我们需要切换穿透以使其可见。在使用 XR 输入切换 MR 视觉效果部分,我们将添加切换穿透可见性的功能。

这就完成了如何在桌子上实例化对象的演示。接下来,让我们继续在地面平面上生成对象。

使用地面平面实例化

与反应堆的放置不同,我们将优先考虑控制器控制台对象相对于玩家前向方向(当检测到平面时)的朝向,以确保玩家可以立即进行交互。

流程基本上是相同的,只是方向不同。所以,我们首先将switch (plane.classification)块添加到我们的代码中,以处理Floor分类:

    case PlaneClassification.Floor:
        if (!_hasSpawnedPrefab_Console)
        {
            SpawnPrefab(plane,
                _prefabConsole,
                new Vector3(-1f, 0f, 0f));
            _hasSpawnedPrefab_Console = true;
        }
        break;

我们有一个类似的布尔检查,以确保我们不会在房间中通过_hasSpawnedPrefab_Console变量生成多个控制台,并且为SpawnPrefab()方法提供了一个方法重载。新的生成方法签名增加了一个额外的参数用于偏移量——我们将假设这个偏移量是从玩家位置开始的。

带有偏移的新 spawner 方法如下——将其添加到GameManager类中:

private void SpawnPrefab(
    ARPlane plane, GameObject prefab, Vector3 playerOffset)
{
    var playerTransform = Camera.main.transform;
    playerTransform.position = new Vector3(
        playerTransform.position.x,
        plane.transform.position.y,
        playerTransform.position.z);
    var worldOffset =
        playerTransform.TransformDirection(playerOffset);
    var spawnPosition = playerTransform.position
        + worldOffset;
    var directionToPlayer =
        (playerTransform.position
            - spawnPosition).normalized;
    var spawnRotation =
        Quaternion.LookRotation(-directionToPlayer,
            Vector3.up);
    Instantiate(prefab, spawnPosition, spawnRotation);
}

新的 spawn 方法中的重点如下:

  • playerTransform: 我们从主相机中获取玩家的当前位置,该相机连接到XR Origin (XR Rig)以表示玩家的头部。

  • playerTransform.position: 我们将新的Vector3 Y 值应用到playerTransform上,以便将实例化的模型锚定到地板平面上(其 Y 值)。

  • worldOffset: 我们接收玩家偏移值并使用TransformDirection()方法来确保玩家偏移将在适当的世界空间坐标中应用——因为我们无法知道传递给 spawn 方法的坐标,因为它是相对于玩家当前位置的。

  • spawnRotation: 我们希望确保实例化时控制台面向玩家,所以我们使用Quaternion.LookRotation()来实现这一点。

我们通过调用Instantiate()方法来完成,就像之前一样。添加所需的变量并执行检查器分配。然后,保存你的更改,继续进行另一个构建和运行,以查看你房间中的控制台。图 14.5 中可以看到控制台放置的示例。

好的,我们在老板房间布局上取得了很大的进展!最后要进入我们房间的环境对象是走廊,它将虚拟扩展房间的现实,并为我们的敌人悬浮机器人与玩家交战设定舞台。

使用墙平面实例化

我们最后一个 AR 检测到的平面实例化示例将与前两个相对相似,但现在我们必须考虑相对于垂直表面实例化对象。这将在定位时需要一些额外的注意,因为平面锚点位于平面表面对象的中心。

幸运的是,我们可以访问所有基本的Bounds属性,例如extents,但我们仍然需要表面范围和方向。所以,让我们首先向我们的switch (plane.classification)块添加代码来处理Wall分类:

    case PlaneClassification.Wall:
        if (!_hasSpawnedPrefab_Corridor)
        {
            SpawnPrefabAtWallBase(
                plane, _prefabCorridorDoorway);
            _hasSpawnedPrefab_Corridor = true;
        }
        break;

你可以在这里看到与之前相同的模式,使用布尔值来确定我们是否已经实例化了走廊 Prefab,并调用 spawn Prefab 方法,这次只传递了平面和 Prefab。

SpawnPrefabAtWallBase()方法看起来如下:

private void SpawnPrefabAtWallBase(
    ARPlane plane, GameObject prefab)
{
    var planeCenter = plane.transform.position;
    var heightOffset = plane.extents.y;
    var basePosition =
        new Vector3(planeCenter.x, planeCenter.y
            - heightOffset, planeCenter.z);
    var prefabRotation =
        Quaternion.LookRotation(-plane.normal, Vector3.up);
    Instantiate(prefab, basePosition, prefabRotation);
}

这里需要做一点额外的计算,以确保我们将 Prefab 的实例化点锚定到表面平面的垂直底部——与地板相同的 Y 值——通过使用plane.extents并从平面的变换位置(平面的中心)中减去。

对于生成的 Prefab 的旋转,我们再次使用LookRotation(),但这次,我们不会使用玩家方向向量,而是使用平面的表面法线向量。平面的表面法线指向房间的中心,因此我们想要将其反转以实例化走廊 Prefab,该 Prefab 的前进方向朝向走廊(对于你自己的 3D 模型,你可以反转法线向量或旋转枢轴的前进方向以获得正确的方向)。

再次,添加所需的脚本变量,保存脚本,将走廊 Prefab 分配到GameManager的字段上,保存你的场景,然后构建并运行应用程序以测试走廊添加到 Boss 房间中的位置。

现在我们已经完成了 Boss 房间的布局,包含了所有战斗所需的元素,让我们看看我们如何与 MR 视觉效果合作,在不显示 AR 平面和启用透传的情况下设置合适的游戏体验。

使用 XR 输入切换 MR 视觉效果

你知道我对在游戏中构建功能的同时也便于设计师使用的可重用组件有多大的热情。所以,让我们通过添加一个依赖于InputAction输入信号的按钮按下组件来以类似的方式从我们的 XR 控制器处理输入。

你可以从 GitHub 仓库中获取OnButtonPress脚本文件:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch14/Code-Assets,然后将其导入到你的项目中Assets/Scripts/Interaction文件夹。

我们将为以下游戏内动作设置按钮:

  • 右控制器,主要按钮(A)→ 切换透传可见性。

  • 右控制器,次要按钮(B)→ 切换 AR 平面表面。

  • 左控制器,主要按钮(X)→ 开始游戏。

让我们看看 Unity 的XR 输入映射的 Oculus 控制器按钮在以下图中是什么样子。

图 14.9 – Oculus XR 控制器按钮映射

图 14.9 – Oculus XR 控制器按钮映射

额外阅读 | XR 输入

Unity XR 输入映射:docs.unity3d.com/Manual/xr_input.xhtml#XRInputMappings

好的,很简单。让我们先为按下A按钮时设置透传可见性切换。

切换透传

MR 模板展示了在虚拟环境和设备透传之间切换的能力。这是通过在环境网格上使用简单的淡入淡出过渡来实现的。网格使用一个ShaderGraph着色器,它具有一个可以平滑过渡的 alpha 属性。

我们甚至不需要编写脚本来实现淡入淡出过渡。MR 模板的Environment预制件已经包含了一个FadeMaterial组件,该组件提供了一个公开的方法用于淡入淡出!

因此,让我们快速进入并切换淡入淡出效果,首先在Assets/Scripts文件夹中创建一个新的控制器脚本,命名为SceneController,并使用以下代码:

using UnityEngine.Events;
using UnityEngine.XR.ARFoundation;
public class SceneController : MonoBehaviour
{
    [Header("Triggered Events")]
    public UnityEvent<bool> OnTogglePassthrough;
}

保存脚本并将其添加到GameManager对象中。如您所见,我们将使用UnityEvent来分配对FadeMaterial函数的引用,传递一个表示要淡入可见状态的布尔参数。

让我们在检查器中分配OnTogglePassthrough(Boolean)回调,然后完成添加切换逻辑代码。所以,首先点击+图标以添加新的事件回调条目。然后,使用图 14.10作为参考,在场景层次结构中找到UI | Environment对象,并将其拖到对象字段。

现在,在事件被调用时,在UnityEventbool参数中选择顶部的FadeMaterial.FadeSkybox函数。

图 14.10 – SceneController 组件设置

图 14.10 – SceneController 组件设置

剩下的就是当玩家按下右侧控制器的主按钮时调用OnTogglePassthrough事件(对于该事件,SceneController类):

public void TogglePassthrough()
{
    _isPassthroughVisible = !_isPassthroughVisible;
    SetPassthroughVisible(_isPassthroughVisible);
}
public void SetPassthroughVisible(bool visible)
{
    _isPassthroughVisible = visible;
    OnTogglePassthrough.Invoke(_isPassthroughVisible);
}

直观、单一职责且命名良好的方法。添加私有成员变量_isPassthroughVisible以跟踪当前的切换状态,默认为false,这是正确的默认透视状态。

最后,参考图 14.10中的这些步骤,当控制器按钮被按下时接收玩家输入,让我们使用OnButtonPress组件,并配置输入动作以用于A按钮:

  1. OnButtonPress组件添加到SceneController

  2. 点击+下拉菜单,然后选择添加绑定

  3. 双击<无绑定>

  4. 路径下拉菜单中,选择XR 控制器 | XR 控制器(右手) | 可选控件 | primaryButton

或者,输入以下文本(点击<XRController>{RightHand}/primaryButton.

  1. 最后,将OnPress() UnityEvent函数分配给SceneController.TogglePassthrough

现在执行构建和运行到您的设备上,您可以通过按下右侧控制器上的A按钮来切换透视可见性。这将是你第一次看到在真实世界空间中生成的老板房间的数字对象——非常酷,对吧?

支持透视的相机设置

MR 模板的主相机已经预配置为启用设备透视,但提一下设置。相机的背景类型是纯色,背景颜色设置为黑色,0透明度。AR Camera Manager组件也被明确包含以控制 Meta Quest 设备上的透视。

Passthrough,检查!现在,让我们看看当按下 B 按钮时如何切换 AR 飞机可见性。

切换 AR 飞机可见性

我们将模仿 passthrough 切换设置,这并不令人惊讶。我们已经看到了如何引用和使用 AR Plane Manager 来为不同的飞机分类生成虚拟对象。嗯,我们还会在这里再次使用它来访问当前的 trackables 集合。

可追踪组件是一个表示在真实世界中检测到的 AR 对象的组件。例如包括飞机(你已经熟悉这些了)、点云、锚点、环境探测、面部、身体、图像和 3D 对象。

可追踪组件(AR 基础)

可追踪组件和可追踪管理器:docs.unity3d.com/Packages/com.unity.xr.arfoundation%405.1/manual/architecture/managers.xhtml#trackables-and-trackable-managers

让我们从向 SceneController 类添加以下切换代码开始,用于控制飞机可见性:

[SerializeField]
private ARPlaneManager _planeManager;
public void TogglePlaneVisibility()
{
    _arePlanesVisible = !_arePlanesVisible;
    SetPlaneVisible(_arePlanesVisible);
}
public void SetPlaneVisible(bool visible)
{
    _arePlanesVisible = visible;
    foreach (var plane in _planeManager.trackables)
    {
        if (plane.gameObject.
            TryGetComponent<FadePlaneMaterial>(
                out var planeFader))
        {
            planeFader.FadePlane(_arePlanesVisible);
        }
    }
}

SetPlaneVisible() 方法中的 foreach 循环负责在迭代可追踪集合时根据找到的 FadePlaneMaterial 组件实现飞机的淡入。如果找到,我们只需调用该飞机的 FadePlane() 方法。FadePlaneMaterial 组件为我们提供了 MR 模板的 AR Plane 预制件。简单易懂。

现在让我们将其连接到控制器按钮按下操作——这将是对应右侧控制器的次要按钮(B):

  1. GameManager 对象添加另一个 OnButtonPress 组件(位于之前的 OnButtonPress 组件下方)。

  2. 点击 + 下拉菜单,然后 添加绑定

  3. 双击 <****No Binding>

  4. 路径 下拉菜单中,选择 XR Controller | XR Controller (Right Hand) | Optional Controls | secondaryButton

或者,输入以下文本(点击 <XRController>{RightHand}/secondaryButton.

  1. 最后,将 OnPress() UnityEvent 函数分配给 SceneControllerTogglePlaneVisibility

现在执行 构建和运行 到你的设备上,你可以通过按下右侧控制器上的 B 按钮来切换 AR 飞机可见性。切换飞机可见性将主要用于调试;如果对象以意外的方式生成,你可以验证物理空间中检测到的飞机(任何检测到的都可能表明你需要重新检查 Quest 头盔的 Room Setup 配置)。

混合现实模板脚本修复!

在撰写本文时,/Assets/MRTemplateAssets/Scripts/FadePlaneMaterial.cs 脚本中存在一个与 FadePlane() 方法的错误,必须纠正,以便在第一次在 Awake() 方法中调用后,飞机能够正确地淡入淡出。

在第 91 行,在 FadeAlpha() 方法内,将 k_DotViewRadius 变量替换为 k_Alpha。现在该行应读作:rend.material.SetFloat(k_Alpha, alphaValue);

注意,我们在开始游戏时将关闭飞机的可见性。说到……现在让我们连接开始游戏的功能。

开始游戏

提供玩家通过按钮或菜单选择开始游戏的选择,可以创造一种控制和期待感。相比之下,对于 MR 甚至 VR,自动开始游戏可能会让人感到迷失方向,或者更糟,令人震惊。嗯,除非你想要敌对,并且没有警告就将玩家直接投入无法原谅的行动中(嗯哼,黑暗灵魂,我看到你了)。

如前所述,我们不会如此残忍,将为玩家提供一个按钮按下以进入 MR 环境开始游戏。考虑到这一点,我们需要对GameManager类做一些补充。让我们添加以下代码:

private SceneController _sceneController;
private void Awake()
    => _sceneController = GetComponent<SceneController>();

在这里,我们只是获取SceneController兄弟组件的引用。请继续添加一个[RequireComponent]属性给SceneController组件。

这是我们将从按钮按下时调用的StartGame()方法:

public void StartGame()
{
    EnablePlaneManager();
    StartCoroutine(DelayStartGame());
    IEnumerator DelayStartGame()
    {
        yield return new WaitForSeconds(1.5f);
        _sceneController.SetPlaneVisible(false);
        _sceneController.SetPassthroughVisible(true);
    }
}

我们将使用EnablePlaneManager()调用首先启用平面管理器来生成构成我们 Boss 房间游戏的虚拟对象。然后,我们将使用协程延迟调用本地DelayStartGame()函数 1.5 秒,设置飞机不可见,穿透可见——这将确保我们在真实世界空间中看到虚拟对象时不受阻碍。

移除IEnumerator Start()方法

不要忘记,我们将在之前“使用 AR Plane Manager 的平面生成”部分中设置的Start()方法移除。我们现在将等待玩家按下按钮开始游戏,以启用虚拟对象的生成。

好的,让我们通过以下步骤再次完成事情,但这次简化一下——你做到了:

  1. 将另一个OnButtonPress组件添加到GameManager对象中。

  2. 为 XR 左手控制器的主要按钮分配输入动作绑定(<XRController>{LeftHand}/primaryButton)。

  3. OnPress()函数分配给GameManager.StartGame

这次,当你构建并运行时,你将看到自己在空旷的虚拟环境中,直到你按下X按钮开始游戏并进入 Boss 房间战斗!

在本节中,我们学习了如何使用生成的 AR 平面在我们的房间中生成对象,以及它们的变换位置和旋转。我们还学习了如何访问 AR 系统和使用预制的组件(由 MR 模板提供)来切换穿透和 AR 平面的视觉状态。

现在我们已经处理好了 Boss 房间环境,并且游戏已经开始,但还没有我们能够做或与之交互的东西。我们将通过添加 XRI 交互对象来解决这个问题的现在。

在世界中放置交互对象

在 MR 游戏设计中,交互对象对于连接虚拟世界和现实世界至关重要。交互对象被设计为响应用户输入,即使是基本的手(或控制器)移动,这也允许自然直观的交互,如推动、抓取、投掷,甚至复杂的双手操作(例如,旋转和缩放对象)。它们确实有助于营造环境的现实感,因此,它们显著增强了玩家的参与度和整体游戏体验。

为了我们游戏的目的,我们将有简单的抓取和放置交互的示例,以及使用枪的次要交互事件动作进行射击。请注意,尽管许多 MR 游戏 和体验是为使用双手(手部追踪)而构建的,但我们的老板房间示例游戏将使用控制器。

让我们先配置抓取模块 – 这些模块随后将被配置以插入到控制台上的插槽中(参考 设计一个房间 部分的 GDD)。

将对象制作成 XR 交互对象

我们将首先处理的第一个可抓取对象是水晶模块。玩家必须能够抓取模块并将其插入控制台,因此我们将打开提供的 模块 预制资产在预制模式中(在 XR 捕捉交互 组件中双击它到根目录。

如以下截图所示,可抓取对象应有一个定位和适当旋转的变换,以便以正确的方向抓取物品,以便正确使用 – 这里,我们看到 模块 资产都定位和旋转得很好,以便于良好的抓取。

图 14.11 – 配置 XR 捕捉附着变换

图 14.11 – 配置 XR 捕捉附着变换

从截图注意,附着 变换的前向方向(Z 轴,蓝色箭头)是指向持有物体的玩家远离的方向。可能需要进行一些实验以达到所需的抓取位置。

现在,我们只需要将 附着 对象分配给 XR 捕捉交互 附着变换 字段,以确保它被正确地附着到玩家的控制器上。您可以在交互组件提供的许多选项中找到隐藏的 附着变换 字段。

图 14.12 – XR 捕捉交互可附着变换分配

图 14.12 – XR 捕捉交互可附着变换分配

阅读更多 | 便利性系统

XRI 便利性系统在与物体交互时提供视觉颜色和音频反馈提示,尤其是在使用双手时触觉不可用的情况下,使用带有交互源的可交互便利性状态提供器组件。示例在 XRI 示例项目中提供。

便利性系统:docs.unity3d.com/Packages/com.unity.xr.interaction.toolkit%402.5/manual/affordance-system.xhtml

保存模块并将其临时添加到您的Boss Room场景中,靠近MR Interaction Setup对象。进入播放模式并测试使用控制器(通过使用控制器侧面的握把按钮,用您的中指)抓取模块并在周围移动它。注意这次我说的是进入播放模式,而不是构建和运行。这是因为我们希望更快地迭代更改,如抓取点附件位置。有关详细信息,请参阅创建 Unity 项目部分的 Quest Link 提示。

完成的交互对象

所有完成后的 XR 交互对象都包含在本书 GitHub 仓库中该章节的完成 Unity 项目文件中:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch14/XR-Assets

在 XR 中使一个物体可交互的全部内容就是这些。XRI 使得获取游戏和体验所需的最小交互变得非常容易,例如在游戏中进行抓取。我们看到了如何在世界中动态放置其他数字对象;让我们为模块做同样的事情,但有所变化。

将模块放置在房间中

在我们的 Boss 房间战斗中,我们的主要目标,除了保持生存之外,就是收集水晶模块以恢复控制台的功能并给反应堆供电,以驱逐邪恶的植物实体。所以,这里又有一场收集和管理模块的挑战!然而,让我们增加收集和管理模块的挑战性。

在 MR 游戏中,通过让这些物体在房间内移动,可以使收集物体变得更加有趣。玩家将需要依靠他们的空间意识和时间技巧,这引入了一个更具动态性和新颖性的挑战,要求他们探索房间。由于物体不仅对玩家的动作做出反应,还对空间的物理性做出反应,这也加深了 MR 游戏体验的沉浸感。

如果收藏品能够以某种方式漂浮,模块也可以为游戏的故事或美学主题做出贡献。事实上,水晶模块具有一种奇特的异世界属性——重力不会影响它们,但力会。 ¯_(ツ)_/¯

在设定了这样的背景之后,让我们首先创建三个所需的模块,然后在游戏开始时将它们实例化到房间中。

创建独特的模块变体

我们将创建的三个模块预制件变体可以在图 14**.7中看到,并且每个都将有一个唯一的标识符——模块的 ID 将在我们配置控制台插槽时发挥作用。

创建预制件变体有多种方法,但这次,我们将使用以下步骤来创建每个独特的模块:

  1. 通过在项目窗口中右键单击Module并选择创建 | 预制件变体来创建Module的预制件变体。

  2. 将其命名为Module Variant A(后续的变体将是BC)。

  3. 双击Module Variant A以在预制件模式中打开它。

  4. Module组件中,将其设置为A(随后是BC)。(Module脚本作为导入的基本资产的一部分提供。)

  5. Assets/Materials文件夹中分配Module_A材质。你可以通过将材质从项目窗口拖动到场景视图中可见的模型上轻松完成此操作。

  6. Rigidbody组件上设置为false

重复这些步骤为模块BC创建变体。记住,你对预制件变体所做的任何编辑,例如修改属性值或添加/删除组件,都将成为基本预制件的覆盖,因此你不希望应用这些覆盖,否则你将应用它们到基本预制件资产,而我们不希望那样!

XR 交互所需组件

XR 抓取交互组件添加到我们的对象中将自动添加一个具有默认值的刚体组件。

三种独特的模块,检查!现在,我们可以添加必要的代码到我们的游戏管理器中,在游戏开始时生成模块。

生成模块以使事物开始移动

重新发明轮子来产生另一个预制件进入场景是没有意义的;我们可以依赖我们已经编码的工作(我们应该通常这样做)。然而,我们将稍微改变生成过程,因为我们不希望在平面对象周围实例化对象。我们希望在世界中有一个更随意的位置,但仍然与玩家位置相关。

打开GameManager脚本进行编辑。让我们首先创建一个序列化的私有成员变量,我们可以将所有需要生成到场景中的模块变体分配到检查器中:

[SerializeField]
private GameObject[] _prefabModules;

现在,我们可以为SpawnPrefab()方法创建另一个方法重载:

private void SpawnPrefab(GameObject[] prefabs,
    Vector3 playerOffset,
    Vector3 forceDirection, float force)
{
    var playerTransform = Camera.main.transform;
    var spawnPosition = new Vector3(
        playerTransform.position.x
            + (playerTransform.right * playerOffset.x).x,
        playerTransform.position.y
            + (playerTransform.up * playerOffset.y).y,
        playerTransform.position.z
            + (playerTransform.forward * playerOffset.z).z
    );
    foreach (var item in prefabs)
    {
        var module = Instantiate(item,
            spawnPosition, Quaternion.identity);
    }
}

在方法签名中,我们将prefabs参数改为数组,GameObject[] prefabs,以接受任何数量的预制件进行生成,然后添加了forceDirectionforce参数,我们将使用这些参数在实例化后对对象施加力。

与这种预制件生成方法的主要区别在于,我们使用foreach语句迭代预制件数组,以确保每个都实例化。

现在,我们可以将SpawnPrefab()的调用添加到模块生成中。为了简单起见,我们将将其附加到控制台生成。在switch语句的地面平面分类case语句中添加以下对SpawnPrefab()的调用:

    case PlaneClassification.Floor:
        if (!_hasSpawnedPrefab_Console)
        {
            …
            SpawnPrefab(_prefabModules,
                new Vector3(0f, 1.5f, 0.8f),
                Vector3.up, 0.05f);
        }
        break;

传入一个新的向量位置作为从玩家位置(世界空间)的偏移量,Vector3.up是力的方向,0.05f是实例化模块时施加到模块上的力。很简单。

好的,我们已经讨论了向水晶模块添加力以使它们在房间内漂浮的话题……现在是时候实现它了!将以下行添加到SpawnPrefab()方法的这个迭代中:

    // Existing line in foreach body.
    var module = Instantiate(item,
        spawnPosition, Quaternion.identity);
    // Added lines.
    if (forceDirection != Vector3.zero || force != 0)
    {
        if (module.TryGetComponent<Rigidbody>(out var rb))
        {
            ApplyForce(rb);
        }
    }

如果在SpawnPrefab()调用中传递了非零的力方向和数量参数,我们尝试获取实例化预制体的Rigidbody组件。如果成功检索到Rigidbody组件引用,我们调用ApplyForce()并将它传递进去。

剩下的就是将ApplyForce()方法作为局部函数添加,以发挥其物理魔法:

    void ApplyForce(Rigidbody rb)
    {
        rb.AddForce(forceDirection * force, ForceMode.Impulse);
        var torqueMultiplier = 3f;
        var randomRotation = new Vector3(
            Random.Range(-1f, 1f),
            Random.Range(-1f, 1f),
            Random.Range(-1f, 1f)).normalized
                * (force * torqueMultiplier);
        rb.AddTorque(randomRotation, ForceMode.Impulse);
    }

我们在这里利用的物理 API 方法是rb.AddForce()rb.AddTorque(),使用Impulse力模式施加力。

额外阅读 | Unity 文档

Rigidbody.AddForce: docs.unity3d.com/2022.3/Documentation/ScriptReference/Rigidbody.AddForce.xhtml

Rigidbody.AddTorque: docs.unity3d.com/2022.3/Documentation/ScriptReference/Rigidbody.AddTorque.xhtml

保存脚本并将所有水晶模块预制体变体分配给GameManagerPrefab Modules字段。进行游戏测试并调整模块的生成位置,直到满意。享受追逐它们的乐趣!

应用冲击力

提供的模块预制体中已添加了一个ImpactApplyForce脚本,当模块与任何具有碰撞器的其他对象发生碰撞时,该脚本将对模块施加相反的力。结合分配给碰撞器的非常弹性的物理材料,这试图使模块在房间内持续移动。

在本节中,我们将晶模块悬浮在房间周围,为 Boss 房间战斗机制添加了第一个挑战。模块挑战的第二部分与将它们正确插入控制台插槽有关。在下一节中,我们将执行必要的 XR 交互配置。

使模块插槽可交互

为了拥有能够协同工作以创建一个直观的系统,该系统模仿现实世界中事物的工作方式,我们使用了一个XR Grab Interactable对象和一个XR Socket Interactor对象——我们有一个交互对象和一个交互者。抓取交互者允许玩家拾取并与对象交互,而插座交互者提供了放置对象的位置。这两个组件之间的握手使得用户与对象交互更加容易,并在虚拟或 MR 环境中提供了更加流畅和沉浸式的体验。

这意味着我们将为每个控制台槽位配置一个插槽交互器。请从Assets/Prefabs文件夹中打开Console Prefab,并在预制模式中操作。为ConsoleSlots对象下的Slot ASlot BSlot C对象添加XR Socket Interactor组件。对象层次结构可以在以下屏幕截图中看到:

图 14.13 – 控制台槽位配置

图 14.13 – 控制台槽位配置

在前面的屏幕截图中还可以看到一个附加变换对象;每个槽位都有一个与其关联的对象,命名为Socket Attach。对于添加到槽位对象的每个插槽交互器,将附加对象分配给交互器的附加变换字段(就像我们对抓取交互器所做的那样)。

我们还希望确保只有模块被插入到控制台上的槽位中;我们可以对此采取措施。我们可以使用XR Grab InteractableXR Socket InteractorInteraction Layer Mask属性。

无论从哪里开始都无关紧要,但首先添加一个Module交互层是至关重要的。您可以通过点击下拉菜单并选择添加层…(在底部),然后返回组件并选择,然后为每个选择模块,从任何交互层掩码字段中完成此操作。

使用资产设置交互层

或者,找到交互层资产在Assets/XRI/Settings/Resources/InteractionLayerSettings,添加模块层,然后返回到组件并设置层。

槽位配置的最后部分是槽位已经配置了ConsoleSlot组件,类似于我们配置模块的Module组件;确保ABC再次正确配置。

谈到ConsoleSlot组件,让我们更深入地看看代码。它不仅仅是槽位 ID——它还能检测模块的插入或移除。这使得它能够通知父控制台控制器何时与特定槽位交互,然后相应地做出反应:

public class ConsoleSlot : MonoBehaviour
{
    [SerializeField] private char _slotID;
    private ConsoleController _controller;
    private XRSocketInteractor _socketInteractor;
    private void Awake()
    {
        _controller =
            GetComponentInParent<ConsoleController>();
        _socketInteractor = GetComponent<XRSocketInteractor>();
        _socketInteractor.selectEntered.
            AddListener(HandleModuleInserted);
        _socketInteractor.selectExited.
            AddListener(HandleModuleRemoved);
    }
}

然后,在Awake()中声明我们的变量,一旦我们有了所需组件的引用,我们注册监听器以响应插槽交互器的selectEnteredselectExited事件,分别处理插入和移除模块。

这里是处理方法声明:

private char _moduleID;
private void HandleModuleInserted(SelectEnterEventArgs arg)
{
    _moduleID = arg.interactableObject.transform.
        GetComponent<Module>().ModuleID;
    if (!char.IsWhiteSpace(_moduleID))
    {
        _controller.InsertModule(_slotID, _moduleID);
    }
}
private void HandleModuleRemoved(SelectExitEventArgs arg)
    => _controller.ResetSlots();

我们首先获取插入模块的 ID(记住,由于交互层的掩码分配,只有模块可以插入)。然后,当模块被移除时,我们调用ConsoleController实例的方法,对于正在插入的模块,调用InsertModule(),或者简单地重置槽位,调用ResetSlots()

您可能正在考虑让 ConsoleController 订阅一个公开的 ConsoleSlot 事件。由于有三个插槽,让每个插槽处理自己的交互(对象应负责自己的状态)并通知控制器(通过传递其 ID 和模块的 ID)会更有效率。这是一种更简化的方法。

奖励活动

随意 翻转脚本 并尝试使用控制台控制器监听所有三个插槽的事件,以比较所需的代码差异。

现在,您应该能够通过拿起一个晶体模块并将其放入任何插槽来测试控制台插槽交互。有趣!

还有更多乐趣等着我们……让我们配置那把激光手枪,为我们提供一些对抗潜入悬浮机器人的保护。

配置激光枪

可交互枪对象的配置基本上与晶体模块相同;我们已经在 图 14.11 中看到了如何配置附加变换。但现在,我们将添加一个当拉动扳机时的射击二级动作。

使用 XR 交互事件实现射击

我们只想在我们实际抓住枪时触发射击,所以这次我们不会依赖于可重用的 OnButtonPress 组件。相反,我们将使用 XR Grab Interactable 组件,特别是 ActivatedActivated 在选择交互对象的交互者发送命令以激活交互对象时被调用——这正是我们所需要的。

额外阅读 | 捕捉交互对象

XRI 示例中提供了捕捉交互的基本和高级示例:github.com/Unity-Technologies/XR-Interaction-Toolkit-Examples/blob/main/Documentation/GrabInteractables.md

要设置 Gun 预制件,请按照以下步骤操作:

  1. 可以直接修改提供的 Gun 预制件,或者创建一个预制件变体以与之配合使用。

  2. 预制件模式 下打开预制件。

  3. 在根对象上添加 XR Grab Interactable 组件。

    1. Attach 对象分配给 Activated,分配 Gun.Shoot 函数。

图 14.14 – XR 捕捉交互事件激活分配

图 14.14 – XR 捕捉交互事件激活分配

  1. 对于 Rigidbody 组件,使用以下属性值(枪将在玩家松开握把的空中漂浮;Kryk’zylx 军事技术确实先进!):

    • false

    • true

只需进行这些配置,就可以将 Gun 预制件设置为可交互对象,玩家可以拿起并射击。砰砰!

枪声音效

我们还添加了射击的音效 FX,由AudioManagerAudioPlayerSFX3D音频播放组件提供。因此,将音频管理器添加到 Boss 房间场景中,创建音频混音器和所需的混音器组,然后将混音器组分配给音频管理器。为了复习,请访问第十二章。

所有负责在调用Shoot()方法时使枪发射激光束的代码都完全包含在Gun类中。它针对这个游戏中特定用例的单职责,代码简单直接,所以我感觉没有必要在这里过度复杂化架构。

代码架构哲学

当你有一把锤子时,一切看起来都像钉子”是一个我们可以应用到软件开发中常见陷阱的隐喻。人们可能会使用他们最喜欢的方案来解决他们遇到的每一个问题,无意中导致代码过度复杂和低效。为每个问题或情况选择最合适的解决方案很重要,而不是仅仅依赖于软件教条。

有时候,你只需要拥抱简单。知道何时以及何时不这样做被称为经验。

当你检查Gun脚本时,你会看到我们只是使用Physics.Raycast()LineRenderer,两个用于绘制线的点设置为射击点和枪的射击范围末端,或者射线击中可损坏对象的点(通过使用层掩码过滤)。

小贴士

Unity 提供了一个专门的XRLineRenderer组件,用于生成与常规LineRenderer组件相比优化的 XR 线渲染。它还能够在非常低成本的发光效果,这对于激光束来说太棒了!

XR 线渲染器:github.com/Unity-Technologies/XRLineRenderer

如果射线投射击中了一个可损坏的对象,我们将在调用TakeDamage()时传递_damageAmount中指定的伤害量。这是我们如何在我们的健康系统中工作的方式,从第八章(是的,可重用系统是胜利的关键!),到对具有健康(即添加了HealthSystem组件)的对象造成伤害。

现在我们已经有一个功能性的自卫武器,让我们把它放到玩家的手中。

生成枪的位置

好吧,这将会是一件小菜一碟!我们已经擅长将虚拟对象生成到房间中。我们将重用大部分已经用于生成对象的部分,因为我们将在玩家附近生成枪,在他们右手边(抱歉,左撇子们)。

首先,打开GameManager脚本,并添加一个序列化私有变量的声明,_prefabGun,以保存对Gun Prefab 的引用:

[SerializeField] private GameObject _prefabGun;

我们已经使用Console Prefab 的生成部分来生成其他对象,所以让我们将枪的实例化添加到它上面:

    case PlaneClassification.Floor:
        if (!_hasSpawnedPrefab_Console)
        {
            …
            SpawnPrefab(_prefabGun,
                new Vector3(0.5f, 1.2f, 0.15f));
        }
        break;

注意这次,当我们调用SpawnPrefab()时,我们有一个另一个新的方法签名。这非常类似于我们用来生成模块的方法重载,除了我们将生成单个 Prefab,并且不会在指定方向上对其应用任何物理力。

在这个版本中,让我们为生成单个 Prefab 创建一个新的方法重载。这个方法将简单地传递值到我们之前的SpawnPrefab()方法,该方法需要一个 Prefab 数组。因此,我们首先需要将单个 Prefab 添加到一个单个项目 数组中:

private void SpawnPrefab(GameObject prefab, Vector3 playerOffset)
    => SpawnPrefab(new GameObject[] { prefab },
        playerOffset, Vector3.zero, 0f);

注意,我们预设了forceDirectionforce参数值为零,以确保不会对生成的对象应用任何物理力。

保存脚本,将Gun分配给GameManagerPrefab Gun字段,保存场景,并使用所有元素进行游戏测试,以开始我们的游戏。

在本节中,我们学习了如何为玩家创建可交互的 Prefab 变体,并将模块收集并放置到控制台上的插槽中,从而增强玩家在环境中的参与度。我们还学习了如何实现枪械的射击作为玩家持有的对象的次要激活动作。现在,随着射击能力的加入,让我们看看我们如何将所有这些元素与游戏玩法机制结合起来。

实现 Boss 房间机制

在我们高潮的 Boss 房间战斗中,玩家将在防御虚拟敌人——被邪恶植物实体如此粗鲁感染的巡逻悬浮机器人——的同时收集晶体模块谜题碎片。凭借 MR 技术提供的物理空间探索和与数字对象的交互式游戏玩法,我们的玩家将面临战略思考的挑战,同时进行身体上的努力。这种创新且新颖的 Boss 房间机制方法推动了传统视频游戏设计的界限,我非常期待看到这项技术继续成熟并打破更多界限!

本章致力于介绍 Unity 技术,这些技术使游戏开发者和创作者能够快速制作出令人兴奋且沉浸式的 MR 体验,供玩家热情消费和享受。因此,Boss 房间机制的概念将在更广泛的意义上讨论,我们只会在需要额外说明的地方深入细节。

在本节中,我们将通过实现解决谜题所需的逻辑来最终确定谜题机制,并激活控制台。我们还将设置敌人机器人以生成并向玩家移动,以及它们的射击行为。最后,我们将通过更新游戏状态来完成游戏循环。

因此,首先,关于晶体模块,让我们来处理解决控制台谜题的问题。

解决晶体模块谜题

如前所述,必须收集晶体模块并将它们放回控制台。每个控制台槽位和模块都有相应的 ID,但它们必须放置的顺序对我们来说并不明显——控制台只显示一些乱码。让我们看看控制台控制器脚本,以设置模块的正确组合并确定它们何时成功插入。

要成功完成恢复控制台和重新激活反应堆的任务,模块必须按正确顺序插入,从第一个开始——你不能随意放置它们以得到正确的顺序(这正是这项技术的工作方式;我不认为你应该为此责怪我)。这使得谜题对玩家更具挑战性,因为你必须在解决这个问题的同时抵御敌人悬浮机器人!

检查ConsoleController脚本,我们首先看到解决方案代码作为一个序列化的私有string变量_consoleCode,因此我们可以在检查器中随时检查和设置它:

public class ConsoleController : MonoBehaviour
{
    [SerializeField] private string _consoleCode = "CBA";
    …
    public void InsertModule(char slotID, char moduleID)
    {
        // TODO: Solve module sequence logic.
        ConsoleEnergized();
    }
}

InsertModule()代码将有效地处理按特定顺序插入的模块,对错误放置提供反馈,并对正确序列发出成功信号。模块插入和解决逻辑应按以下方式执行:

  1. 使用当前的槽位索引,从零开始,检查槽位顺序与插入的模块是否一致。

  2. 当提供下一个正确的模块 ID 时,增加槽位索引;否则,重置槽位(索引恢复为零)。

  3. 检查代码完成情况并相应更新 UI 或触发事件。

奖励挑战

ConsoleController类中,根据上述步骤,首先使用`InsertModule(_slotID, _moduleID)**方法自己编写解决谜题的逻辑,以正确顺序将模块插入控制台。

你可以从 GitHub 仓库的ConsoleController脚本中获取完成的控制台谜题代码:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch14/Code-Assets

这些步骤旨在确保模块按正确顺序插入,并且模块的 ID 与当前槽位索引预期的 ID 相匹配。如果所有模块都正确插入,则激活控制台,进度在控制台屏幕(UI)上显示。

世界空间 UI

对于上述控制台屏幕(UI)中提到的步骤 3 的更新 UI,控制台 Prefab 包括一个世界空间uGUI Canvas。世界空间 UI 是一种用户界面,它出现在游戏的 3D 世界中,而不是作为屏幕叠加。它被渲染在一个可以定位、旋转和缩放的画布上,就像场景中的任何其他 3D 对象一样。开发者使用它们在游戏世界中创建交互式元素,例如控制面板、信息显示或交互式菜单。

TextMeshProUGUI and .text = "string"; either approach is acceptable (i.e., developer style).

当玩家恢复控制台后,我们可以通过事件通知反应堆激活它。还有什么比我们自己的全局事件系统更好的实现方式呢?请参阅第九章以刷新事件系统的设置和使用(只需确保在场景中某处添加EventSystem组件)。但你可以看到我们如何在ConsoleEnergized()方法中触发此事件:

public void ConsoleEnergized()
{
    _consoleScreen.SetText(MSG_SOLVED);
    EventSystem.Instance.TriggerEvent(
        EventConstants.OnConsoleEnergized, true);
}

然后,在Reactor脚本中,我们通过简单地交换网格渲染器的材质到一个具有发射属性的材料来响应事件,以视觉上指示它已经被激活(这可以不仅仅是简单的材质变化;想想粒子系统、VFX 图形或自定义的着色器图形):

public class Reactor : MonoBehaviour
{
    …
    private void OnEnable()
        => EventSystem.Instance.AddListener<bool>(
            EventConstants.OnConsoleEnergized, Energize);
    public void Energize(bool energize)
        => _renderer.material = _matEnergized;
}

现在问题已经解决,可以实施谜题机制,让我们继续到生成悬浮机器人敌人的波次……因为它们应该挡住我们的路,使解决谜题变得更加具有挑战性!

生成敌人

我们有一个挑战。现在,让我们让玩家更加困难!在 Boss 房间遭遇中生成敌人的波次大大增加了玩家的挑战性,并提升了游戏体验的强度。生成无数敌人的方法不仅提高了克服挑战的兴奋感和满足感,而且加深了玩家对战斗机制的参与度。

Boss 房间起始资产包中提供的Corridor预制体包括用于生成器和走廊末端门口目标位置的的游戏对象,悬浮机器人将前往那里。因此,让我们通过添加EnemySpawner组件并配置其属性来完成设置。

图 14.15 – 敌人生成组件值

图 14.15 – 敌人生成组件值

关于提供的起始资产的说明

提供的起始资产包中包含的已导入的资产已经使用前几章中的技术创建。你会发现设计模式、代码架构以及所有使用的组件都将很熟悉。因此,我们不会再次涵盖所有内容。然而,我建议花些时间检查用于配置这些预制体的组件,特别是敌人悬浮机器人 A 射击 1,因为它提供了最显著的例子。

你可能已经能够处理添加生成器组件并配置它。如果是这样,恭喜!不过,仅供参考,以下是我们可以遵循的配置步骤:

  1. 在预制模式中,打开Assets/Prefabs文件夹中的Corridor预制体。

  2. EnemySpawner脚本添加到SpawnerLocation子对象中。我们在这里添加脚本是因为我们将使用对象的变换前向作为生成悬浮机器人的移动方向。

  3. 分配组件的字段值:

    • 2(起始值 – 这是检查当前实例化的机器人是否被销毁的时间间隔)。

    • (2, 6)(从实例化机器人到将其送入走廊的时间段)。

  4. 退出预制件模式并保存更改。

现在,当我们进行构建和运行练习时,我们将看到敌人悬浮机器人被摧毁后会反复生成,以威胁的方式向我们移动,当它们进入射程时,它们将开始射击(当然使用我们的池化射击设置)。

我们还必须确保一些配置仍然正确,因为从'.unitypackage'导入资产并不等同于拥有一个起始 Unity 项目。例如,标签AI 导航设置和构建设置并不会随着导入的资产一起导入。

确保以下层被添加到项目中(投射物可伤害的)。

现在,在Assets/Prefabs文件夹中,对以下预制件对象进行以下分配:

  • 等离子球预制件:选择它,在检查器中,使用投射物

此外,在编辑 | 项目设置… | 物理设置页面的底部使用层碰撞矩阵来禁用可放置表面投射物之间的碰撞——我们不希望两者之间有物理交互。

  • 枪械预制件:选择它,在检查器中,对于枪械组件,设置可伤害的

  • 敌人悬浮机器人 A 射击 1:选择它,在检查器中,设置可伤害的。你可能会认出这种层分配与前面枪械的伤害掩码分配的相关性。

完整代码

为了参考,本节完整的代码和项目设置可以在本书 GitHub 仓库中提供的 Unity 项目文件中找到:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch14/Unity-Project

这次,玩游戏将会有交互对象和虚拟房间表面之间的适当交互,枪械会损坏并摧毁悬浮机器人,而机器人的投射武器,等离子球,会完全接近玩家——你。剩下要做的就是完成胜利和失败条件的游戏循环……使其成为一个真正的游戏挑战。

完成游戏循环

在游戏设计中,完成一个具有明确胜利或失败条件的游戏循环是基本的。胜利条件通常以克服最终挑战(如我们的 Boss 房间)结束,并为获胜的玩家带来巨大的成就感,而失败条件,如健康值耗尽,则增加了游戏的挑战性。这些条件之间脆弱的平衡对于确保体验引人入胜、有奖励且公平(就玩家在游戏中的投入时间而言)至关重要。

我不会宣称我已经在创建的 Boss 战房间战斗中实现了完美的,甚至接近完美的游戏平衡。这仅仅是创建一个沉浸式、吸引人、有趣的 MR 游戏体验的基础。

让我们通过查看如何将创建的游戏中的胜利和失败条件结合起来来完成 Boss 战房间 MR 游戏的开发,先从失败开始。

失败战斗

当谈到玩电子游戏时,失败与胜利一样常见——任何玩过电子游戏的人都有过失败的经历。对于我们的 MRBoss 战房间战斗,当敌方悬浮机器人向我们射击时,明显的游戏失败场景是我们健康值耗尽,无法继续游戏。所以,这就是我们要做的。

为了在我们的场景中完成设置以支持玩家健康值耗尽导致的失败,我们首先需要确保场景中有一个Player对象——一个标记为Player的 GameObject,并添加了Player脚本。按照以下步骤完成玩家设置:

  1. Player预制体添加为MR交互设置的子对象。

  2. 确保它被标记为Player。如果还没有添加,现在就添加Player标记。

  3. 确保玩家对象(Player)的层级设置为Damageable

Player实现了健康系统接口IHaveHealth,因此也要确保敌人的PlasmaBall预制体的ProjectileDamage组件将Damage Mask设置为Damageable

Player类中,当玩家在遭受过多来自敌方悬浮机器人的等离子球打击后健康值完全耗尽时,会触发一个事件系统事件:

public class Player : MonoBehaviour, IHaveHealth
{
    …
    public void Died()
        => EventSystem.Instance.TriggerEvent(
            EventConstants.OnPlayerDied, true);
}

我们的GameManager类将监听事件,并通过设置用于失败的_isConditionMetLose条件变量来相应地做出反应。当事件传递的布尔值为true时,我们使用它来设置条件:

private void OnEnable()
{
    EventSystem.Instance.AddListener<bool>(
        EventConstants.OnPlayerDied, SetLoseCondition);}
}
private void SetLoseCondition(bool value)
    => _isConditionMetLose = value;

我们可以使用单个事件来更新 UI,关闭穿透,或变为全黑。然而,GameManager将根据条件变化来结束游戏:

private void Update()
{
    switch (_currentState)
    {
        case State.Playing:
            if (_isConditionMetLose || _isConditionMetWin)
                ChangeState(State.GameOver);
            break;
    }
}

状态机(FSM)重构

跟踪我们的游戏状态的最佳解决方案当然是状态模式。我们之前在EnemyController类中使用了一个简单的有限状态机(FSM);你也可以在这里找到它。基于枚举的 FSM 的重构超出了我为本书计划的范围,所以我将重构留给了你,但提供了一个使用 GitHub 上可用的UnityHFSM(Unity 分层有限状态机)包的示例:github.com/Inspiaaa/UnityHFSM?tab=readme-ov-file#simple-state-machine

在书中 GitHub 仓库提供的项目文件中,检查并评估重构后的Assets/Scripts/Refactored/GameManager_HFSM脚本,与GameManager中的基于枚举的switch语句进行比较,并在你的项目中实现它。

失败的战斗并不有趣,但如果你不成功……有句俗语说“尝试,尝试再尝试?”我相信如果你这样做,你将会赢。让我们看看胜利条件是如何连接的;它实际上就像失败条件一样——全局事件系统真的让这件事变得简单。

赢得战斗

对于我们的 MR 老板房间战斗,以敌方悬浮机器人为主要对手,胜利的情景显然是击败所有波次的敌人,对吧?不。这里我们并没有为此打下基础;正如你所知,当我们解决控制台插槽的谜题并重新激活反应堆时,我们才赢得游戏。

由于一切都已经就绪,可以通过全局事件系统事件重新激活反应堆,我们只需向GameManager中的OnConsoleEnergized事件添加另一个监听器来设置胜利条件变量,就像我们为失败所做的那样:

private void OnEnable()
{
    …
    EventSystem.Instance.AddListener<bool>(
        EventConstants.OnConsoleEnergized, SetWinCondition);
}
private void SetWinCondition(bool value)
    => _isConditionMetWin = value;

就这样!将游戏状态设置为State.GameOver处理其余部分!

通过精心设置游戏状态并定义清晰的胜利路径,我们创造了一个有奖的游戏循环,挑战玩家制定策略来克服游戏挑战,并在他们的 MR 老板房间战斗中最终取得成功。

在本节中,我们探讨了 MR 游戏中老板房间的基本机制。我们学习了如何集成和解决玩家在压力下必须解决的谜题机制,并介绍了如何生成装备有投射武器敌人的波次。了解玩家的激光手枪配置允许对悬浮机器人造成伤害并摧毁它们。然后,我们进一步了解了如何将游戏管理器状态机中的胜利和失败条件连接起来,以完成游戏设计,强调了解决谜题和生存之间的战略平衡。

概述

在本章中,我们探讨了 Unity MR 技术,这些技术简化了开发过程,并赋予开发者创建沉浸式 MR 体验的能力。我们深入研究了制作吸引人的老板房间的设计原则,同时使用 Quest 设备设置我们的物理空间,并配置 Unity 的 MR 模板以满足我们的需求。此外,我们还了解了如何利用 AR 平面和组件来操纵 AR 视觉元素,并在我们的环境中动态生成虚拟对象。

此外,我们磨练了创建交互式 Prefab 变体和集成射击机制以支持核心游戏玩法的能力。通过允许玩家收集并将模块放入控制台插槽中,我们加深了游戏世界的 XR 交互潜力。将所有各种 MR 元素结合起来,我们制作了一个引人入胜的 MR 游戏体验。

在下一章中,我们将通过探索“游戏即服务”(Games as a Service)、包括 Unity DevOps 和 LiveOps,了解运营已发布游戏的意义。我们将探讨如何通过强大的源代码管理和吸引玩家参与游戏经济的策略来保护你在项目中的投资,并简要介绍平台分发的要点。这个概述将为你提供管理和维护、扩展以及分发你完成的游戏所需的工具和知识。

第十五章:完成具有商业可行性的游戏

第十四章中,我们完成了从创建 3D第一人称射击FPS)游戏开始,到设计一个真正沉浸式的 Boss 房间遭遇战——在我们的房间里——的过程。为了实现这一点,我们利用了Unity XR 交互工具包XRI)的强大功能和灵活性,并将 3D 资产与之前工作中可重复使用的组件和系统相结合。

我们还探讨了额外的 Unity 特定 MR 技术,如 AR Foundation,我们使用它来创建使用玩家物理环境中的检测到的平面来实现的沉浸式体验。我们使用墙壁、地板和桌子来生成虚拟对象并创建虚拟游戏环境。我们还设计了 Boss 房间,设置了 Quest 设备,并定制了 Unity 的混合现实模板。此外,我们还制作了交互模块预制体变体,以及谜题和射击机制,以加深玩家的体验。

在本章中,我们将探讨游戏即服务GaaS)、Unity DevOps 和 LiveOps 资源,以及通过版本控制系统VCS)进行源代码管理。我们还将讨论游戏内经济以实现商业化,并以探索不同的店面平台来分发我们的游戏结束,包括实现Unity 游戏服务UGS)和发布的示例。

在本章中,我们将涵盖以下主要主题:

  • 介绍 GaaS – UGS

  • 保护您的投资!使用 Unity 版本控制进行源代码管理

  • 通过游戏内经济吸引玩家

  • 将您的游戏推向市场!平台分发

  • 实现 UGS 和发布

到本章结束时,您将对自己的项目开发生命周期在生产和发布阶段的管理和安全性充满信心。您还将了解如何有效地分发具有商业可行性的完成游戏。

技术要求

要跟随本章内容,您必须安装 Unity Hub 和 2022 版本的 Unity 编辑器。您还需要能够使用 Unity ID 账户登录 Unity 云服务。

您可以从 GitHub 下载完整项目,链接为github.com/PacktPublishing/Unity-2022-by-Example

介绍 GaaS – UGS

最重要的教训是完成您的游戏。除非您完成游戏,否则您没有作为业余爱好者毕业的潜力,也没有作为独立游戏开发者自给自足的职业前景。完成的游戏也证明了您作为游戏开发者精细调校的技能,并且是您想要加入的任何工作室的展示品。

因此,完成游戏开发的过程可以归结为将游戏制作过程更像是一种商业行为。这就是GaaS,将开发和运营策略相结合,为你在游戏制作商业中的玩家创造动态、引人注目、商业化和持续发展的游戏体验。

当使用 GaaS 模型开发游戏时,重点在生产的后期阶段更多地转向长期参与和货币化策略。如果我们希望通过游戏的发布最大化商业可行性——以实现持续的收益流——我们需要采用持续的内容交付、社交互动和数据分析来指导我们的决策过程。

Unity 不仅为你的游戏开发提供游戏引擎作为基础,还提供了你应该利用的云服务,以增加你成功的机会。特别是,Unity Cloud提供了一系列高效且节省时间的工具和工作流程,这些工具和工作流程在很大程度上有助于确保你的项目并发布具有商业可行性的游戏。

额外阅读 | Unity Cloud

Unity Cloud: cloud.unity.com/

Unity Cloud 提供了对UGS的访问,他们将其描述为“一个为实时游戏提供完整服务生态系统的服务。”考虑到服务提供的广泛性和易于集成到我们的游戏项目中,我们从中受益于采用一个或多个服务也就不足为奇了。

下面是 UGS 提供的一些快速概述:

  • 基础游戏系统:如身份验证和玩家账户等系统,使云存档和平台跨游戏成为可能,内容管理使游戏内容的部署和动态更新成为可能。同时,多人游戏托管可以将你的游戏扩展到数百万玩家(就像Among UsApex Legends所做的那样)。最后,版本控制和构建自动化构成了 Unity 的DevOps功能。

  • 玩家参与:如分析等特性使数据驱动的决策最大化玩家体验。具有 A/B 测试的参与工具可以让玩家最享受的功能浮出水面,而语音和文本聊天等通讯则可以建立社区。别忘了至关重要的崩溃报告,以便知道你的游戏在哪里崩溃,这样你就可以快速响应并提供修复。这一组服务是 Unity 的LiveOps功能。

  • 游戏增长:通过用户获取来增长你的游戏,并通过游戏内广告、广告中介和在应用内购买功能来驱动货币化收入,以建立你的游戏经济。

额外阅读 | UGS

在一个平台上从 DevOps 到 LiveOps:unity.com/solutions/gaming-services

上述 UGS 主题可以写成一整本书,所以我们将专注于本章中对于游戏开发生产至关重要的核心服务,以及旨在商业发布的部分。这意味着我们将在本节中关注 Unity DevOpsUnity LiveOps,以及下一节中游戏经济和发布的基础知识。

GaaS 的成功取决于 DevOps 和 LiveOps 之间的互补作用。DevOps 负责高效更新游戏。同时,LiveOps 决定包含哪些更新以及如何吸引玩家(根据玩家参与度决定游戏中要增强或削弱的内容是一项有趣的挑战!)。这两个工具共同工作,创造一个持续改进的循环(即 Kaizen),随着时间的推移,通过更新来维持和提升游戏。

Kaizen(改善)

Kaizen 是中日混成的词汇,意为“改善”。它关乎持续改进。在游戏开发中,这可能意味着每周甚至每天对机制、平衡、剧情或甚至编码实践进行小幅度调整。这有助于识别问题,以便我们可以在它们成为更严重问题之前及时进行调整。

让我们更详细地探讨这些核心服务,从 DevOps 开始。

介绍 Unity DevOps

DevOps 是由 开发Dev)和 运维Ops)实践融合而成的,在软件开发中扮演着至关重要的角色,尤其是在 GaaS 的应用中尤其有价值。Unity DevOps 工具促进了加速的开发和部署周期——贯穿整个生产生命周期——并有助于确保可靠的游戏发布。

通过检查可以使用 Unity DevOps 工具解决的问题,可以帮助我们更好地理解其优势。服务提供解决游戏开发中的基本挑战,包括以下关键方面:

  • 管理资产(复杂和/或大型):您可以放心,无论资产的结构大小或复杂程度如何,您的资产都将被维护和存档在项目仓库中。这适用于独立工作或与团队协作。

  • 管理代码库变更:除了管理资产外,通过版本历史记录跟踪变更将确保质量得到保持,并且工作进度永远不会丢失。

  • 简化构建和部署:自动化游戏可执行文件的构建和部署过程可以显著加快更新分发速度——无论是为了修复错误还是提高玩家参与度,都能更快地将更新传递给玩家。

阅读更多 | Unity DevOps

Unity DevOps: unity.com/products/unity-devops

Unity DevOps 提供了两个具体的服务:

  • Unity 版本控制:游戏开发团队和独立开发者可以通过使用与 Unity 紧密集成的专用版本控制系统来克服项目持续性的挑战。Unity 版本控制提供本地和私有云代码仓库,为程序员、艺术家和创作者提供了一个可扩展的协作平台。

重要提示

你可能想知道,尽管本书的项目是通过每章开头提供的 GitHub 链接共享的,为什么我建议使用 Unity 版本控制来处理我们的游戏开发项目。原因很简单:GitHub 提供公共仓库,这通常用于开源项目,而 Unity 版本控制不提供此功能。

  • Unity 构建自动化:一个可定制的构建管道,可以无缝集成到 Unity 版本控制或第三方仓库(例如,GitHubGitLabBitbucket)。它提供了强大的平台构建(例如,在 Windows、Mac、Linux、Android 和 iOS 上)和测试功能,从而简化了构建和部署游戏构建的过程。

Unity DevOps 服务远不止这里提供的简要介绍,所以让我们更详细地探讨每个服务。我们将从 Unity 版本控制开始。

Unity 版本控制

Unity 版本控制(以前称为Plastic SCM)是一个可以无缝与 Unity 一起使用的现代版本控制系统。它提供定制的 Unity 版本控制功能,并减轻了许多与游戏开发相关的风险。据记录,您可以使用 Unity 版本控制与 Unity 以外的软件平台和项目一起使用。

附加阅读 | Unity 版本控制

Unity 版本控制:unity.com/solutions/version-control

那么,什么是版本控制?版本控制是一个跟踪文件随时间变化的系统。它使多个人能够协作同一组文件,并跟踪不同的文件版本。它还允许你在需要时回滚到之前的文件版本。

正如反复提到的,由于其重要性,使用 VCS 来避免在游戏开发项目中工作时的工作丢失至关重要。作为 DevOps 的核心支柱之一,版本控制还使团队协作更加容易,同时更容易解决多人协作同一代码库和资源时可能出现的冲突。

因此,Unity 版本控制提供了以下基本版本控制功能:

  • 高效管理大型资源:Unity 版本控制工作空间优化处理项目中大型二进制文件(如纹理图像和 3D 模型资源)。与其他依赖有问题的较大文件支持附加组件的 VCS 不同,Unity 版本控制确保高效存储和检索大型资源,而不会降低性能。

  • 通过分支和合并增强协作:Unity 版本控制提供了强大的功能,可以将实现的功能分支到隔离环境中,并在两个人对同一文件进行工作后合并更改。通过有效地管理并行或异步工作以减轻冲突,这为项目团队提供了更流畅的协作体验。

  • 云存档以实现项目持续性:大多数 VCS(版本控制系统)提供云存档,Unity 版本控制也是如此,这可以防止因本地系统故障而造成的损失——这种情况确实会发生!如果您的项目源代码不在一个以上的位置存在,那么它就不存在。哎呀!

  • 与 DevOps 管道集成:Unity 版本控制与任何游戏引擎或软件开发环境兼容,而不仅仅是 Unity。它集成了 DevOps 工具链、问题跟踪应用程序、团队沟通平台、IDE 等更多功能。

现在,让我们探索 Unity 构建自动化,它从 Unity 版本控制那里接手,是 Unity DevOps 的后半部分。

Unity 构建自动化

使用 Unity 构建自动化 工具,您可以自动化创建和部署跨平台构建的过程,这对于 DevOps 环境中的 GaaS(游戏即服务)至关重要——玩家对开发者的快速响应和高品质要求非常苛刻。尽管游戏市场有时竞争激烈,但您通常只有一次机会满足玩家的期望;否则,他们可能会毫不犹豫地放弃您的游戏。

构建自动化通过解决与个人和手动构建创建过程、构建分发的连贯性和效率相关的开发者挑战,补充了版本控制。版本控制和构建自动化的完美结合为管理游戏开发工作流程提供了一个全面的解决方案,从代码管理到构建分发。

额外阅读 | Unity 构建自动化

Unity 构建自动化:unity.com/solutions/ci-cd

因此,Unity 构建自动化提供了以下基本构建功能:

  • 简化的集中构建流程:构建项目是一项耗时的工作,在开发机器上构建时,将开发者的系统占用率保持在 100% 的 CPU 利用率会导致其生产力下降。将构建和部署过程从本地系统卸载到基于云的管道可以消除瓶颈并提供标准化的流程,尤其是在针对多个平台时。

  • 简化的构建部署流程:自动化部署过程可以显著节省时间并消除手动复制和上传游戏构建到多个平台上的分发服务器的——容易出错的——过程,确保您的游戏二进制文件始终准备好发布给测试团队(对于独立开发者来说,这是您的朋友和家人)或玩家。

  • 实时集成和反馈:由于您的 QA 测试人员和玩家已经迅速收到了最新的游戏更新,因此在接收反馈和错误报告方面不应有任何延迟。这对于及时满足玩家需求并高效地关闭迭代更改的循环,在维护游戏质量的过程中至关重要。

额外阅读 | CI/CD

CI/CD代表持续集成持续部署(或交付)。它包括开发团队使用自动化代码集成和交付的最佳实践。

什么是 CI/CD?www.infoworld.com/article/3271126/what-is-cicd-continuous-integration-and-continuous-delivery-explained.xhtml

通过利用 Unity DevOps,游戏开发者可以简化他们的工作流程,加速开发周期,并向玩家提供更高品质的游戏体验。DevOps 负责您游戏开发旅程的生产阶段。现在,让我们回顾 Unity LiveOps,它负责发布阶段。

介绍 Unity LiveOps

LiveOps,源于“实时”(Live)和运营Ops)实践的融合,在游戏开发中也发挥着重要作用,尤其是在发布后阶段应用 GaaS 时。Unity LiveOps 专注于运行实时游戏所需的活动,如内容更新、社区管理和分析,所有这些都有助于保持游戏对玩家的相关性和吸引力。

检查 Unity LiveOps 工具可以解决的问题将帮助我们更好地理解其益处。服务提供解决运营实时游戏的基本挑战,包括以下关键方面:

  • 优化玩家参与度:为了提高玩家参与度和留存率,我们可以采用 LiveOps 策略,这些策略允许我们动态更新游戏内容,提供个性化的玩家体验,并响应实时分析数据以识别和降低流失率(即那些离开游戏且不再返回的玩家数量)。

  • 高效的游戏管理:您可以通过自动化事件管理来简化游戏运营,启用直接与玩家的沟通,并通过 A/B 测试快速迭代以更好地符合玩家期望。

  • 货币化策略:Unity LiveOps 提供分析应用内购买和管理游戏内广告的工具,使我们能够在保持玩家满意的同时,通过促销和定价优化收入。

额外阅读 | Unity LiveOps

使用 LiveOps 获取您需要的见解以获得更好的玩家体验:unity.com/solutions/gaming-services/player-insights

提升您的 LiveOps 策略以更好地保留玩家:unity.com/solutions/gaming-services/continuous-game-improvements

许多服务都包含在 Unity LiveOps 的范围内。这些服务共同赋予游戏开发者延长游戏生命周期的能力,所以我鼓励您查看前面提到的 Unity LiveOps 链接。

对于我们的目的,一些服务特别关键,对于直播发布的游戏的成功和持久性至关重要。因此,我们只关注几个维护参与度和确保高质量玩家体验的必要服务。这些服务可以在 Unity Cloud 的产品部分找到,如下面的截图所示:

图 15.1 – Unity 云产品列表

图 15.1 – Unity 云产品列表

我将按照 Unity Cloud 仪表板中显示的相同类别列出服务:

  • 游戏服务 | 账户

    • 玩家认证:提供安全且一致的玩家认证,包括匿名、平台特定或自定义选项,并支持跨设备和云存储
  • 游戏服务 | 参与度 分析

    • 分析

      • 分析:一套全面的统计分析功能,包括游戏性能留存率收入用户获取,以做出专注的数据驱动决策。提供定制仪表板和数据探索器,以便您深入挖掘玩家指标,并使用漏斗来识别玩家的旅程。

      • 管理:您可以使用事件管理器事件浏览器功能来管理和分析游戏中的事件(预定义和自定义)。合理放置的事件允许您根据玩家的参与度调整游戏策略。

    • 游戏覆盖:利用远程配置A/B 测试可以对游戏内容和游戏逻辑进行调整,以适应玩家的偏好(无需部署新的游戏版本),从而提高参与度和乐趣!

  • 游戏服务 | 崩溃报告

    • 云诊断:利用崩溃报告工具及时检测和解决影响游戏稳定性和性能的问题,为玩家提供无缝且无错误的游戏体验。

Unity 云定价

Unity DevOps 和 LiveOps 采用按需付费的消费模式。所有开发者都从一份慷慨的免费计划开始:unity.com/solutions/gaming-services/pricing

注意,认证游戏覆盖是 100%免费的。

通过将这些必要的 LiveOps 服务集成到我们的游戏中,我们可以努力提高玩家的参与度和满意度,这两者都有助于我们游戏的长久发展。完成我们的第一个商业游戏只是我们作为职业游戏开发者旅程的开始。我们希望使我们的下一款游戏能够实现,因此我们应该利用我们所能利用的所有工具来帮助我们实现这一目标。

在本节中,我们学习了如何结合 Unity DevOps 和 Unity LiveOps 技术,例如 Unity 版本控制和分析,为游戏开发者提供构建成功 GaaS 项目所需的基础工具。接下来,我们将学习如何配置 Unity 版本控制以保护我们的游戏项目代码和资源。

保护您的投资!使用 Unity 版本控制进行源代码管理

Unity 版本控制为 Unity 项目提供定制的版本控制解决方案,具有紧密的编辑器集成。这确保了我们的工作得到保护,并且对于团队环境,为团队成员提供流畅的协作体验。正如之前所述,在软件开发中,包括游戏开发,版本控制对于防止数据丢失和有效解决冲突是必不可少的。

在本节中,我们将探讨实施 Unity 版本控制的项目步骤。首先,我们将分解 Unity 版本控制针对程序员和艺术家定制的 VCS 体验方法。我们将从程序员开始。

为程序员定制的版本控制

Unity 版本控制在一个存储库中为艺术家和程序员提供定制的流程。它为开发者提供在集中式或分布式环境中工作的灵活性,具有全面的分支和合并功能。艺术家和设计师可以使用名为Gluon的简单、用户友好的工作区界面,利用直观的基于文件的资源工作流程来提高他们的创造力。

图 15.2显示了以开发者为中心的工作区界面,它为程序员提供所有提供的功能,例如挂起更改更改集分支分支浏览器

图 15.2 – Unity 版本控制工作区

图 15.2 – Unity 版本控制工作区

将此与图 15.3中的更简单、更直接、以艺术家为中心的工作区界面进行比较,该界面位于艺术家版本控制部分。

接下来,我们将通过设置使用 Unity 版本控制的新 Unity 项目来深入了解每个界面的功能。但在那之前,我们将查看以艺术家为中心的工作流程。

为艺术家定制的版本控制

Gluon 建议使用 Unity 版本控制的艺术家使用 Gluon 来简化艺术生产工作流程。Gluon 提供了一个易于使用的文件管理界面,允许您选择要工作的特定文件,而无需下载和管理整个项目。此外,Gluon 允许用户锁定文件(跨分支),确保对艺术资产具有独家访问权限,这样就没有其他人可以同时修改同一文件,然后无缝地将更改提交回存储库。在工作过程中根据需要锁定和解锁资产的能力,使得游戏制作团队内的协作更加顺畅。

图 15**.3 展示了以艺术家为中心的简化 Gluon 工作区界面,它为艺术家和设计师提供了 工作区资源管理器检查更改传入更改更改集 功能:

图 15.3 – Unity 版本控制 Gluon 工作区

图 15.3 – Unity 版本控制 Gluon 工作区

额外阅读 | Gluon

艺术家的版本控制:unity.com/solutions/version-control-artists

Gluon 还提供了一项直接造福艺术家的独特功能:它内置了图像查看器差异工具!使用它,您可以从更改历史中比较同一文件的两个版本,从而确保永远不会对项目中的艺术资产进行了哪些更改、何时更改以及由谁更改产生疑问。仅 Gluon 工作区本身就可以用作强大的资产管理平台。Gluon 与标准图像格式(如 PNG 和 JPG)兼容良好,但可以通过预览生成器支持其他格式(例如,通过添加 ImageMagick 作为外部预览工具以支持超过 100 种额外的格式)。

Gluon 的图像差异工具

使用 Gluon 的图像差异工具,您可以并排查看图像,使用“洋葱皮”预览,计算差异,进行“滑动”,甚至以文本格式比较图像属性。

不仅 Unity 版本控制提供了所有日常操作所需的 VCS 工具,例如检查更改(即提交和推送)、上传到云存储库、处理传入的更改(即拉取)以及在工作区内部合并和解决文件更改冲突,而且它还完全兼容流行的 Git 分布式版本控制系统DVCS)。让我们快速了解一下 Unity 版本控制作为 Git 客户端的工作方式。

为 Git 提供版本控制服务

对于 Git 用户来说,是的,Unity 版本控制也为您提供了支持——它也支持 Git 网络协议。它可以直接与任何远程 Git 服务器(如GitHubGitLabBitbucket)推送和拉取更改。Git 同步功能,在分支浏览器(如图 15.7所示)中通过简单的右键菜单实现,您可以选择推送/拉取 | 与 Git 同步…,立即将 Unity 版本控制转变为与 Git 双向同步完全兼容的 VCS。优势在于,您可以使用 Unity 版本控制满足所有 DVCS 客户端需求,无论是使用 Unity 版本控制工作区还是 Git 项目。

额外阅读 | Git

Git 是一个流行的免费开源分布式版本控制系统:git-scm.com/

然而,Unity 版本控制旨在快速有效地管理游戏特定资产,即使处理大文件和二进制文件也是如此。这使得它成为 Git(特别是以其易出问题的倾向而闻名的 Git 大文件支持LFS))的有效替代品。无论您的游戏资产大小如何,Unity 版本控制都提供了一种快速有效的方法来管理您游戏项目中的所有资产。

好了,关于实践和工作流程就说到这里——让我们为新的 Unity 项目设置第一个 Unity 版本控制云工作区。

设置 Unity 版本控制

Unity 通过在创建新项目时直接在Unity Hub界面中添加一个选项,使得将版本控制添加到您的项目尽可能无摩擦。您只需选择一个复选框,如图 15.4所示。使用 Unity 版本控制?勾选——请使用!

图 15.4 – Unity Hub 显示一个新版本控制项目

图 15.4 – Unity Hub 显示一个新版本控制项目

在 Unity Hub 中创建项目时,参照图 15.4,我们必须按照以下步骤创建和链接一个版本控制工作区:

  1. 打开Unity Hub并单击新建项目按钮(窗口右上角)。

  2. 选择一个模板作为您新项目的基准(如果需要,请下载模板)。

  3. 现在,在项目设置部分(窗口的右侧),填写所需的字段:

    • 项目名称: 您将为该项目分配的名称(例如“我的精彩游戏”——不包含特殊字符)。

    • 位置: 您将在本地系统驱动器上存储项目文件的位置(使用短路径且不包含特殊字符)。

    • Unity 云组织: 您必须为您的项目选择一个组织。

    • 连接到 Unity 云: 可选;如果您打算使用 UGS 来处理此项目(考虑到我们已经讨论了 UGS 的所有优点,如果我们不启用它,那将是对自己的一种不公),请启用此选项。

    • 使用 Unity 版本控制: 最后,我们到了这里。这是可选的,但我们应该确保为我们的“我的精彩游戏”项目启用此选项,这样我们就不会丢失任何开发工作!

  4. 点击创建项目

让 Unity 做它的事情,在本地和 Unity Cloud 中创建项目和 Unity 版本控制工作区。当 Unity 编辑器打开时,我们将被Unity 版本控制窗口欢迎。正如我们所看到的,我们已经有了一些挂起的文件更改要提交:

图 15.5 – Unity 编辑器的 Unity 版本控制窗口

图 15.5 – Unity 编辑器的 Unity 版本控制窗口

在任何时候,如果您需要Unity 版本控制窗口但当前没有显示,请转到窗口 | Unity 版本控制或点击管理服务(云图标)按钮右侧的按钮(左上角,直接位于文件菜单下方)。

小贴士

您还可以通过点击齿轮图标并选择在桌面应用程序中打开来快速使用独立的 Unity 版本控制桌面应用程序,如图 15.5所示。

图 15.5所示,这些文件是 Unity 添加到新项目的默认资源。它们仅存在于我们的项目文件夹中,因此我们需要通知 Unity 版本控制工作区我们有这些更改要跟踪。我们可以通过使用挂起更改选项卡,确认我们想要选择的文件,并提交更改(或在 Git 术语中,提交)来实现。

初始提交是更改集的常见第一个提交信息,这意味着这是我们工作区或存储库历史的开始,所以请在提供的文本框中输入。勾选添加并私有旁边的复选框,因为我们也想包含所有这些文件,然后点击提交更改。这将为更改集中的所有文件创建一个更改集和文件版本历史。太好了!您刚刚完成了第一个 VCS 提交!

提交最佳实践

小步提交,频繁提交。或者,至少在您的工作日结束时提交,以确保不会丢失任何工作进度。在提交之前测试您的工作并使用清晰简洁的提交信息(例如,包括更改的原因,而不仅仅是更改了什么)也是好的。

Unity Cloud 仪表板还提供了一个基于浏览器的界面来管理 Unity 版本控制存储库。它允许用户可视化并与其项目的版本历史、分支、更改集、代码审查和文件锁定进行交互。还有座位和用户组可供团队管理,并且有使用报告可供跟踪计费。

您可以通过在浏览器中打开 Unity Cloud(cloud.unity.com)并转到DevOps | Repositories来查看您的游戏项目的云存储库:

图 15.6 – Unity Cloud 存储库仪表板

图 15.6 – Unity Cloud 存储库仪表板

对于独立开发者来说,VCS 工作流程非常简单直接。您是唯一进行更改的人,在您的独立成员工作区进行日常工作时,您很少会与自己发生冲突。

拉取最佳实践

对于项目团队来说,一个常见的最佳实践是定期更新他们的工作副本,通过从云存储库中拉取最新更改来在新工作或进一步更改之前开始工作。这样,团队始终在推动项目代码库向前发展的基础上构建。

然而,你可能希望将一些更改还原到早期版本。因此,在这些情况下,如果你与同一工作空间中的开发团队一起工作,我们必须考虑一些设置 Unity 项目以协作的最佳实践,以最大限度地减少文件合并冲突。

设置协作项目结构

当为版本控制设置 Unity 项目时,采用专为更好协作设计的项目架构是至关重要的,这有助于防止团队成员之间的工作流程冲突。一个组织良好的项目结构和清晰的场景组织以及预制件工作流程指南可以显著降低冲突和合并问题的可能性,这些问题在处理 Unity 场景和预制件资产以及任何无法合并的二进制文件时经常遇到。

项目组织和 VCS 最佳实践电子书(Unity)

游戏开发者版本控制和项目组织最佳实践:unity.com/resources/version-control-project-organization-best-practices-ebook

我们之前提到的两个改善项目结构的方面,即预制件和场景,需要更多的背景信息,因此让我们快速了解一下它们:

  • 预制件工作流程:预制件工作流程对于 Unity 项目中的高效协作至关重要。预制件允许进行模块化游戏设计,其中游戏对象是预先制作的对象,它们构成了场景层次结构,并组合在一起以创建所有必要的功能。预制件更改被仔细管理和沟通,以最大限度地减少冲突并确保一致性。尽管如此,它们是独立于场景更改进行更新的,以防止多个团队成员无意中同时更新场景。

  • 增量场景工作流程:在管理 Unity 中的场景时,拥有协作工作流程和组织策略至关重要。使用增量场景方法允许多个开发者通过将级别划分为主要场景和增量场景(如照明、游戏元素或 UI 组件)来同时处理同一级别的不同方面;团队可以并行处理不同的场景,并在运行时将它们合并在一起而不会发生冲突。此外,确保使用 Unity 的场景序列化在文本模式下(版本控制的默认模式)进行,以便 VCS 更容易跟踪更改并使用合并工具,如 UnityYAMLMerge

除了这些 Unity 项目特定的协作工作流程之外,我们还可以使用 VCS 团队工作流程,例如 按功能分支 或甚至 按任务分支

在开发新功能或处理特定项目任务时,团队在隔离的分支上工作,而不是使用单个主(或 master)分支,通常很有帮助。这种方法通过允许每个功能在自己的时间线上独立进展,减少冲突并使团队并行开发成为可能。因此,团队工作流程更顺畅,项目管理更加有序。

Git Flow

在 Git 中,Git Flow是一种使用不同分支来处理功能、错误修复和发布的流程。

图 15.7中,我们可以看到 Unity 版本控制如何可视化工作区分支。额外的分支将作为从源分支的变更集分叉出来显示:

图 15.7 – Unity 版本控制分支浏览器和变更集

图 15.7 – Unity 版本控制分支浏览器和变更集

提交请求(PR)| 代码审查

在完成功能分支的工作后,创建一个提交请求PR)将更改合并到 dev/develop 分支(在合并到用于分发的主分支之前)是一个好习惯。团队领导或 DevOps 团队指定的资深开发者应在接受和合并请求之前审查更改。

在 Unity 版本控制中,我们本身没有 PR(这是一个 Git 概念)。相反,我们有一个完整的代码审查系统:docs.plasticscm.com/code-review

一旦在功能分支上完成工作,使用 PR 或代码审查流程将工作合并到 Dev(develop)分支是一个好习惯。团队领导或 DevOps 团队指定的资深开发者有责任在接收和合并请求之前进行审查。

最后,最重要的是你——或者你的开发团队——对 VCS 工作流程感到舒适。然而,无论我们多么严格地遵循最佳工作流程实践和协作策略,避免合并冲突并不总是可能的。因此,当面对不可避免的任务——解决合并冲突时,我们依赖合并工具来帮助我们。

合并冲突的文件更改

Unity 版本控制附带了一个强大的合并工具和针对处理 Unity 项目合并专门设计的合并配置设置。合并配置允许我们指定特定资产的合并工具,例如UnityYAMLMerge用于 Unity 场景文件和语义合并用于大多数其他基于文本的文件资产:

  • UnityYAMLMerge:一个专门用于合并 YAML 格式的场景和预制件文件的合并工具。它理解 Unity 特定资产的结构,使其比基于文本的合并工具更智能。对于在 Unity 项目中工作的团队来说,这是一个无价的资产,Unity 版本控制的默认配置包括它。

  • 语义合并:一个智能的 C#语言依赖合并工具,可以有效地解决代码冲突,大多数情况下是自动的,因为它理解代码结构而不仅仅是简单的文本差异。

查看文件历史和差异

您可以在 Unity 编辑器的项目窗口中随时右键单击任何脚本或资产,并选择Unity 版本控制 | 查看 文件历史

或者,您可以选择Unity 版本控制 | 与上一个版本比较差异,以查看两个版本之间的文件特定更改。

我希望我已经说服了你使用 Unity 版本控制系统在您的游戏项目中——或者任何 VCS——的价值。我们学习了如何为新 Unity 项目设置 Unity 版本控制,检查我们工作的基础知识,以及为团队协作组织项目的最佳实践。

Unity 版本控制定价

Unity 版本控制使您或您的团队能够安全、安全地存储和并行处理您的游戏项目资产和数据:unity.com/products/unity-devops#pricing

Unity 提供 1 到 3 个免费座位和 5GB 的存储空间。您只需为额外的座位(每个额外团队成员每月一个座位)以及超过 5GB 的存储空间(每月每 GB)付费。

我在整个创建本书项目的过程中都依赖 Unity 版本控制。你以为我会想在项目进展的任何阶段冒险丢失我的工作,对吧?

在下一节中,我们将继续讨论为我们的游戏准备商业发布,概述游戏内经济。

通过游戏内经济吸引玩家

游戏中的经济对于商业成功至关重要,在设计我们的游戏时值得我们关注。在设计游戏机制时,不同的盈利机制在我们的游戏内生成收入的能力中起着重要作用,这与商业化努力(如营销和发行)是不同的。

经济机制可以涵盖广泛的策略,并且在游戏平台和类型之间可能存在巨大差异。这些策略可以包括微交易、广告、虚拟货币、订阅模式或高级游戏购买。通过理解这些机制之间的一些基本细微差别,游戏制作人和开发者可以了解他们游戏的有效商业策略。

商业化与盈利的区别

商业化和盈利之间的区别在于它们各自的重点。商业化主要关注将游戏推向市场并使其对玩家可用,而盈利则涉及游戏一旦在玩家手中如何产生收入。

制作商在发布商业游戏时有两个主要平台可供选择,而且游戏经济结构在这两者之间差异很大。这些是移动PC——我们甚至可以说“免费”和付费。

因此,让我们快速了解一下每个平台的经济策略旨在做什么,先从移动平台开始。

移动游戏的经济体系

移动游戏通常遵循“免费增值”或免费游玩F2P)模式,这意味着玩家可以免费下载和游玩,无需预先支付。然而,货币化策略包括购买虚拟货币以获取游戏内物品、观看广告或支付小额交易以获得不同的游戏优势。不出所料,这种模式旨在吸引广泛的玩家下载和游玩游戏,这意味着你的游戏必须引人注目且有趣,才能取得成功,因为大多数移动游戏只从其玩家的一小部分中获得收益(玩家流失是真实存在的——玩家需要快速找到乐趣!)。

要从移动平台上的商业游戏发布中获得最大收益,以下是一些你可以在游戏设计中关注的重点策略:

  • 鼓励玩家定期参与:移动游戏开发者通常使用每日登录奖励、任务、活动、计时器、加速以及甚至每周或每月的挑战来鼓励玩家定期返回游戏,最终目标是让玩家购买游戏。

  • 奖励广告观看:通过向玩家提供观看完整广告的奖励来生成广告收入。奖励可以加快游戏进度,加倍虚拟货币奖励,甚至保存玩家的进度。你获得了广告收入,同时也引导玩家走向未来的购买之路。

  • 游戏内购买优惠:提供外观物品和游戏优势。然而,平衡游戏优势以避免“付费获胜”场景至关重要,这可能会驱使玩家离开。

  • 订阅和季票:提供有限时间内的独特内容——唤起紧迫感和兴奋感——可以激励玩家不断返回并让他们继续在游戏中消费。

这绝对不是一份详尽的列表,而且在你的游戏货币化机制中实施这些策略需要很多细微之处。尽管如此,最好对引入游戏内经济以发挥移动游戏商业潜力所需的内容有一个大致的了解。

接下来,我们将检查付费游戏类似的策略。

付费游戏的经济体系

付费游戏,预先购买并提供免费游玩体验(如果发行商提供演示),更注重玩家的初始投资,但可能包括一些额外的激励措施以延长收入。然而,与我说过的内容有些矛盾,一些游戏虽然是 F2P,但完全通过游戏内购买和扩展包进行货币化。因此,付费游戏经济与移动游戏存在显著差异。

在您的游戏设计中关注以下关键策略,以充分利用在 PC 上商业发行游戏的机会:

  • 扩展包和可下载内容(DLC):为了延长付费游戏的寿命和持久性,玩家可以在游戏最初发布后购买新故事和内容以产生额外收入。最好在开发阶段就考虑 DLC,因为游戏架构必须支持它。

  • 游戏内购买优惠:这一策略从移动端延伸而来,因为我们在这里也包括了外观物品和游戏优势。您无需再看其他地方,只需看看免费游玩的堡垒之夜,就能知道外观物品可以产生多少收入!同样,在这里,平衡游戏优势以避免“付费获胜”的情况很重要——我相信 PC 玩家更倾向于因此被驱离。

  • 游戏初始质量和深度 = 玩家信任:付费游戏应提供游戏体验,使玩家觉得游戏价格合理(也就是说,他们觉得为游戏支付的费用是值得的)。因此,在这种情况下,游戏经济应专注于丰富玩家体验,而不是过多地鼓励玩家消费,这在尝试确保我们的游戏投资从收入角度来看是积极的时是一个难以平衡的问题。

这也不是一个详尽的列表,实施这些策略所需的明显细微差别留待您的创意思维,同时我们戴着经济学家的帽子。然而,这仍然是一个很好的感觉,了解如何为付费游戏的商业潜力带来游戏内经济。

通过考察移动和 PC 平台对比的策略和机制,让我们完成对游戏内经济的介绍。

对比经济策略

我们讨论的关于游戏内经济的平台各有其独特的吸引玩家和产生相应收入(希望如此)的方法。为了更好地理解为每个平台制定的策略之间的差异,我们可以简单地、清晰地比较关键策略。

以下矩阵表显示了每个平台的游戏内经济策略和关联玩家参与度的主要差异:

策略 移动游戏 PC(付费)游戏
参与度与投入 专注于持续的玩家参与和频繁但小额的激励性交易 专注于初始玩家投入,并在之后通过 DLC 提供扩展内容
可访问性与深度 游戏必须易于上手和游玩,具有上瘾性的乐趣,并吸引广泛的玩家群体 游戏必须提供更深层次和更复杂的游戏体验,以吸引长期玩家
收入频率 依赖于连续的货币化机会,采用多种策略来促进玩家消费 依赖于通过更少但更重要的玩家消费(如果有的话)进行货币化,超出初始游戏购买

表 15.1 – 移动和 PC 游戏游戏内经济对比

创建游戏内经济是一个需要许多学科的知识,包括游戏玩法设计和公平货币化策略的融合。对于移动游戏来说,挑战在于吸引并保持一小部分玩家的兴趣,使他们愿意为免费体验支付真实货币。而对于高级 PC 游戏,最初的挑战在于提供给玩家足够的前期价值,使他们愿意购买你的游戏。然后,就取决于你如何吸引他们,使他们愿意为额外内容支付更多。这两种经济都需要深入了解你的目标玩家群体以及避免引入不理想的货币化方法的能力。

在本节中,你学习了移动和 PC 平台上游戏内经济的基础知识。当然,这里还有很多可以学习的内容,所以我鼓励你阅读对你游戏设计最有吸引力的主题。顺便说一句,Unity 也提供了关于这个主题的最佳实践和指南!

什么是游戏内经济 | Unity

关于游戏内经济系列指南的第一篇:unity.com/how-to/what-is-in-game-economy-guide-part-1

在下一节中,我们将探讨游戏生产生命周期中的平台分发方面。

将你的游戏推向市场!平台分发

在上一节中,我们讨论了与移动和 PC 平台相关的游戏内经济。然而,我们没有详细讨论将这些平台上的分发。好吧,现在是时候了。

在本节中,我们将探讨在移动和 PC 平台上分发我们的游戏的可选方案。这将是一个高级别的流程回顾,重点更多地放在商业发行上。我还会为每个平台提供链接,以获取在大多数情况下准备在该平台上分发我们的游戏的详细步骤。

游戏机分发

对于像PlayStationXboxNintendo Switch这样的游戏机分发平台,一个与大型 AAA 工作室无关的游戏开发者将面临这些平台上的额外挑战。将我们的游戏带到游戏机上的额外要求通常包括严格的审批流程、购买昂贵的开发者套件、漫长而复杂的代码移植过程,以及符合特定的标准和性能要求。

从移动平台开始,让我们看看如何将我们的游戏带给玩家手中。

在移动平台上分发

在移动游戏分发的世界中,有两个关键玩家需要了解——说实话,大多数足够购买智能手机或平板电脑设备的人都知道它们是什么——那就是谷歌 Play 商店苹果 App Store

这些以移动为中心的平台或商店为开发者提供了访问庞大全球受众的机会,而无需太多摩擦即可访问平台和构建我们的游戏——尤其是在使用 Unity 的情况下。然而,开发者面临着在拥挤的市场中脱颖而出并与大工作室(以及大营销预算!)竞争的挑战,因此,仅通过一款出色的游戏来优化可发现性是至关重要的,同时还要了解应用商店优化ASO)的方方面面。

在移动商店中,免费游玩模式是主流,因为这是商店提供大量支持的模式,也是玩家普遍期望的模式。如前所述,通过在游戏中引入经济体系并与玩家期望保持一致,我们将考虑平台内置的支付系统以进行应用内购买和广告以获取收入。

让我们从谷歌应用商店的发布要求概述开始。

在谷歌应用商店发布

希望在谷歌应用商店发布游戏的开发者必须了解该平台的技术和财务方面——这不仅适用于谷歌应用商店,也适用于我们可用的任何其他分发平台。

考虑到技术方面,发布到谷歌应用商店意味着为Android平台构建我们的游戏。Unity 为 Android 平台提供了出色的支持,并提供了许多设置来定制游戏构建(例如图形 API、纹理压缩、质量设置和脚本后端)。此外,Unity 还提供了对移动特定设备功能的脚本 API 访问,例如触摸输入、摄像头、震动、陀螺仪和加速度计、位置服务、通知以及 AR 支持(ARCore)。

Android 屏幕分辨率

由于屏幕分辨率、纵横比、显示刘海和挖孔以及现在在设备制造商的狂野西部出现的可折叠设备数量众多,为 Android 设备适配游戏对移动开发者来说是一个巨大的挑战。这要求游戏开发者戴上他们的 UI 设计师帽子,并实施响应式和灵活的 UI 设计策略,以确保在设备屏幕的安全区域内获得最佳观看体验。

每个分发平台通常都提供一套独特的增值服务,这些服务针对其平台特定,开发者可以将这些服务纳入他们的游戏设计中。谷歌提供了其谷歌应用游戏服务,包括登录、保存游戏、成就、排行榜、朋友、玩家统计数据和活动。

谷歌应用商店 | 分发信息

有信心发布:play.google.com/console/about/guides/releasewithconfidence/

现在,让我们回顾一下苹果应用商店的发布要求。

在苹果应用商店发布

希望在苹果应用商店发布游戏的开发者必须了解,该平台在分发游戏方面有其自身的技术和财务方面。同样,这一点适用于所有平台。

当我们想要将游戏发布到苹果应用商店时,技术方面涉及为iOS平台构建我们的游戏。Unity 在这里也为我们提供了支持,并提供了构建 iOS Xcode项目的优秀支持。正确——我没有说游戏构建。Unity 会生成一个 Xcode 项目来构建 iOS 应用程序和游戏,因此如果您想在本地构建游戏,您必须安装 Xcode——它仅在macOS系统上可用。然而,我们从Unity DevOps 简介部分已经了解的 Unity 构建自动化,可以为您构建和部署 iOS 游戏,使得在非 macOS 系统上开发 iOS 应用程序成为可能。

额外阅读 | Unity 文档

为 iOS 构建:docs.unity3d.com/2022.3/Documentation/Manual/iphone-BuildProcess.xhtml

毫不奇怪,Unity 还为 iOS 设备特定的功能提供了脚本 API 访问,例如触摸输入、摄像头、震动、陀螺仪和加速度计、位置服务、通知以及 AR 支持(ARKit)。

iOS 屏幕分辨率

与 Android 相比,iOS 设备生态系统提供了更一致的屏幕分辨率、宽高比和“刘海”安全区域,以显示游戏内容和 UI。苹果精心控制的硬件设计也意味着设备型号更少,每个型号都有其详尽的屏幕规格文档。这种一致性简化了游戏开发,当我们戴着 UI 设计师的帽子时,确保我们的游戏在苹果的移动设备上呈现得更加无缝。

苹果还为其平台提供一系列特定的增值服务,开发者可以将这些服务整合到他们的游戏设计中。苹果提供了游戏中心,可用于针对苹果 Arcade的游戏。以下服务可用:玩家身份、保存的游戏、排行榜、成就以及回合制和多玩家游戏。

苹果应用商店 | 分发信息

将您的 iOS 应用程序提交到应用商店:developer.apple.com/ios/submit/.

可用的第三方应用商店包括 Android 的亚马逊应用商店三星 Galaxy 商店华为应用商店APPTUTTi

Unity 分发门户 (api-udp.unity.com/)试图简化发布和管理我们的 Android 游戏到多个全球应用商店的过程,这可能对您有所帮助。但(目前)iOS 没有这样的第三方应用商店市场。

让我们快速总结一下移动分发平台的财务考虑因素。

发布财务信息

在财务方面,我已经在以下表格中整理了对于对 Google Play 商店和 Apple App Store 平台感兴趣的开发者来说的必要发布信息:

注册费 收入分成 平台
Google Play Store $25 一次性 70/30% 分成*30% 归 Google 广泛的受众覆盖,丰富的游戏市场
Apple App Store $99 年度 70/30% 分成*30% 归 Apple 仅限 Apple 设备用户,Apple Arcade

表 15.2 – 移动商店发布详情

关于表 15.2 的注意事项

我大大简化了这个声明,因为它一直在变化;请查阅平台文档以获取最新信息。在某些情况下,收入分成费降低到 15%。

这涵盖了针对移动特定的发行平台,那么让我们继续探讨基于 PC 的平台,并了解它们的一些具体要求。

在 PC 平台上发行

PC 游戏在商业销售和非商业分享我们的游戏方面有更广泛的发行选项。对于移动游戏,发行完全是数字交付,而 PC 游戏也可以有实体发行渠道,但我们只关注数字店面。

SteamEpic Games Store 是 PC 基于游戏数字商店的主要参与者。这些商店的主要商业化策略是高端。一般来说,基于 PC 的玩家通常寻求比移动游戏更沉浸式的游戏体验,这更符合高端模式对前期购买或订阅的关注。

然而,就像移动店面一样,在 PC 游戏发行平台上推出的开发者必须最大化可见性,导航渠道,并利用平台特定的功能来吸引玩家,增强他们的参与度,当然,当然,推动销售。

直接销售

游戏开发者可以通过他们的网站直接向玩家提供游戏,作为 PC 游戏商店的替代方案。这种方法提供了每笔销售更高的收入和销售过程的完全控制,但确实需要大量的努力和投资在市场营销、客户服务和基础设施上。你想要制作游戏并将它们送到玩家手中,还是管理一个电子商务网站?

让我们深入了解这些主要的 PC 游戏商店,与移动商店有类似的覆盖范围,并以 Itch.io 结尾,它更多地服务于小型独立游戏开发者,无论是高端、基于捐赠还是完全免费的游戏发行。

Steam 平台发布

对于想要在 Steam 上发布 PC 游戏的开发者来说,必须了解并导航这个平台的技术和财务方面,这并不令人惊讶。

考虑到技术方面,在 Steam 发布意味着为独立 PC 平台构建我们的游戏,但主要是 Windows(Mac 和 Linux 也得到支持)。在我们能够在 Steam 上发布 Unity 游戏之前,我们首先需要集成Steamworks 软件开发工具包SDK)。该 SDK 提供了确保与 Steam 分发平台兼容所需的所有工具和资源(包括设置我们的 SteamApp ID等基本事物)。游戏还必须满足平台设定的特定技术要求。这些要求包括最低系统规格、控制器支持以及游戏性能。

就像移动平台提供的那样,PC 平台也有独特的服务,可以为我们的游戏增值!对于 Steam,我们有广泛的服务可供集成到我们的游戏中,包括成就、游戏统计、排行榜、OpenID、游戏通知、语音聊天和命令、输入、玩家创建的内容、库存、微交易、游戏服务器以及多人游戏的匹配和大厅。

Steam | 分发信息

查看 Steamworks 提供的内容:partner.steamgames.com/

现在,让我们来看看 PC 游戏分发平台的另一面,即 Epic Games Store。

在 Epic Games Store 上发布

想要在Epic Games StoreEpic)上分发 PC 游戏的开发者面临着相同的熟悉平台技术和财务方面。让我们来回顾一下。

技术方面首先考虑;在 Epic 发布意味着为独立 PC 平台构建我们的游戏,Epic 支持 Windows 和 Mac。与 Steam 不同,Epic 不需要我们集成任何特定的平台 SDK,但游戏仍然必须满足平台设定的特定技术要求。这些要求旨在提供最佳的游戏体验,并且出人意料的是,不会将玩家锁定在单个商店中。这些要求包括支持跨平台的多玩家游戏,如果在其他商店的分发中实现了成就,则实现 Epic 的成就,并确保游戏下载、安装、启动和运行一致——同时也要保证足够的质量。

这并不是说 Epic 不提供类似 Steam 的游戏服务——他们提供了一系列令人眼花缭乱的在线服务。Epic Online ServicesEOS)内置了对所有平台的全面支持:Windows、Mac、Linux、PlayStation、Xbox、Nintendo Switch、Android 和 iOS。提供的账户和游戏服务包括成就、游戏统计、排行榜、登录、玩家账户和数据存储、标题存储、玩家管理、朋友、在线状态、游戏邀请、语音聊天、分析、反作弊以及具有跨平台、点对点、匹配和大厅的多玩家服务。哇!请注意,它们都是完全免费的。

为什么 Epic 提供免费的在线游戏服务?

扩大游戏规模和加强玩家社区需要各种后端服务和基础设施;完成我们的游戏只是战斗的一半。Epic 通过提供免费服务(最初是为 Fortnite 制作,目前运营 Epic Games Store)来帮助游戏开发者成功,对所有开发者免费。Epic 的观点是,当他们的服务得到广泛采用时,参与的开发者就会成功。

Epic 最近也开始允许独立游戏开发者通过 Epic Developer Portal 在其商店上自行发布,表示商店对所有开发者和游戏发行商开放,只要他们的游戏符合商店的要求。

Epic Games | 发行信息

在 Epic Games Store 上开始发行 PC 游戏:store.epicgames.com/en-US/distribution

除了作为差异化因素的自发行之外,Epic Games Store 还以其独家交易、定期免费游戏赠送、奖金以及支持基于区块链的游戏(涉足游戏开发的更具争议性的一边)而闻名。

Web3(区块链)游戏

Epic Games Store 是唯一几个拥抱区块链技术游戏的领先店面之一。区块链、NFT 和加密货币产品可以发布,但前提是它们遵循特定的政策:dev.epicgames.com/docs/epic-games-store/requirements-guidelines/distribution-requirements/blockchain

从领先的 PC 发行平台店面转向更受独立游戏开发者欢迎的发行平台,是一种远离纯粹商业收入生成目标的转变。让我们看看像 Itch.io 和 Game Jolt 这样的更受独立游戏开发者欢迎的平台是如何优先考虑创意自由和为游戏开发者提供一个更实验性的空间,让他们与世界分享他们的游戏创作的。

在 Itch.io 上发布

在数字发行平台店面景观中,Itch.io 独树一帜。该平台明确针对独立游戏开发者和创意人士。这并不是说它没有一些知名且受欢迎的游戏,例如 CelesteNight in the WoodsDoki Doki Literature Club!(针对视觉小说爱好者)。该平台为游戏发行商提供了一个独特的机会,从小规模开始,随着游戏的增长而扩大规模。

平台的技术方面相当开放,因为支持的平台类型取决于通过 Unity 或任何其他游戏引擎或编码框架分发的游戏类型。该平台支持播放 *.zip*.rar 文件,以及媒体文件(即图像、音频和视频文件)。

Itch.io | 发行信息

创建一个自定义页面,立即分发或销售您的独立游戏:itch.io/developers

关于财务方面,Itch.io 允许卖家确定 Itch.io 的收入百分比分成,范围从 0% 到 100%,如果你不做任何更改,默认为 10%。太棒了!

Itch.io 收入分成

介绍开放式收入分成:itch.io/updates/introducing-open-revenue-sharing

作为旁白,对于只想快速分享游戏(通常是早期原型或测试中的开发)的 Unity 开发者,SIMMER.io 网站提供了拖放式简单性,可以快速分享 WebGL 游戏构建。我相信正在开发一个“小费罐”。

SIMMER.io

Unity 开发者快速、简单(且免费)分享 WebGL 游戏的地方:simmer.io/

让我们快速总结一下 PC 发行平台的财务考虑因素。

发布财务

对于财务方面,对于希望在 Steam、Epic Games Store 或 Itch.io 上发布游戏的开发者,以下表格显示了基本的发布信息:

注册费 收入分成 平台
Steam 每款游戏 $100 的费用 | 通过 $1,000 的收入进行报销 70/30% 的分成*30% 归 Steam 大型社区,Steamworks 工具供开发者使用
Epic Games Store 每款游戏(自发布)$100 的费用 88/12% 的分成*12% 归 Epic 独家交易,游戏资金,免费游戏服务(EOS),虚幻引擎的制作者,Fortnite 的家园
Itch.io 开放式收入分成 0% 到 100% 归 Itch.io* 大型独立游戏开发者社区,创意自由,灵活定价,捐赠

表 15.3 – Steam 发布详情

关于表 15.3 的说明

*我大大简化了这个声明,因为它一直在不断演变;请查阅平台文档以获取最新信息。

在本节中,我们了解了移动和 PC 的更大游戏发行平台。我们探讨了在这些商店发布游戏的技术和财务方面,并查看它们为我们游戏设计提供的独特服务。

接下来,我们将通过一个 UGS 的示例实现,并将游戏发布到基于 PC 的商店平台。

实施 UGS 和发布

现在我们已经了解了 Unity 的 DevOps 和 LiveOps 云服务的基础知识,让我们通过一个示例来添加基础 LiveOps 分析和崩溃报告到一个项目中,并通过动态更新游戏内容来实施基本的玩家参与策略。我们将通过 DevOps 自动化我们的构建过程来完成。

我们将首先将 LiveOps 服务添加到我们的游戏项目中。对于这个例子,以及如本章截图所示,我将使用我们在第二章中开始制作的 2D 收集游戏。为了跟上,您要么需要基于该项目制作一个游戏,或者您可以从这个书的 GitHub 仓库中下载 Unity 项目:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch2/Unity%20Project

添加 LiveOps 服务

我们将添加到游戏项目中的两个服务是Analytics云诊断。这些服务是成功运行实时游戏所需的最基本活动,我绝不会在没有它们的情况下发布游戏!我们可以轻松了解我们的游戏是如何与玩家表现的,Unity 提供这些服务是开箱即用的,这无疑是一个加分项。

这两个服务都可以在 Unity 编辑器中直接添加和启用。让我们首先添加 Analytics 服务。

添加 Analytics

Unity Analytics 服务收集有关您的游戏和玩家的关键数据。当玩家打开并运行您的游戏时,核心游戏事件和用户属性会自动收集。此外,您还可以定义和跟踪您自定义的游戏内事件。从玩家活动收集的所有数据都会汇总并显示在 Unity 云仪表板上,这使得您或任何团队成员都可以分析数据并获得与玩家参与度相关的见解。分析对于确定如何优化您的游戏以提高玩家保留率和满意度至关重要。

额外阅读 | Unity 文档

关于您游戏的深度数据洞察:unity.com/products/unity-analytics

开始使用 Analytics:docs.unity.com/ugs/en-us/manual/analytics/manual/get-started

幸运的是,通过以下步骤可以轻松快速地将它添加到我们的游戏中:

  1. 打开窗口 | 包管理器

  2. 下拉菜单中,将上下文更改为Unity 注册表

  3. 搜索analytics

  4. 选择Analytics并点击安装按钮。

  5. 安装完成后,点击配置按钮。

  6. 点击转到仪表板链接以打开 Unity 云仪表板(在您的默认网络浏览器中),以配置和监控事件,并在游戏性能中查看您游戏活动的概述,使用数据探索器深入关键指标,并使用事件浏览器查看事件随时间的变化。

初始化 Analytics 服务

要开始收集将在 Unity 云仪表板中显示和分析的玩家数据,我们需要为分析服务做两件事。首先,游戏开始时,我们需要调用 UnityServices.InitializeAsync()。更重要的是,由于数据隐私法规(如 GDPR、CCPA 或 PIPL),我们还需要让玩家选择允许收集数据,以便我们可以调用 StartDataCollection()

您可以在此处找到有关如何执行此初始设置的详细说明:docs.unity.com/ugs/en-us/manual/analytics/manual/sdk-guide

我在这里提供了一个基本的分析初始化脚本示例,供您开始使用:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch15/Game-Assets/AnalyticsInitialization.zip

通过使用以下声明等方式让玩家选择体验变得积极:“帮助我们为您创造更好的游戏体验!选择加入分析让我们了解您如何玩游戏以及我们可以如何改进。我们非常重视您的隐私;所有数据都是匿名收集的,仅用于增强游戏玩法和功能。感谢您的支持!”(免责声明:我不是律师;在涉及法规时,您应相应咨询。)

添加我们的下一个 LiveOps 服务将更加简单。

添加云诊断

在云诊断服务中,最基本的服务无疑是 崩溃和异常报告。当你的游戏在野外与玩家互动时,了解其操作稳定性非常具有挑战性。你只需在 Unity 编辑器中添加并启用该服务,就可以通过项目简单地获取游戏中崩溃和异常的实时数据。然后,你可以定期查看任何发生的事件及其崩溃和异常详情(包括堆栈跟踪),并采取行动。

要将云诊断添加到您的项目中,请按照以下步骤操作:

  1. 打开 窗口 | 包管理器

  2. 下拉菜单中,将上下文更改为 Unity 注册表

  3. 搜索 diagnostics

  4. 选择 云诊断 并点击 安装 按钮。

  5. 安装完成后,点击 配置 按钮(或者,在任何时候,转到 服务 | 云诊断 | 配置)。

  6. 点击 项目设置 窗口右上角的滑块以启用云诊断。

  7. 点击 转到仪表板 链接以打开 Unity 云仪表板(在您的默认网页浏览器中)以监控和审查崩溃和异常报告中的任何崩溃事件。

额外阅读 | Unity 文档

设置云诊断:unity.com/products/cloud-diagnostics

设置崩溃和异常报告:docs.unity.com/ugs/manual/cloud-diagnostics/manual/CrashandExceptionReporting/SettingupCrashandExceptionReporting

要测试我们云诊断服务的配置,我们可以在任何添加到场景中 GameObject 的 Start() 方法中使用 Debug.LogException() 语句在控制台窗口中查看异常消息,并进入播放模式

Debug.LogException(
    new System.Exception(("Cloud diagnostics test!")));

要完成我们的 LiveOps 示例,让我们看看我们如何通过为我们的瓢虫玩家角色添加季节性节日主题来动态更改游戏内容,以适应万圣节。

动态更新游戏内容

美国东北部的秋季一直是我一年中最喜欢的季节,无论是成年后还是成长过程中。我特别享受这个时期的万圣节季节。话虽如此,让我们从这个季节中汲取灵感,看看我是如何为我们的瓢虫玩家角色(来自二维收集游戏)的艺术作品添加内容的:

图 15.8 – 万圣节主题玩家角色

图 15.8 – 万圣节主题玩家角色

恐怖,对吧?如 Player 预制件的根目录名为 Graphics_Halloween 所见。在具有 Theme Graphics 标题的 GameObject 数组中,我已分配了原始图形,重命名为 Graphics_Default,以及新的节日图形。

要继续操作…

您可以自行更新瓢虫玩家角色以包含节日图形,并将 _graphics 数组添加到 PlayerController 脚本中(参考随后的 更新玩家控制器脚本 部分),或者在本书的 GitHub 仓库中找到已经完成这两个步骤的更新玩家角色:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch15/Game-Assets/SeasonalPlayer.zip

让我们看看我们如何通过设置和编码一个远程 配置集成来动态切换到万圣节图形。

添加远程配置

使用远程配置,我们可以微调游戏调整以适应难度或时间值,进行个性化更改,或运行定时事件,而无需分发新的游戏版本——前提是我们在我们设计和开发阶段考虑到这些功能。

对于我们的示例,我们将使用一个简单的布尔值作为配置键值来开启或关闭万圣节图形的显示。然而,首先我们需要按照以下步骤安装和配置远程配置:

  1. 打开窗口 | 包管理器

  2. 下拉菜单中,将上下文更改为Unity 注册表

  3. 搜索remote

  4. 选择远程配置并点击安装按钮:

    1. 注意,远程配置需要身份验证,并且也会进行安装。
  5. 安装完成后,转到窗口 | 远程配置(不,我不知道为什么它不在服务下):

图 15.9 – 编辑器区域中的远程配置键值设置

图 15.9 – 编辑器区域中的远程配置键值设置

图 15.9所示,默认情况下,我们的环境将是生产,但要知道你有创建任意数量额外环境的选项以满足你的需求——第一个逻辑选择将是开发,用于在编辑器区域工作并在推送至生产(即你的实时玩家)之前进行测试。

图 15.9所示,你可以通过点击在仪表板中查看按钮快速访问 Unity 云仪表板。网络仪表板允许你在不启动 Unity 编辑器的情况下添加和更改键值,并且可以从任何带有网络浏览器的电脑或移动设备上访问;只需访问cloud.unity.com/

让我们添加一个配置键来控制我们假日图形的可见性:

  1. 在打开远程配置窗口后,点击在仪表板中查看按钮以打开 Unity 云仪表板:

    1. 是的,我们可以在 Unity 编辑器内的远程配置窗口中直接添加一个新的键值对,你当然可以通过点击添加设置按钮来做这件事。不过,我们还是将利用仪表板来“作弊”——啊,我是说,生成这个第一个设置的初始代码。
  2. 如果你看到的第一个屏幕上没有显示设置指南,你可以在主窗口左侧的GAMING SERVICES 远程配置标题下的列中找到链接。底部有几个选项,其中一个是设置指南——点击它。

  3. 通过确认或简单地点击下一步完成的默认选项来检查你的环境和安装包的选项,因为我们在这里不需要更改任何内容。

  4. 现在,点击Theme_Holiday

  5. 类型布尔型

  6. 点击下一步,将设置为true,然后点击完成

  7. 现在,我们想要点击新创建的Assets/Scripts/Services文件夹中的RemoteConfigSettings

  8. 在你的集成开发环境(IDE)中打开脚本,删除所有现有的模板代码,并将复制的代码粘贴进去。

  9. 将公共类声明从ExampleSample重命名为RemoteConfigSettings并保存(Ctrl/Cmd + S)。

好的;我们已经开始了我们的集成第一阶段,但我们将想要添加两个小的修改以使其与我们的其他脚本兼容:

  • 首先,我们将将其改为单例实例,这样我们就可以从我们的PlayerController组件中访问它。

  • 第二步,我们将添加一个事件监听器,以便在从服务器获取远程配置完成后更新我们的图形,基于当前发布的值。

自动化动态内容 | UGS 用例

注意,对于这个示例,我们将在决定显示假日主题图形时手动切换值。不过,Unity 还提供了另一种通过日历计划自动化的方法,那就是使用游戏覆盖。这个用例可以在 Unity 文档中找到:docs.unity.com/ugs/en-us/manual/game-overrides/manual/use-cases

实现 UGS 的许多其他用例可以在以下位置找到:docs.unity.com/ugs/en-us/solutions/manual/Welcome

使用以下单例模式和OnSettingsChanged事件更新RemoteConfigSettings脚本:

public class RemoteConfigSettings : MonoBehaviour
{
    public static RemoteConfigSettings Instance
        { get; private set; }
    private void Awake()
    {
        if (Instance == null)
            Instance = this;
        else
            Destroy(gameObject);
        DontDestroyOnLoad(gameObject);
    }
    public event UnityAction<RuntimeConfig> OnSettingsChanged;
    …
}

现在,为了能够在服务器配置值检索到时调用事件,将以下OnSettingsChanged调用添加到ApplyRemoteSettings()方法中:

private void ApplyRemoteSettings(ConfigResponse configResponse)
{
    …
    OnSettingsChanged?.Invoke(
        RemoteConfigService.Instance.appConfig);
}

如您所见,我们传递了RemoteConfigService类的appConfig对象,以便事件监听器可以获取它们所需的配置设置。有了这个,我们现在可以将远程配置服务添加到我们的游戏中。在场景中创建一个新的名为RemoteConfig的 GameObject,并添加RemoteConfigSettings脚本。这样,我们就完成了——简单易行。

架构技巧

为了简洁和保持这个动态内容示例的简单性,我决定直接将图形更改添加到PlayerController脚本中。然而,您可能希望考虑将其作为一个独立的组件,以实现更健壮和可重用的动态图形交换系统组件。

现在剩下的就是增加我们PlayerController组件的功能,以便我们可以获取配置设置值,以确定是否显示假日图形。

更新玩家控制器脚本

打开PlayerController脚本进行编辑,并添加以下声明,用于保存图形(默认和假日版本)的GameObject引用以及我们之前定义的远程配置设置键的常量:

[SerializeField] private GameObject[] _graphics;
private const string THEME_HOLIDAY = "Theme_Holiday";

为了能够响应远程设置获取完成的操作,让我们在RemoteConfigSettings单例实例的OnSettingsChanged事件上添加一个监听器:

private void OnEnable()
    => RemoteConfigSettings.Instance.
        OnSettingsChanged += ConfigSettingsChanged;
private void OnDisable()
    => RemoteConfigSettings.Instance.
        OnSettingsChanged -= ConfigSettingsChanged;

我们还确保添加了监听器的移除,就像我们这些优秀的程序员一样。

最后,我们可以通过添加以下方法来处理由OnSettingsChanged事件触发的情况:

private void ConfigSettingsChanged(RuntimeConfig config)
{
    var isThemeEnabled = config.GetBool(THEME_HOLIDAY, false);
    if (!isThemeEnabled)
        return;
    ShowThemeGraphics(1);
}
private void ShowThemeGraphics(int value)
{
    foreach (var g in _graphics)
    {
        g.SetActive(false);
    }
    _graphics[value].SetActive(true);
}

好的,让我们最后一次这样做。方法分解如下:

  • ConfigSettingsChanged(): 这个方法是对OnSettingsChanged事件的处理程序。使用事件中传入的RuntimeConfig对象,我们使用字符串常量作为键名THEME_HOLIDAY,通过其GetBool()方法来检索当前值。如果找不到键,则返回默认值false

    • If (!isThemeEnabled):如果GetBool()返回的值是false,我们将通过return短路该方法,保留默认图形作为玩家角色上当前可见的图形:

      • 否则,将调用ShowThemeGraphics(1)并传入索引值1,这意味着分配在_graphics数组中的第二个项目——我们在0索引处分配了万圣节图形,项目编号2是索引1,以此类推。
  • ShowThemeGraphics():在这里,我们使用foreach语句遍历_graphics数组中的所有 GameObject,将每个对象的激活状态设置为false。我们紧接着将传入的索引值对应的对象设置为激活状态,确保分配给该索引的图形将通过_graphics[value].SetActive(true)显示出来。

保存脚本并尝试运行!在进入播放模式后,你应该看到你的瓢虫玩家角色的图形变成了为该主题创建的节日图形。太棒了!

奖励活动

更新OnSettingsChanged事件架构,使其使用我们在第九章中创建的全局事件系统的实现。

在你构建并发布你的游戏后,你可以使用 Unity Cloud 仪表板随时更改Theme_Holiday的值,以显示/隐藏节日图形,而无需重新构建和重新分发新的游戏构建。

完整项目代码

您可以从本书的 GitHub 仓库下载本章节的完成 2D 集合游戏项目代码:github.com/PacktPublishing/Unity-2022-by-Example/tree/main/ch15/Unity-Project

说到构建和分发你的游戏构建,你将在游戏的生命周期中多次进行这项工作。如果有一种方法可以简化并自动化这个过程……等等,有!

使用 Unity 构建自动化进行发布

将我们本地系统的构建过程移至专用构建服务器——在这种情况下是云基础系统——在我们的游戏开发工作流程中提供了几个好处,但主要是提高了生产力,因为我们的机器在构建时间不会锁定。在专用服务器上生成的构建还可以帮助确保所有游戏版本的一致性和可靠性——显著减少了“但我的机器上能运行”的问题。

为了帮助我们在早期识别问题和捕捉玩家遇到的 bug,我们还可以将自动化测试和质量保证QA)集成到我们的自动化构建流程中。此外,我们还可以自动化完成构建的分配,以便各个团队进行进一步测试,并在需要时发布。

所有这一切都始于理解构建过程,让我们看看如何在本地进行构建。

构建你的游戏

第十四章中,我们看到了如何使用构建设置将构建目标平台设置为Android,并将构建到我们的头戴式设备进行 MR 游戏的测试。在这里,我们可以做同样的事情,但通过以下步骤构建我们的 2D 集合游戏,用于独立平台,如 Windows 和 Mac PC:

  1. 文件 | 构建设置打开构建设置

  2. 确保将平台设置为Windows、Mac、Linux(也称为独立)。

  3. 目标下拉菜单中选择所需的平台(例如,Windows)。

  4. 点击构建,或构建并运行,以在构建完成后自动启动游戏,并选择系统中的一个文件夹以存储构建。

  5. 等待构建完成。

您没有选择;您必须坐那里等待……逐分钟盯着进度指示器,直到它完成。幸运的是,由于我们的 2D 游戏很小,这并不需要很长时间,但我们的游戏随着大小和复杂性的增长,情况可能不会总是如此。准备好离开办公桌,去泡一杯咖啡,然后回来只看到进度条几乎没有移动!

当构建完成时,你将在为构建选择的文件夹中找到运行游戏所需的文件。在您的系统文件资源管理器中打开文件夹,并运行 EXE 文件来玩游戏。要分发您的游戏——与您的团队、朋友和家人分享——将文件夹的内容(除以DoNotShip结尾的任何文件夹)压缩,并通过在线云存储服务(如 Google Drive、Dropbox、OneDrive 或 Box)分享。

尽管如此,我们还能做得更好。让我们将构建过程卸载到 Unity 构建自动化。

自动化构建流程

我们已经看到了如何将 Unity 版本控制添加到我们的项目 DevOps 策略中。好吧,自动化构建是这个 DevOps 故事的另一半——具体来说是CI/CD——因为我们将在项目的云工作区作为云构建配置的源。这就是云服务如何获取我们的项目文件以执行构建。

首先,让我们通过以下步骤将构建自动化添加到我们的项目中:

  1. 打开窗口 | 包管理器

  2. 下拉菜单中,将上下文更改为Unity 注册表

  3. 搜索build

  4. 选择构建自动化并点击安装按钮。

  5. 安装完成后,点击配置按钮(或,在任何时候,转到服务 | 构建自动化 | 配置)。

  6. 项目设置窗口的右上角,点击滑块以启用构建自动化。

  7. 点击管理构建目标按钮以打开 Unity 云仪表板(在您的默认网页浏览器中)。这是我们添加第一个构建目标的地方:

图 15.10 – Unity 云构建自动化配置

图 15.10 – Unity 云构建自动化配置

现在,点击开始按钮以连接源控制提供程序和仓库(即工作区):

  1. 源控制提供程序/SCM 类型下拉菜单中选择Unity 版本控制

  2. UVCS 组织服务器 URL下拉菜单中选择您的 Unity 组织。

  3. 保持使用 Unity ID 进行身份验证选中。

  4. 点击保存(位于顶部)。

一旦保存了源控制设置,请转到构建自动化 | 配置,并按照以下步骤添加我们的构建目标:

  1. 点击快速目标 设置按钮。

  2. 为以下平台选择构建 对话框中,选择Windows 桌面 64 位

  3. 确认仓库字段已填充您的游戏:

    1. 如果您的工区中有多个分支,请在分支下拉菜单中选择构建的分支。
  4. 版本控制下,在Unity 版本下拉菜单中选择始终使用最新 2022.3

  5. 构建器操作系统和版本下拉菜单中选择Windows 11

  6. 点击下一步

  7. 构建器配置屏幕上,确保已选择标准并点击下一步

  8. 调度屏幕上,在这个例子中,我们将从 Unity 编辑器内部手动启动构建过程,因此我更喜欢启用自动取消

重要提示

您可以随时更改构建的调度。最受欢迎的策略是在您的工区内为分支设置一个分支,以便在分支更新时自动启动构建。

  1. 点击保存配置按钮。

现在,返回 Unity 编辑器并重新打开构建自动化设置。当它刷新时,您会看到我们现在已添加了一个构建目标:默认 Windows 桌面 64 位

首先,确保您的 Unity 版本控制工作区是最新的,通过检查项目中的任何挂起更改,然后点击构建按钮。将创建一个状态为构建#1 默认 Windows 桌面 64 位 已添加到队列的控制台条目,以通知您构建已被排队。

您可以通过点击构建历史记录按钮或在 Unity 云仪表板下的构建自动化 | 构建历史记录中打开任何设备的网络浏览器来检查您的云构建状态。

构建自动化定价

您可以为包括 Windows、Mac、Linux、Android 和 WebGL 在内的多个平台以及多台机器同时进行快速构建:unity.com/products/unity-devops#pricing

Windows 构建每月包含 200 分钟的免费时间。您需要为额外的构建分钟数、Mac 构建分钟数、额外的并发构建机器以及超过 5GB 的存储(每月每 GB)付费。

当构建完成时,如果您仍然在 Unity 编辑器中,您将看到一个控制台消息出现:构建#1 默认 Windows 桌面 64 位 成功

您还将收到一封主题为为 Windows x86_64 构建‘2D Collection Game’的电子邮件,以及消息‘2D Collection Game’(默认 Windows 桌面 64 位)#1 已为 Windows x86_64 构建!电子邮件还将列出构建过程中遇到的任何警告或错误以及云控制台配置的链接。

最重要的是,将提供一个安装链接以下载构建工件(即,您的团队用于部署或测试应用程序的文件)。点击链接将带您到 Unity Cloud 控制台的构建详情页面。您可以通过点击下载 .ZIP 文件按钮下载游戏构建版本。您还可以通过点击分享按钮轻松地将此构建版本与任何人共享 - 您将获得一个有效期为 14 天的分享链接和二维码。

自动构建共享

您可以在构建自动化设置页面启用自动创建构建分享链接。启用后,分享链接将包含在构建成功时收到的电子邮件通知中(防止您前往 Unity Cloud 控制台获取它)。

除了启用自动构建共享外,Unity 还允许与流行的开发者工具(如 Discord、Slack、Jira 和 Trello)集成,以在这些空间中创建构建自动化和云诊断的自动通知。您可以在 Unity Cloud 控制台下的管理 | 项目 集成docs.unity3d.com/Manual/UnityIntegrations.xhtml)中配置您的集成。

现在我们已经制作了我们的第一个独立 PC 游戏,让我们在 Itch.io 上发布它,并与全世界分享!

发布您的游戏

在此发布示例中,我们将我们的游戏发布到 Itch.io。这是我们的小型 2D 收集游戏开始将我们的独立游戏标题带给玩家进行一些有价值的测试、反馈和错误修复(哎呀!但不是我们的瓢虫!)的完美平台。

打开 Itch.io 并在网站上注册;确保您勾选了我感兴趣在 itch.io 上分发内容itch.io/register

注册后,前往创作者仪表板区域(即仪表板)并点击创建新项目按钮。这将带您到以下屏幕,您可以在其中开始填写所有必要的详细信息并上传截图作为封面图像以显示您的游戏在商店中的列表:

图 15.11 – 在 Itch.io 上创建新项目

图 15.11 – 在 Itch.io 上创建新项目

在我们将游戏构建上传到网站时需要关注的字段是项目类型。确保它设置为可下载。然后,在下面的上传部分中,点击上传文件按钮并选择我们在构建您的游戏部分或从构建自动化过程中创建的 ZIP 文件。

为 WebGL 构建 | Unity 文档

注意,对于 Itch.io,你可以选择发布一个 WebGL 游戏版本,该版本可以直接在玩家的网络浏览器中运行,而无需下载。在承诺发布你的游戏的 WebGL 版本之前,你必须确保你的游戏在浏览器中可玩且性能良好,以提供良好的玩家体验。

构建你的 WebGL 应用程序:docs.unity3d.com/2022.3/Documentation/Manual/webgl-building.xhtml.

填写剩余的字段,包括详细信息部分下的所有字段,这包括描述类型社区互动可见性与访问权限。同时,别忘了添加截图游戏玩法视频或预告片来激发并鼓励访问者下载并玩你的游戏。

Itch.io Devlogs

你听说过 Devlog 吗?这是一个论坛帖子,游戏开发者在这里分享他们正在进行的项目更新。任何人都可以留下评论和想法,这对于玩家来说了解他们感兴趣的游戏的最新进展是一个极好的方式。此外,开发者可以利用论坛来激发热情并增加项目的参与度。

Itch.io 社区 Devlogs: itch.io/devlogs.

当你对所输入的内容满意时,点击保存并查看页面以完成在 Itch.io 上发布你的游戏。耶!

在本节中,我们学习了如何将 Unity LiveOps 服务添加到我们的 2D 游戏集合中,包括核心服务如分析和云诊断,以及通过远程配置动态更新游戏内容,而无需分发新的游戏版本。我们还学习了如何设置 Unity DevOps 来自动化我们的游戏构建过程,同时将其从本地机器卸载。我们以一个快速的游戏发布示例结束。

摘要

在本章中,我们介绍了探索运营和发布 GaaS 的概念和策略,以帮助我们实现商业可行性目标,并最终取得成功。通过采用 GaaS 并实施 DevOps 和 LiveOps 的工具和技术,我们可以建立一个坚实的基础来支持游戏的持续发布和内容更新。这有助于保持玩家的参与度,并可以为我们的游戏发布提供更长的生命周期价值。

此外,我们还学习了如何通过引入 Unity 版本控制来保护我们投入游戏项目的所有辛勤工作。我们还学习了如何在 Unity 编辑器中设置和使用版本控制,以实现基于云的 DVCS 解决方案,并更好地组织我们的项目以最小化团队协作中的冲突。

在讨论如何更好地利用玩家参与度将移动免费游戏通过游戏内购买、广告、订阅和 PC 游戏的付费购买转化为收入时,我们获得了对游戏经济和分销渠道的基本理解。然后,我们为免费和付费游戏提供了顶级游戏分销平台的技术和财务功能分解。

我们以 Unity LiveOps 和 DevOps 的实现示例结束了这一章。我们通过添加核心云服务、动态更新游戏内容、构建和自动化构建过程,最终发布我们的游戏来实现这一点。

最后的话!

在这些章节中,从设计游戏和与艺术资产合作的过程,到编程机制和系统的详细实现,我们探讨了基本原理,解决了问题,并一起庆祝克服挑战。无论你是经验丰富的专业人士、独立开发者、学生还是有抱负的业余爱好者,我希望你已经得到了灵感、洞察力、启示时刻,以及偶尔的笑声。

随着这一章的结束,我想向那些与我一起踏上这段冒险旅程的每一位表达我衷心的感谢。谢谢。我很荣幸有机会与这样一个了不起的开发者社区分享我的知识和经验。

所有的故事都有结局,因此这个故事现在也结束了。然而,这只是我们作为游戏开发者旅程中的许多阶段之一,因为我们继续努力完成并发布我们卓越的游戏。

到下次见面之前……我现在要完成一个游戏(你也是一样)。祝你们玩得开心!

posted @ 2025-10-25 10:31  绝不原创的飞龙  阅读(41)  评论(0)    收藏  举报