精通-C--Unity-游戏开发-全-

精通 C# Unity 游戏开发(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:贡献者

关于作者

穆罕默德·艾萨姆是一位技术高超的 Unity 开发者,擅长在各种平台上创建吸引人的游戏体验。凭借超过四年的游戏开发背景,他成功地为移动设备和其它平台设计了引人入胜的游戏机制。他目前专注于开发一款非常受欢迎的多玩家游戏,拥有令人印象深刻的 2000 万次下载。凭借对尖端技术的深刻理解和创造性解决问题的能力,穆罕默德·艾萨姆在他的项目中始终交付卓越的结果。

我非常感谢我的支持我的家人,他们的鼓励和信任对我的成就至关重要。我想感谢我的未婚妻坚定不移的支持,她的爱和谅解一直是我不断灵感的源泉。

关于审稿人

瓦赫·彼得罗相是一位经验丰富的游戏开发者,擅长 C++ 和 C#,专注于 Unity 和 Unreal Engine。他创造了专注于医疗领域和心理健康的教育性沉浸式 VR 体验,并领导开发了吸引成千上万玩家的流行移动游戏。他的项目,特别是在 Unity VR 方面,展示了通过创新游戏解决方案提升学习和健康的承诺。

目录

前言

第一部分:游戏设计和项目结构

1

游戏设计和项目管理简介

技术要求

GDD 简介

什么是 GDD?

GDD 和投掷:比较视角

投掷:激发你对游戏概念的激情

理解 GDD 元素

关于玩家的体验如何?

让我们谈谈项目组织

掌握项目结构以实现高效开发

摘要

2

为 Unity 游戏开发编写清晰和模块化的 C# 代码

技术要求

编写清晰代码的简介

编写清晰代码的原则

单一责任原则 (SRP)

开放-封闭原则 (OCP)

Liskov 替换原则 (LSP)

LSP 和 OCP 之间的区别是什么?

接口隔离原则 (ISP)

依赖倒置原则 (DIP)

理解游戏开发中的设计模式

创建型模式

结构型模式

行为模式

编码约定和最佳实践

重构技术

问题

摘要

第二部分:Unity 高级 C# 游戏开发技术

3

使用 Unity 插件扩展功能

技术要求

理解 Unity 插件

集成 Unity 插件

新输入系统

Cinemachine

使用 Unity 插件的最佳实践

摘要

4

在 Unity 中使用 C# 实现引人入胜的游戏机制

技术要求

介绍游戏机制

游戏机制的基本原则

代码与游戏机制之间的联系

使用 C# 实现玩家行为和 AI 逻辑

编写 IHealth 和 IDamage 接口

实现射击系统

深入 AI 逻辑

使用 C# 实现挑战和奖励系统

挑战与任务/任务之间的比较

平衡难度级别以吸引更广泛的受众

探索奖励系统

C# 实现挑战和奖励

摘要

5

使用 C# 为 Unity 游戏设计优化的用户界面

技术要求

在游戏中介绍 UI 设计

UI 的最佳实践和优化技术

拆分画布

避免过多的图形射线投射器和关闭射线投射目标

高效管理 UI 对象池

正确隐藏 Canvas

为 UI 元素高效实现动画

有效处理全屏 UI

介绍架构模式(MVC 和 MVVM)

理解 MVC – 三角合作的角色

理解 MVVM – 视图和模型的混合

为 Unity UI 选择正确路径

提高 UI 开发的实用建议

使用 C# 创建 UI 系统

UIManager 类

BaseView 类

实现 MVVM

总结

第三部分:使用 C# 在 Unity 中的数据管理和代码协作

6

使用 C# 在 Unity 中有效处理和管理工作数据

技术要求

使用 C# 进行数据组织和序列化

理解数据结构

通过适当选择数据结构来提高游戏性能

Unity 中的序列化

使用 C# 创建保存和加载系统

PlayerPrefs

自定义保存系统

使用 C# 进行数据驱动游戏

为统计数据创建数据

挑战系统

总结

7

使用 C# 在 Unity 中为现有代码库做出贡献

技术要求

介绍版本控制系统

理解版本控制系统

使用 C# 协作和解决冲突

协作的最佳实践

掌握协作中的分支和合并

掌握代码冲突管理

理解现有代码库

对现有代码库的实用探索

总结

第四部分:使用 C# 在 Unity 中的高级集成和外部资产

8

在 Unity 中使用 C# 实现外部资产、API 和预构建组件

技术要求

使用 C# 利用预构建资源

通用渲染管道 (URP)

使用 C# 集成后端服务

后端服务

使用 C# 集成分析 API

集成 GameAnalytics

GameAnalytics 使用示例

总结

9

使用 Unity 的 Profiler、帧调试器和内存分析器优化游戏

技术要求

介绍 Unity 分析工具

深入探索 Unity 的分析工具

理解配置文件过程

性能优化技术

物理和碰撞

音频

用户界面

网络和多人游戏

人工智能和路径查找

构建大小

渲染

脚本编写

内存管理和优化

内存分析器

总结

10

Unity 中的技巧和窍门

技术要求

使用 C# 的提高生产力的快捷键

Unity 编辑器快捷键

Visual Studio 快捷键

预制件工作流程优化

使用 C# 的高级技术和工作流程

ScriptableObjects

自定义编辑器

故障排除和常见挑战

调试技术

特定平台挑战

总结

索引

你可能喜欢的其他书籍

前言

欢迎来到激动人心的游戏开发与 Unity 精通的世界!在《精通 Unity 游戏开发与 C#》中,我们踏上了一段揭示创造引人入胜游戏和掌握 Unity 游戏开发平台的秘密的旅程。

我们的焦点不仅在于创建游戏,还在于理解提升你创作的基础原则。通过实际例子和最佳实践,你将深入研究 UI 设计、清洁代码架构、优化技术和游戏机制实现等关键领域。每一章都精心设计,旨在让你能够自信地应对游戏开发的复杂性。

这本书与众不同的地方在于其对游戏开发的实用方法。你不仅会学习理论,还会通过真实世界的例子获得实践经验,让你能够立即应用你新获得的知识。

从我在游戏开发领域的经验和行业专家的洞察中汲取灵感,这本书总结了多年的知识和专业技能。无论你是寻求提升技能的资深开发者,还是第一次尝试游戏创作的初学者,这本书都提供了使用 Unity 和 C#构建沉浸式和成功游戏的综合指南。

游戏开发不仅仅是一种职业,它是一种激发创新和创造力的激情。随着你深入阅读这本书,你不仅将学会创建游戏,还将解锁在蓬勃发展的游戏行业中开辟自己道路的潜力。

加入我在这段激动人心的旅程中,我们将深入探讨创造引人入胜游戏的技艺和科学,让你在游戏开发不断演变的领域中留下自己的印记。通过这本书,你将获得实用技能、行业见解和自信,在竞争激烈的游戏开发世界中脱颖而出。

这本书面向的对象

本书主要面向渴望提升 Unity 游戏开发技能的开发者和游戏设计师。虽然它涵盖了基础概念,但重点是中级到高级开发者,他们希望深入研究高级主题。

背景:

  • 初级开发者:拥有一些 Unity 经验的人,例如创建场景、编写脚本和操作对象,会发现这本书很有价值,因为它强化了核心概念并提供逐步指导

  • 中级到高级开发者:本书深入探讨了高级主题,如清洁代码架构、优化技术和第三方资产和 API 的集成,对于希望提升游戏开发技能的开发者来说,这是一本理想的书籍

  • 游戏设计师:虽然本书主要面向开发者,但寻求更深入理解 Unity 开发和游戏机制实现的游戏设计师也会在这本书中找到有价值的见解和实用技巧

总体而言,无论你是希望巩固知识的初级开发者,还是希望提升技能的资深开发者,本书都提供了掌握 Unity 游戏开发和创建沉浸式、精良游戏的综合指南。

本书涵盖的内容

第一章, 《游戏设计与项目管理简介》深入探讨了游戏设计的关键要素和原则。它涵盖了游戏机制、玩家体验和叙事技巧等基本主题。此外,本章还探讨了有效的项目管理技术,旨在简化游戏开发流程。

第二章, 《为 Unity 游戏开发编写干净和模块化的 C#代码》深入探讨了遵循行业最佳实践的编写干净和可维护的 C#代码的艺术。它涵盖了文档化和结构化 C#代码的重要性,以改善团队成员之间的协作。此外,本章还探讨了重构和优化现有 C#代码的技术,以提升性能和可扩展性,确保游戏开发过程顺畅高效。

第三章, 《使用 Unity 插件扩展功能》探讨了不同类型的 Unity 插件,帮助读者识别和评估其功能。本章指导读者如何无缝地将 Unity 插件集成到他们的项目中,以增强整体功能。此外,它还提供了使用 C#实现功能插件以引入新功能和丰富游戏机制的见解,从而扩展 Unity 游戏开发中的创意可能性。

第四章, 《使用 C#在 Unity 中实现引人入胜的游戏机制》深入分析了有效游戏机制所遵循的原则,并探讨了其应用。本章引导读者通过使用 C#实现挑战和奖励系统,以提升游戏体验。此外,它还探讨了如何使用 C#创建玩家行为和 AI 逻辑,以提供互动和响应式的游戏体验。通过这些策略,本章旨在增强玩家的参与感和沉浸感,在 Unity 中营造动态和吸引人的游戏体验。

第五章, 《使用 C#为 Unity 游戏设计优化用户界面》专注于应用 UI 设计原则,使用 C#制作视觉上吸引人的界面。读者将学习如何设计有效的视觉层次和布局,确保最佳的用户体验。此外,本章还涵盖了使用 C#实现响应式和交互式 UI 元素,以增强 Unity 游戏开发中的整体用户参与度和满意度。

第六章使用 Unity 中的 C#进行有效的游戏数据处理和管理,深入探讨了使用 C#组织和序列化游戏数据以实现高效存储和检索。它涵盖了使用 C#实现保存和加载系统,以有效管理游戏进度。此外,本章还探讨了使用 C#创建基于数据驱动的游戏元素,增强 Unity 游戏体验的深度和互动性。

第七章使用 C#在 Unity 中为现有代码库做出贡献,专注于利用版本控制系统来高效管理代码库。它指导读者如何有效地与共享代码库协作,并解决代码冲突,在 C#协作中保持代码质量。本章为开发者提供了在 Unity 项目中实现无缝团队合作和代码管理的必要技能和策略。

第八章使用 C#在 Unity 中实现外部资产、API 和预构建组件,深入探讨了使用 C#集成第三方资产,提升游戏视觉和音频。它还涵盖了使用 C#实现后端和分析 API,以提升用户参与度和增强整体游戏体验。本章为开发者提供了在 Unity 项目中有效利用外部资源的技能,有助于创造沉浸式和引人入胜的游戏体验。

第九章使用 Unity 的 Profiler、帧调试器和内存 Profiler 优化游戏,探讨了如何利用 Unity 的剖析工具来定位性能瓶颈。读者将学习如何应用性能优化技术来有效提升游戏性能。此外,本章还深入探讨了在游戏中管理内存使用和优化内存性能,确保 Unity 项目中的最佳性能和流畅的游戏体验。

第十章Unity 中的技巧和窍门,揭示了一系列提高生产力的快捷方式,以实现高效的 Unity 开发。读者将探索使用 C#的高级技术和工作流程,以提升他们的游戏开发过程。此外,本章还为读者提供了解决 Unity 开发中常见挑战的策略,发现有效解决方案,使他们能够克服障碍,在项目中取得成功。

为了充分利用这本书

这本书是为那些有一定 Unity 经验和熟悉 C#编程的个人设计的。如果你对 Unity 的核心功能有基本的了解,例如创建场景、编写脚本和操作对象,你就准备好深入这本书了。

本书涵盖的软件/硬件 操作系统要求
Unity3D 2022.3.13 Windows、macOS 或 Linux
Visual Studio 2022
GitHub Desktop

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

下载示例代码文件

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

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“commit -a 命令或其等效命令,仅在项目的初始提交期间使用,通常项目仅包含 README.md 文件时使用。”

代码块设置如下:

// Define WaitForSeconds as a variable
    private WaitForSeconds waitShort = new WaitForSeconds(2f);

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“然后,您可以在侧面板中点击提交暂存,在合并后推送更改。”

小贴士或重要提示

如此显示。

联系我们

欢迎读者反馈。

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

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

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

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com

分享您的想法

读完 Mastering Unity Game Development with C# 后,我们非常乐意听到您的想法!请 点击此处直接进入此书的 Amazon 评论页面 并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

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

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

不要担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

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

优惠不仅限于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱

按照以下简单步骤获取优惠:

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

二维码图片

packt.link/free-ebook/9781835466360

  1. 提交您的购买证明

  2. 就这么简单!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱

第一部分:游戏设计与项目结构

在本部分中,你将沉浸在游戏设计的复杂世界中,探索基本元素,如游戏机制、玩家体验和叙事技巧。了解针对游戏开发量身定制的高效项目管理策略,旨在优化工作流程和简化流程。此外,深入探讨遵循行业最佳实践的编写清晰和可维护的 C#代码的艺术,涵盖文档化和结构化代码以提高协作的重要性。通过结合这些创造性和技术方面,你将获得将你的游戏想法高效、创新并以高质量实现所需的工具和知识。

本部分包括以下章节:

  • 第一章**,游戏设计与项目管理简介

  • 第二章**,为 Unity 游戏开发编写清晰和模块化的 C#代码

第二章:游戏设计与项目管理简介

踏入创意与结构相遇的领域——游戏设计与项目管理简介。本章是您了解赋予游戏生命力的核心原则的入门。在本章中,我们将探讨游戏设计的必要元素,探索制作引人入胜的机制和引人入胜的叙事的艺术。此外,我们将揭示有效项目组织的秘密,为您提供简化开发流程的工具。无论您是梦想着您的第一款游戏还是寻求提高技能,这个简介为您提供了一个激动人心的冒险的基础。

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

  • GDD 简介

  • 玩家体验如何

  • 让我们谈谈项目组织

技术要求

准备好深入 Unity 开发了吗?请确保您的系统已准备好:

  • Unity 版本 2022.3.13:下载并安装 Unity,选择版本 2022.3.13 以确保与提供的内容最佳兼容性。

  • 主要 IDE - Visual Studio 2022:教程和代码示例使用 Visual Studio 2022 制作。请确保已安装,以便无缝跟随。如果您更喜欢 Rider 或其他 IDE,请随意探索,尽管说明是为 Visual Studio 定制的。

  • 足够的系统资源:确保您的系统满足 Unity 的最低要求,以获得流畅的开发体验。

  • 代码示例的 GitHub 仓库:在我们的专用 GitHub 仓库中访问代码示例和项目文件:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp。克隆或下载仓库,以便轻松访问本章中展示的代码。

GDD 简介

让我们深入了解游戏设计!我们将深入游戏设计文档(GDD)的世界。制作游戏不仅仅是关于代码;它是关于创造体验。这些基础知识有助于将想法转化为令人兴奋的游戏。我们将探讨 GDD 是什么以及它如何帮助制作玩家喜爱的游戏。

GDD 是什么?

游戏设计文档(GDD)是一个全面的指南,概述了游戏项目的核心主题、风格、功能、机制和想法。其主要作用是有效地传达项目细节,无论是您在游戏开发过程中自我推进,还是向团队成员、发行商或潜在玩家等利益相关者传达。本质上,它是帮助管理和开发游戏概念的工具,为其创作提供关键路线图。虽然其格式没有严格的标准,但一个精心制作的 GDD 成为游戏开发不可或缺的一部分,确保开发团队之间的清晰和对齐。

GDD 与提案:比较视角

当谈到游戏设计文档(GDD)和提案时,它们在游戏开发过程中具有不同的目的。GDD 是一份深入文档,捕捉核心元素和复杂性,为开发团队提供详细指南。另一方面,提案是一种简洁而有力的演示,旨在激发兴趣和支持。虽然 GDD 在整个游戏创作过程中提供全面信息,但提案充当了一个预告片——一个令人兴奋的快照,以激发潜在利益相关者的兴趣。

提案:激发对游戏概念的激情

提案是一种强大的工具,旨在激发对游戏概念的兴趣和支持。它是一种简短、引人注目的演示,迅速传达游戏的核心。与 GDD 的详细性质不同,提案完全是关于创造即时的兴奋。它在吸引注意、激发好奇心和为与利益相关者的潜在合作奠定基础方面发挥着至关重要的作用。

以下图像作为我们游戏的提案,提供视觉表现来阐述概念:

图 1.1 – Fusion Fury 的提案

图 1.1 – Fusion Fury 的提案

你可以在 github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp 找到完整的 GDD。

理解 GDD 元素

如果你曾好奇视频游戏背后的魔法,你就在正确的位置。加入我们,开始对 GDD 元素的入门级探索,揭示游戏开发的神秘语言。以下列表显示了 GDD 的一些元素:

  • 游戏概念:

    • 描述游戏的核心思想和整体概念。

    • 定义游戏的设定、主题和主要目标。

  • 核心游戏机制:

    • 详细解释支配游戏的基本规则和交互。

    • 描述玩家如何参与和导航游戏世界。

  • 游戏功能特性:

    • 识别和详细阐述增强游戏体验的关键特性。

    • 包括独特卖点、特殊能力和创新方面。

  • 游戏玩法分解:

    • 游戏中不同阶段或级别的深入分解。

    • 概述玩家将遇到的进展和挑战。

  • 项目范围分解:

    • 清晰界定项目的范围,包括其限制。

    • 定义项目包含的内容,以及同样重要的是,不包含的内容。

  • 技术要求:

    • 与游戏开发所需的技术和工具相关的规格。

    • 包括有关平台、编程语言和软件要求的信息。

  • 艺术和音效资源:

    • 游戏所需的视觉和听觉元素的概述。

    • 描述角色设计、环境艺术、音效和音乐。

  • 用户界面(UI)设计:

    • 用户界面的设计,包括菜单、HUD 元素和导航。

    • 确保用户体验友好且视觉上吸引人。

  • 货币化策略

    • 讨论游戏如何产生收入(如果适用)。

    • 包括定价模式、应用内购买或其他收入来源。

  • 测试和质量保证

    • 测试游戏的策略,以识别和解决错误。

    • 确保游戏在发布前符合质量标准。

  • 营销和推广

    • 概述推广和营销游戏的计划。

    • 确定目标受众和建立认知的策略。

这些并不是 GDD 中唯一的元素,但这本书的重点不是这些。我们还提供了一些建议,特别是针对中期开发或独立游戏开发者。通常建议不要一开始就创建过于详细的 GDD,因为随着开发旅程的进行,你可能需要做出更改。

我们将在接下来的章节中创建游戏时使用这个游戏设计文档(GDD)。我们将学习如何理解 GDD,将其分解为任务,并组织它们以完成我们的游戏。

让我们讨论与玩家体验相关的一个关键方面,以及为什么它对我们游戏的成功至关重要。

玩家的体验如何?

欢迎来到游戏开发的核心,这里是魔法发生的地方——塑造玩家的体验。在本节中,作为本节的介绍,帮助您理解玩家体验,我们深入探讨创造吸引玩家并留下持久印象的沉浸式世界的艺术。玩家体验不仅仅是一个功能;它是你游戏灵魂的一部分,我们将引导您了解将提升玩家旅程的元素。

玩家体验,通常称为PX用户体验(UX),是 GDD 中概述的一个关键方面。它体现了玩家在沉浸于电子游戏时所经历的整体印象和情感。以下是 GDD 中与玩家体验相关的关键组成部分:

  • 情感投入

    • 描述游戏旨在引发的预期情感反应。

    • 确定不同游戏阶段的情感旅程。

  • 沉浸感

    • 详细说明游戏如何让玩家沉浸在其虚拟世界中。

    • 讨论如逼真图形、音效设计和叙事深度等特性。

  • 挑战 和难度

    • 概述挑战性游戏玩法和玩家参与度之间的平衡。

    • 定义难度曲线及其在整个游戏中的演变。

  • 奖励系统

    • 描述游戏如何奖励玩家的成就。

    • 包括得分、升级、成就或其他激励措施。

  • 玩家进步

    • 解释玩家如何进步,获得新能力或解锁内容。

    • 概述进步感和成就感。

  • 叙事影响

    • 讨论游戏故事如何对整体玩家体验做出贡献。

    • 解决叙事与游戏玩法机制的结合。

  • 用户界面(UI)反馈

    • 定义 UI 如何向玩家传达关键信息。

    • 确保提供清晰、直观的反馈,以增强整体体验。

  • 交互性

    • 描述玩家与游戏世界的交互程度。

    • 包括控制响应性、决策影响和玩家自主权。

  • 节奏

    • 概述了游戏节奏和流程,平衡紧张、放松和高潮。

    • 确保在整个玩家旅程中保持引人入胜的节奏。

  • 可访问性

    • 针对游戏如何适应不同技能水平和偏好的玩家。

    • 确保为多元化的观众群体提供包容性和积极的体验。

当你深入到创造玩家体验时,请记住,你的游戏的每个部分,无论是游戏机制还是叙事,都在吸引玩家方面发挥着作用。这不仅仅是创造一个游戏;这是关于与你的观众建立情感联系。通过精心设计的玩家体验,你的游戏从简单的娱乐转变为进入你虚拟领域的玩家难忘的旅程。

在下一节中,我们将探讨构建我们的 Unity 项目、任务组织和引入版本控制的重要性。

让我们谈谈项目组织

让我们跳入项目组织——这是独立开发者和团队成员都必备的一项技能。本节揭示了使用 Git 进行版本控制和 Hack N Plan 进行项目协调的秘诀。掌握项目组织可以提高效率,无论你是单独工作还是团队合作。从这里开始,为更顺畅的游戏开发之旅奠定了基础。所以,拿起你的工具包,让我们深入其中,让组织成为你的超级力量!

无论单独工作还是团队合作,整理你的游戏项目可以节省时间并增加灵活性。在游戏开发中采用结构化的方法可以帮助你保持进度并简化工作流程。这就像是在你的旅途中拥有了一张地图——简化了挑战的导航并确保了更顺畅的开发路径。我们将一起踏上这段旅程,保持混乱远离,为高效的游戏创作铺平道路。

掌握高效开发的项目结构

让我们深入探讨我们将如何构建我们的项目。我们将涵盖三个主要方面:

  • 发现使用版本控制系统进行无缝协作的重要性。

  • 在 Unity 中构建和整理项目,提供实用的技巧和最佳实践。

  • 利用 Hack N Plan 高效地组织和管理工作任务。

在接下来的几节中,我们将探讨每个要点,以获得有效项目组织的有价值见解和技能。

版本控制系统

在游戏开发中使用版本控制系统对于独立开发者和团队都至关重要。它就像是一个安全网,允许你跟踪变更,在出现问题时回滚到之前的版本,并与他人无缝协作。

即使单独工作,它也能防止意外错误,并提供一种结构化的方式来管理项目的发展。对于团队来说,它确保每个人都在同一页面上,减少冲突,简化协作,使整个开发过程更加顺畅和有序。本质上,它是一个让你的游戏开发之旅无烦恼、高效的工具。

在 Git、Perforce 和 Unity 官方版本控制 Plastic 等众多版本控制系统的大千世界中,本书将通过专注于 Git 和 GitHub 来简化内容。虽然你可以自由选择你喜欢的工具,但我们推荐使用 GitHub,以便更好地与提供的代码保持一致,并实现无缝的工作流程跟踪。

版本控制的工作原理

版本控制就像是你的游戏项目的安全网。想象一下,你正在尝试一个新功能,但它并不完全按预期工作。有了版本控制,你可以轻松地回滚到一个干净、功能正常的版本,撤销任何实验性更改。这就像是给你的项目配备了一个时间机器,让你可以无惧地迭代想法。

此外,如果你需要切换到主项目中的重大问题并协助处理,版本控制让你可以保存你的更改以供稍后使用。一旦紧急任务处理完毕,你可以无缝地恢复你的实验性工作。这个系统还可以防止在团队协作时意外覆盖。每次你提交工作,你都会检查最新更新,确保你不会与你的队友发生冲突。虽然一开始处理合并冲突可能听起来令人畏惧,但一旦掌握了技巧,它就是一个可管理的流程。

版本控制的最佳实践

无论你选择哪种版本控制系统,各种最佳实践都可以提高团队的效率。由于每个团队都有独特的要求,并非每个实践都适合所有团队。以下是一些版本控制的最佳实践:

  • 经常提交,但每次提交的增量要小。

    确保每次提交都对应一个特定的任务或票据,使更改保持专注且易于管理。这种做法使得在出现问题时,更容易识别和撤销任何负面更改,而不会影响到正面更改。

  • 保持对最新变化的了解。

    定期从仓库中拉取最新更新到你的工作副本中,以避免孤立工作并最小化遇到合并冲突的机会。将这一做法融入你的日常工作中,以实现更顺畅的协作和有效的版本控制管理。

  • 保持提交信息的清洁和描述性。

    清晰地传达每次提交的目的,使理解项目的历史更加容易。如果你使用任务票据系统,考虑在提交中包含票据号,以实现更好的可追溯性和协作。

在下一节中,我们将学习如何组织我们的 Unity 项目以及正确组织项目的重要性。

组织 Unity 项目

高效地组织 Unity 项目不仅简化了版本控制,还提高了整体团队协作,从而实现更流畅的工作流程。

在本节中,我们将了解如何构建我们的项目和组织我们的文件夹。我们还将了解各种命名规范以及如何使用它们。

文件夹结构

这里有一些组织 Unity 内部文件夹的最佳实践:

  • 文档命名规范和文件夹结构以方便文件组织;考虑使用风格指南或项目模板。

  • 在命名规范上保持一致性;避免偏差。普遍修订规则,并使用脚本自动化大规模更新。

  • 在文件和文件夹名称中避免使用空格,以防止与 Unity 的命令行工具出现问题。使用驼峰式作为替代。

  • 为测试或实验创建单独的文件夹,以保持非生产场景的有序。带有用户名的子文件夹可以帮助按团队成员划分工作区域。

  • 将内部资产与第三方资产分开,特别是如果是从资产商店或其他插件中获得的,因为它们可能有它们自己的项目结构。

虽然没有固定的规则来组织您的文件夹,但以下图 1.2显示了您可能如何构建 Unity 项目的示例:

图 1.2 – 功能结构(左)和垂直结构(右)

图 1.2 – 功能结构(左)和垂直结构(右)

这些设置围绕按资产类型、功能或系统对项目进行分类。虽然您不必使用这些特定的文件夹名称,但它们提供了一个有用的起点。

为了更有效和简化的组织方法,建议同时利用这两种方法。

在以下图中,我们可以使用组合结构以获得更好的方法:

图 1.3 – 组合结构

图 1.3 – 组合结构

好的,让我们通过实施一个自动化的系统来创建文件夹,从而简化我们的工作流程,提高项目管理的效率和节省时间。

在以下脚本中,我们将使用 Unity 编辑器逻辑来生成文件夹和子文件夹。

using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.IO;
public class CreateFolders : EditorWindow
{
    private static string projectName = "PROJECT_NAME";
    [MenuItem("Assets/Create Default Folders")]
    private static void SetUpFolders()
    {
        CreateFolders window = ScriptableObject.CreateInstance<CreateFolders>();
        window.position = new Rect(Screen.width / 2, Screen.height / 2, 400, 150);
        window.ShowPopup();
    }
    private static void CreateAllFolders()
    {
        List<string> folders = new List<string>
        {
            "Animations",
            "Audio",
            "Editor",
            "Materials",
            "Meshes",
            "Prefabs",
            "Scripts",
            "Scenes",
            "Shaders",
            "Textures",
            "UI"
        };
        foreach (string folder in folders)
        {
            if (!Directory.Exists("Assets/" + folder))
            {
                Directory.CreateDirectory("Assets/" + projectName + "/" + folder);
            }
        }
        List<string> uiFolders = new List<string>
        {
            "Assets",
            "Fonts",
            "Icon"
        };
        foreach (string subfolder in uiFolders)
        {
            if (!Directory.Exists("Assets/" + projectName + "/UI/" + subfolder))
            {
                Directory.CreateDirectory("Assets/" + projectName + "/UI/" + subfolder);
            }
        }
        AssetDatabase.Refresh();
    }
    void OnGUI()
    {
        EditorGUILayout.LabelField("Insert the Project name used as the root folder");
        projectName = EditorGUILayout.TextField("Project Name: ", projectName);
        this.Repaint();
        GUILayout.Space(70);
        if (GUILayout.Button("Generate!")) {
            CreateAllFolders();
            this.Close();
        }
    }
}

上述代码块显示了您在创建项目后可以在编辑器中使用的静态函数,以设置您想要的文件夹结构。

您还将有灵活性来修改名称、路径和整个结构。当您在编辑器中的项目标签页上右键单击鼠标时,我们将使用下拉菜单来方便地创建此文件夹结构。

图 1.4中,我们可以看到下拉菜单,从中我们可以选择创建默认文件夹选项来创建我们自己的默认文件夹:

图 1.4 – 从菜单创建默认文件夹

图 1.4 – 从菜单创建默认文件夹

一旦我们点击创建默认文件夹选项,您将看到以下面板,您可以在其中命名项目:

图 1.5 – 输入项目名称

图 1.5 – 输入项目名称

一旦命名了项目,点击 生成

命名规范

制定标准不仅仅是关于项目文件夹。为场景中的 GameObject 或项目文件夹中的 Prefabs 制定一致的命名约定可以简化团队内的协作。虽然不存在适用于所有 GameObject 的单一命名标准,以下是一些考虑因素:

  • 使用描述性名称:

    • 选择几个月后您还能记住的名称。

    • 确保其他人能够理解和发音这些名称。

    • 避免使用混淆的缩写和拼写错误,例如,不要使用 numBtn,而应使用 numberButtonnumericButton

  • 使用驼峰式/Pascal 标准命名:

    • 提高可读性和输入准确性,例如,使用 OutOfMemoryExceptiondateTimeFormat,避免使用 Outofmemoryexceptiondatetimeformat
  • 遵循设计文档的命名规范:

    • 使用设计文档中提到的确切拼写,例如,如果设计文档提到 HighSpellTower,则保持该拼写。
  • 适度使用下划线:

    • 通常,避免使用下划线,但在特定情况下它们是有用的。例如,在长标识符中的名称清晰度或避免名称冲突。

    • 使用下划线作为前缀,使其在字母表中排在前面。

    • 使用下划线表示特定对象的变体,例如:

      • 激活状态:EnterButton_Active

      • 纹理贴图:Foliage_Diffuse

在下一节中,我们将探讨通过利用预设来简化工作流程的技术,使导入过程更加高效:

工作流程优化

除了决定如何在 Assets 文件夹中存储您的资产之外,还有各种设计和开发决策可以增强您的工作速度,尤其是在使用版本控制时。我们将在以下小节中探讨这些内容。

使用预设

预设是您可以保存并应用于资产或组件的预定义设置或配置。预设帮助您快速将一致的设置应用于项目中的不同元素,节省时间并确保一致性。它们通常用于材质、灯光和其他 Unity 组件,以简化开发过程。当您在组件中应用设置时,您可以从检查器中选择预设窗口并创建一个新的预设以供以后应用。

图 1.6 中,您可以看到在检查器中如何选择预设窗口:

图 1.6 – 预设按钮

图 1.6 – 预设按钮

应用预设设置

使用预设非常简单——转到 选择预设 窗口——当你点击预设选择器图标(图 1.6)时,此窗口将出现,或者从 项目 窗口拖动预设到具有组件的 GameObject 上。

图 1.7 – 预设菜单

图 1.7 – 预设菜单

重要提示

当您应用预设时,就像将预设的设置复制到您的项目项中。预设和项目项之间没有链接,因此对预设的任何更改都不会影响之前应用了它的项目项。

要使用选择预设窗口应用预设,请按照以下步骤操作:

  1. 选择您想要应用预设的 GameObject 或资产。在检查器中点击预设选择器(滑块图标)。

图 1.8 – 点击此按钮选择预设

图 1.8 – 点击此按钮选择预设

  1. 选择预设窗口中,找到并选择您想要的预设。通过这样做,Unity 将所选预设应用到您的组件、资产或项目设置中。

  2. 关闭选择 预设窗口。

如果您将组件预设拖放到 GameObject 上,您可以执行以下任何一项操作:

  • 将其拖放到层次结构窗口中现有的 GameObject 上,Unity 将在复制预设属性的同时添加一个新的组件。

  • 将其拖放到层次结构窗口的空白区域,以创建一个具有预设属性的新空 GameObject。

  • 将其拖放到检查器窗口中现有组件的标题上,Unity 将复制预设属性。

  • 将其拖放到检查器窗口的空白区域,Unity 将在复制预设属性的同时添加一个新的组件。

分离您的资产

以下是一些关于您项目的通用提示:

  • 分解 Unity 场景:将级别分解为更小的场景以提高团队合作效率。

  • 使用 SceneManager 进行增量加载:在运行时,使用具有增量模式的 SceneManager LoadSceneAsync。

  • 利用预制件实现模块化:将工作分解为预制件以方便管理。

在这里,我们分享了一些有价值的提示,以帮助您项目顺利起步,确保有一个干净高效的基础。

HacknPlan 管理工具

现在,让我们谈谈 Hack N Plan 管理工具。这是一个至关重要的工具,它将使您组织和管理游戏开发变得更加容易。请继续关注,我们将探讨其功能,使您的项目管理更加简单和成功。

尽管存在许多管理工具,但出于本书的目的,我们将专注于演示 HacknPlan——一个专门为游戏开发团队设计的项目管理解决方案。作为中央枢纽,它服务于开发者、设计师和团队成员,提供一系列工具,以有效地规划、监控和监督创建视频游戏的复杂过程。HacknPlan 的关键亮点包括:

  • 任务管理:用户利用 HacknPlan 系统性地组织和监督任务,有效地进行分类。此工具非常适合游戏开发项目的动态和不断变化的特点。

  • 看板板:利用看板式系统,就像便利贴一样。HacknPlan 提供了任务在不同项目阶段(如待办进行中完成)移动时的直观视觉表示。

  • 与版本控制系统的集成:HacknPlan 与 Git 等版本控制系统无缝集成,促进协作并确保跟踪不同团队成员的贡献的变更。

  • 时间跟踪:HacknPlan 允许用户跟踪在各项任务上花费的时间,为项目时间表和资源分配提供洞察。

  • 团队协作:HacknPlan 通过允许任务相关的讨论、文件共享以及在平台内促进整体沟通,促进团队成员之间的协作。

  • 敏捷方法支持:与敏捷开发原则相一致,HacknPlan 使团队能够有效地适应变化,并通过迭代开发过程逐步改进。

  • 路线图规划:团队可以在 HacknPlan 上创建和可视化项目路线图,概述不同开发阶段的关键里程碑和目标。

  • 游戏设计文档:HacknPlan 经常包括创建和管理游戏设计文档的工具,确保项目相关信息集中存储,便于团队轻松访问。

以下图像显示了 HacknPlan 内的默认屏幕,我们可以开始为任务添加卡片以进行检查 图 1**.9

图 1.9 – HacknPlan 中的空项目

图 1.9 – HacknPlan 中的空项目

以下图像显示了组织任务的示例,检查 图 1**.10

图 1.10 – 组织卡片示例

图 1.10 – 组织卡片示例

随着我们结束对游戏开发中组织项目的探索,是时候总结我们所学的内容了。我们深入研究了使用版本控制无缝协作的基本要素,在 Unity 中高效地安排我们的游戏元素,以及使用 Hack N Plan 精通任务管理。

现在我们已经涵盖了这些关键方面,你正站在本章的结尾,手中拿着一套宝贵的技能工具箱。无论你是独行侠还是团队的一员,这些见解都将是你进入游戏开发世界的伴侣。

因此,当你关闭这一章时,请记住,有效的组织不仅仅是一项技能;它是成功游戏开发冒险的支柱。愿你的未来项目顺利,你的合作蓬勃发展,你的游戏留下持久的印象。祝你将这些课程应用到你的游戏制作努力中一切顺利!

摘要

在本章中,我们探讨了游戏设计的基础,介绍了 GDD 及其如何塑造游戏体验。转向 Unity 2024 中的项目组织,我们探讨了版本控制系统的细节,理解了它是如何工作的,并分享了团队合作的最佳实践。然后,我们讨论了 Unity 项目的组织,重点在于拥有一个井然有序的文件夹系统,使用一致的名称,并使工作更加顺畅。我们还学习了预设及其为何保持资产分离的重要性。

本章以对“Hack N Plan”的深入探讨结束,这是一个有助于团队协作、良好组织任务并使沟通有效的实用工具。现在,在了解了游戏设计基础、项目组织技巧和强大工具之后,我们已准备好进入游戏开发的实战部分。请加入我们,在下一章中我们将看到这些原则的实际应用!

在下一章“编写清晰和模块化的 C#代码”中,我们将更深入地探讨项目组织,重点关注清晰和模块化的代码结构。基于本章的基础,我们将通过我们自己的游戏开发中的实际例子来探索游戏设计原则。

一个关键亮点将是探索 SOLID 原则和其他软件基础,帮助我们编写遵循最佳实践的效率代码。本章旨在揭示稳健和灵活代码背后的原则,提供创建引人入胜且持久的游戏所需的工具。

准备好将这些原则付诸实践,开辟通往游戏开发艺术和科学之精通的道路。

第三章:为 Unity 游戏开发编写干净和模块化的 C#代码

欢迎来到第二章,我们将重点介绍为 Unity 游戏开发编写干净和模块化 C#代码的重要性。在接下来的页面中,您将学习编写清晰和高效代码的实用技能。我们将涵盖干净代码原则,强调可读性的重要性,并介绍行业最佳实践。您还将探索协作的约定和代码结构。此外,我们将深入研究重构和优化技术,以增强您的 C#代码的性能和可扩展性。到本章结束时,您将具备有效地编写、文档化、重构和优化 C#代码的专业知识,为成功的 Unity 游戏开发打下坚实的基础。

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

  • 编写干净代码的介绍

  • C#代码约定和可读性

  • C#代码重构和优化技术

技术要求

为了跟随我在本章中的学习,您需要以下内容:

编写干净代码的介绍

干净代码指的是组织良好、易于阅读和理解的代码。它就像写一个清晰的故事,任何人都可以跟随而不会迷失在令人困惑的术语或混乱的段落中。

在软件开发中,干净代码非常重要。首先,它使团队中的每个人都更容易理解代码,帮助他们顺利合作。其次,它节省了时间,因为当出现问题需要修复时,干净代码更简单。最后,它就像有一个干净的房间——它感觉更好,并且从长远来看更容易管理。在这个列表中,我们强调了采用干净代码的重要性:

  • 构建 AAA 游戏: 在您的编码之旅中是否遇到过“干净代码的力量”?好吧,它就像是烹饪高质量代码的秘密酱料,尤其是在您致力于创建顶级 AAA 游戏时。干净代码是构建在行业中脱颖而出的游戏的关键成分。

  • 与他人合作:在编码的世界里,我们永远不会孤单。无论你是公司中高标准的一部分,还是在项目上进行合作,清晰的代码心态是你的无声伴侣。你可能没有意识到你正在使用它,但只要有一点知识,你就可以通过使你的代码对团队中的每个人来说都清晰易懂,来在行业中脱颖而出。

  • 在应用清晰代码时的灵活性:本章的目标不是强迫你在每次都使用所有这些清晰的代码原则。有时你需要先进行编码,然后再进行整理。这些不是严格的规则;它们更像是在需要时可以取出的工具。如果一个功能很熟悉,你知道如何使用 SOLID 等原则来实现它,那就去做吧。但如果你不确定,从基础开始,完善你的代码,以便你能够更清楚地了解需要什么。

  • 每个程序员的挣扎:想象一下:你厌倦了无休止的编码和更新,在编码过程中遇到问题。更改一个功能变成了一场头痛,尤其是当代码变成一个混乱的拼图时。与测试人员打交道就像是一个永无止境的修改循环。如果这听起来很熟悉,你并不孤单。本章将指导你克服代码维护和更新的挑战。

  • 讲述故事的代码:有没有编写过一段代码,然后完全忘记了它的用途?这种情况发生在我们所有人身上。本章旨在教你如何编写讲述故事的代码——一个清晰易懂、易于跟随的故事,不会让你在以后挠头。

  • 模块化和效率技巧:准备好一些关于如何使你的代码模块化和高效的技巧和窍门。我将带你了解一些基本原则,并展示如何在现实场景中应用它们。此外,我们将深入研究示例——混乱的代码与清晰的代码——对于每个清晰的代码原则,给你一个在实际项目中实施这些想法的实际理解。

清晰的代码对于构建更好的软件至关重要。通过理解其重要性,你将更好地装备自己,以编写清晰、易懂和有效的代码。在下一节中,我们将了解编写清晰代码的原则。

编写清晰代码的原则

在面向对象编程(OOP)的世界里,清晰的代码建立在五个关键原则的基础上,被称为SOLID。这些原则作为指导灯,帮助我们编写不仅功能性强,而且以下方面的代码:

  • 易于阅读和理解:任何拿起你的代码的人都应该能够不费太多力气就理解其目的和逻辑。

  • 可维护性:修改和更新应该变得轻而易举,即使是对代码历史不熟悉的人也是如此。

  • 可扩展性和可重用性:在现有代码的基础上构建应该是直截了当的,促进代码重用,减少冗余。

让我给你讲一个故事。在我发现 SOLID 原则之前,我经常发现自己很难想出如何构建功能。到达终点就像一场混乱、杂乱无章的旅程。然后,SOLID 原则出现了,一切都变了。它就像一张地图,帮助我把思想和代码组织成清晰、结构化的路径。

但 SOLID 不仅仅是一个编写干净代码的工具;它赋予了我成为一个更好的问题解决者的能力。它将我的思维方式从“努力尝试”转变为“创造解决方案”。我从与代码搏斗的人变成了创造优雅解决方案的人。

作为软件开发者,我们的工作是解决问题。SOLID 成为你在这一战斗中的秘密武器。它为你提供了应对大多数挑战的框架。

然而,仅仅阅读关于 SOLID 的内容是不够的。真正的学习来自于自己应用它。拿起你现有的代码,深入其中!尝试将 SOLID 原则融入其中,看看它们如何改变你的工作。相信我,实践的经验将巩固你的理解,并解锁全新的开发技能层次。

单一职责原则(SRP)

让我们讨论一下在游戏开发背景下单一职责原则SRP)。SRP 建议一个类应该只有一个改变的理由,这意味着它应该只有一个责任。在游戏世界中,这转化为确保每个组件或类只负责游戏的一个方面,使代码库更加模块化和易于维护。

当我们开始编码时,通常会将所有逻辑都塞进一个承担许多工作的巨大类中。试图修复一个问题而不会弄乱该类中的其他事物或引入最终导致其他部分行为异常的新逻辑,这变得很棘手。

让我们从玩家控制器脚本的一个例子开始,它结合了各种责任,然后重构它以符合 SRP。

在下面的代码块中,我们可以看到旧的PlayerController类,它承担了许多责任:

public class PlayerController : MonoBehaviour
{
    private Animator playerAnimator;
    private RigidBody rigidBody;
    private void Start()
    {
        playerAnimator = GetComponent<Animator>();
        rigidBody = GetComponent<RigidBody>();
    }
    private void Update()
    {
        // Logic for handling animations
        playerAnimator.SetBool("IsRunning",playerInput.IsRunning());
        // Logic for handling player input
        if (Input.GetKeyDown(KeyCode.Space))
        {
            rigidBody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
        }
        // Logic for handling player movement
    }
}

让我们将这个大类拆分成更小的类,每个类都有自己的动作集。

在下面的代码块中,我们可以看到负责处理玩家动画的PlayerAnimation类:

public class PlayerAnimation : MonoBehaviour
{
    private Animator animator;
    private void Start()
    {
        animator = GetComponent<Animator>();
    }
    public void UpdateAnimation(bool isRunning)
    {
        animator.SetBool("IsRunning", isRunning);
    }
}

在下面的代码块中,我们可以看到PlayerMovement类,它负责处理玩家移动,以及PlayerInput类,它负责处理玩家输入:

public class PlayerMovement : MonoBehaviour
{
    public void Move(float horizontalInput)
    {
        // Logic for moving the player based on input
    }
     public void Jump()
    {
        // Logic for jumping the player based on input
    }
}
public class PlayerInput : MonoBehaviour
{
    public float HorizontalInput()
    {
        return Input.GetAxisRaw("Horizontal");
    }
    public bool IsJumping()
    {
        // Logic for determining if the player is jumping
        return Input.GetKeyDown(KeyCode.Space);
    }
    public bool IsRunning()
    {
        // Logic for determining if the player is running
        return Input.GetKey(KeyCode.LeftShift);
    }
}

在下面的代码块中,我们可以看到PlayerController类作为协调者,委托责任:

public class PlayerController : MonoBehaviour
{
    private PlayerAnimation playerAnimation;
    private PlayerInput playerInput;
    private PlayerMovement playerMovement;
    private void Start()
    {
        playerAnimation = GetComponent<PlayerAnimation>();
        playerInput = GetComponent<PlayerInput>();
        playerMovement = GetComponent<PlayerMovement>();
    }
    private void Update()
    {
        playerMovement.Move(playerInput.HorizontalInput());
        if (playerAnimation != null)
        {
            playerAnimation.UpdateAnimation(playerInput.IsRunning());
        }
        if (playerInput.IsJumping())
        {
            playerMovement.Jump();
        }
    }
}

PlayerController类的这个简化版本中,我们为移动、输入处理和动画分别创建了类,使PlayerController更加专注,并符合 SRP。每个类处理其特定的责任,增强了代码的组织性和清晰度。

开放-封闭原则(OCP)

让我们探讨在游戏开发背景下开放封闭原则OCP)。OCP 提倡一个类应该对扩展开放,同时对修改封闭。在游戏开发的背景下,这意味着能够在不修改现有代码的情况下引入新的功能或功能。这个原则在增强代码灵活性和可维护性方面发挥着关键作用,允许无缝地添加新元素到游戏中,而不会破坏现有的框架。

增强功能困境的一个例子如下。

想象一下,在你的游戏中有一个基本的增强功能系统,可以增加分数。使用 OCP,你可以创建一个具有常见功能的基础PowerUp类,例如激活和持续时间。然后,你可以为不同的特定增强功能创建子类,例如双跳或临时无敌。

这样,添加一个新的功能增强需要创建一个新的子类,而不需要修改现有的代码。你不会陷入一个僵化的系统——可能性是无限的!

OCP 允许你构建灵活、适应性强、易于维护的游戏。这就像拥有一个设计精良的构建套件,让你可以无限制地创建和扩展你的游戏世界。

在下面的代码块中,我们可以看到具有常见功能的基础PowerUp类:

public abstract class PowerUp
{
    public abstract void Activate(); // Common activation logic
    public abstract void Deactivate(); // Common deactivation logic
}

在下面的代码块中,我们可以看到DoubleJumpPowerUp的子类:

public class DoubleJumpPowerUp : PowerUp
{
    public override void Activate()
    {
        // Specific activation logic for double jump
    }
    public override void Deactivate()
    {
        // Specific deactivation logic for double jump
    }
}

在下面的代码块中,我们可以看到TemporaryInvincibilityPowerUp的子类:

public class TemporaryInvincibilityPowerUp : PowerUp
{
    public override void Activate()
    {
        // Specific activation logic for temporary invincibility
    }
    public override void Deactivate()
    {
        // Specific deactivation logic for temporary invincibility
    }
}

在下面的代码块中,我们可以看到PowerUpManager类正在使用增强功能:

public class PowerUpManager : MonoBehaviour
{
    private void Start()
    {
        // Example of using the power-up system
        PowerUp doubleJump = new DoubleJumpPowerUp();
        AddPowerUp(doubleJump);
        PowerUp invincibility = new TemporaryInvincibilityPowerUp();
        AddPowerUp(invincibility);
    }
    private void AddPowerUp(PowerUp powerUp)
    {
        powerUp.Activate();
        // Logic for adding power-up to the game
    }
    private void RemovePowerUp(PowerUp powerUp)
    {
        powerUp.Deactivate();
        // Logic for removing power-up from the game
    }
}

在这个 Unity 示例中,PowerUp类被扩展为特定的增强功能,如DoubleJumpPowerUpTemporaryInvincibilityPowerUpPowerUp 管理器类展示了如何添加和移除增强功能,每个增强功能在激活和去激活时都会记录一条消息。这种结构允许在不修改现有代码的情况下添加新的增强功能,遵循 OCP。

现在,有趣的部分开始了!我们可以使用这个系统将每个增强功能子类连接到它自己的预制件。当玩家抓取一个增强功能时,只有与该预制件关联的特定增强功能会被激活。这意味着添加新的增强功能变得轻而易举——只需创建一个新的子类和它的预制件,然后就是了!你扩展了游戏的可能性,而没有触及核心逻辑。虽然这个原则不仅仅适用于增强功能,你还可以用它来处理敌人、物品、能力——天空才是极限!所以,勇敢地前进,用 OCP 的力量构建你的精彩游戏!

Liskov 替换原则(LSP)

让我们探讨游戏开发领域内的里氏替换原则LSP)。LSP 主张用子类对象替换超类对象不应破坏程序的正确性。在游戏开发的背景下,这意味着使用派生类(子类)应无缝集成,而不损害基类的预期功能。这个原则确保了类之间的平滑可替换性,在游戏开发场景中提供了灵活性和易用性。

潜行敌人困境的一个例子如下。

假设你有一个名为Enemy的基类,它具有基本的移动和攻击行为。然后你创建了两个子类:GroundEnemyFlyingEnemy。LSP 确保这两个子类都表现出预期的敌人行为,以符合Enemy基类的定义进行移动和攻击。这意味着任何设计用于处理敌人的代码,如碰撞检测或伤害计算,都将无缝地与GroundEnemyFlyingEnemy实例一起工作。这种一致性简化了开发,并允许你专注于为每个子类创建独特的行为,而无需担心破坏核心功能。

在下面的代码块中,我们可以看到Enemy类的基类:

public class Enemy : MonoBehaviour
{
    public virtual void Move()
    {// Basic movement logic for all enemies
    }
    public virtual void Attack()
    {// Basic attack logic for all enemies
    }
}

在下面的代码块中,我们可以看到GroundEnemy的子类:

public class GroundEnemy : Enemy
{
    public override void Move()
    {// Specific movement logic for ground enemies
    }
    public override void Attack()
    {// Specific attack logic for ground enemies
    }
}

在下面的代码块中,我们可以看到FlyingEnemy的子类:

public class FlyingEnemy : Enemy
{
    public override void Move()
    {// Specific movement logic for flying enemies
    }
    public override void Attack()
    {// Specific attack logic for flying enemies
    }
}

在下面的代码块中,我们可以看到演示 LSP 的EnemyManager类:

public class EnemyManager : MonoBehaviour
{
    void Start()
    {
        // Creating instances of GroundEnemy and FlyingEnemy
        Enemy groundEnemy = new GroundEnemy();
        Enemy flyingEnemy = new FlyingEnemy();
        // Using LSP, treating both enemies as base class
        groundEnemy.Move();
        groundEnemy.Attack();
        flyingEnemy.Move();
        flyingEnemy.Attack();
    }
}

在这个 Unity 示例中,Enemy类作为基类,具有基本的移动和攻击方法。GroundEnemyFlyingEnemy子类扩展了基类,并为移动和攻击提供了特定的实现。EnemyManager类通过将两个子类的实例都视为基类的实例来演示 LSP,确保编写用于处理敌人的代码可以无缝地与GroundEnemyFlyingEnemy实例一起工作。

LSP(里氏替换原则)和 OCP(开闭原则)之间的区别是什么?

在游戏开发中,LSP 和 OCP 之间的关键区别在于它们的关注点和应用。

LSP 确保派生类可以无缝地替换其基类而不影响程序行为。在游戏中,这意味着不同类型的敌人(例如,地面和飞行敌人)应该是可互换的,而不会破坏预期的功能。

OCP 鼓励设计那些对扩展开放但对修改关闭的类。在游戏开发中,这允许在不更改现有代码的情况下添加新功能(例如,新类型的武器),从而促进灵活性和可维护性。

为了更好地说明它们的区别,这里有一个例子。在一个游戏系统中,考虑一个武器的基础类。遵循 LSP 允许在不破坏预期行为的情况下替换特定的武器类型,而遵循 OCP 则允许在不修改现有代码的情况下扩展系统以添加新的武器。

接口隔离原则 (ISP)

现在,让我们谈谈游戏世界中的接口隔离原则ISP)。ISP 建议一个类不应该被要求去做它不需要做的事情。简单来说,它鼓励创建小型的、任务特定的接口,而不是大型的、通用的接口。在游戏开发的情况下,这意味着设计适合每个类特定需求的接口。这有助于保持事情清晰,使代码更加专注,并允许在游戏开发中更容易地进行维护和更改。

让我们看看 NPC 接口困境的一个例子。

想象一下,在你的游戏中,每个 NPC 都有各种功能,比如漫步、交谈和交易。应用 ISP 确保每个 NPC 只需要实现与其特定行为相关的接口,避免不必要的函数。

没有 ISP,在下面的代码块中,我们可以看到INPC接口,它为所有 NPC 提供了通用方法,以及FriendlyNPCAggressiveNPC类,它们实现了INPC

public interface INPC
{
    void Wander();
    void Talk();
    void Trade();
}
public class FriendlyNPC : INPC
{
    public void Wander() { /* Implementation */ }
    public void Talk() { /* Implementation */ }
    public void Trade() { /* Implementation */ }
}
public class AggressiveNPC : INPC
{
    // Unnecessary implementations for Wander and Trade
    public void Wander() { /* Unnecessary Implementation */ }
    public void Talk() { /* Implementation */ }
    public void Trade() { /* Unnecessary Implementation */ }
}

应用 ISP,在下面的代码块中,我们将根据功能分离接口,FriendlyNPCAggressiveNPC类将实现相关的接口:

public interface IWanderable
{
    void Wander();
}
public interface ITalkable
{
    void Talk();
}
public interface ITradable
{
    void Trade();
}
public class FriendlyNPC : IWanderable, ITalkable, ITradable
{
    public void Wander() { /* Implementation */ }
    public void Talk() { /* Implementation */ }
    public void Trade() { /* Implementation */ }
}
public class AggressiveNPC : ITalkable
{
    public void Talk() { /* Implementation */ }
}

在这个修改后的例子中,应用 ISP 导致为不同的 NPC 功能创建了单独的接口。现在,每种 NPC 类型(友好或攻击性)现在只能实现与其行为相关的接口,避免了实现不必要的函数。这使得系统更加模块化和适应性强,因为不同的 NPC 类型可以遵循它们特定的接口,而不会受到无关方法的负担。

依赖倒置原则 (DIP)

让我们讨论一下在游戏开发中的依赖倒置原则DIP)。DIP 建议高层模块(例如,游戏逻辑)不应该依赖于低层模块(例如,特定实现),而两者都应该依赖于抽象(例如,接口或抽象类)。此外,它还促进细节应该依赖于抽象,而不是相反。

让我们来看一个武器管理困境的例子。

考虑一个游戏,其中WeaponManager负责处理玩家使用的不同类型的武器。如果不遵循 DIP,WeaponManager可能会直接实例化和管理特定的武器类,例如手枪和步枪。然而,应用 DIP 会改变这一情况。现在,WeaponManager依赖于一个抽象,比如说IWeapon,它代表了所有武器的共同功能。

在下面的代码块中,我们可以看到高级模块以及没有使用 DIP 的低级模块:

public class WeaponManager
{
    private Pistol pistol;
    private Rifle rifle;
    public WeaponManager()
    {
        pistol = new Pistol();
        rifle = new Rifle();
    }
    public void UseWeapons()
    {
        pistol.Fire();
        rifle.Fire();
    }
}
public class Pistol
{
    public void Fire() { /* Implementation */ }
}
public class Rifle
{
    public void Fire() { /* Implementation */ }
}

使用 DIP(依赖倒置原则),在下面的代码块中,我们可以看到高级模块和低级模块实现了抽象:

public interface IWeapon
{
    void Fire();
}
public class WeaponManager
{
    private readonly List<IWeapon> weapons;
    public WeaponManager(List<IWeapon> weapons)
    {
        this.weapons = weapons;
    }
    public void UseWeapons()
    {
        foreach (var weapon in weapons)
        {
            weapon.Fire();
        }
    }
}
public class Pistol : IWeapon
{
    public void Fire() { /* Implementation */ }
}
public class Rifle : IWeapon
{
    public void Fire() { /* Implementation */ }
}

在这个例子中,应用 DIP 允许WeaponManager依赖于抽象(IWeapon),使得在不修改高级模块的情况下,可以轻松地扩展新的武器类型。这种灵活性在游戏开发中至关重要,因为随着时间的推移,可能会添加新的功能和组件,而不会破坏现有代码。

在 Unity 中实现 SOLID 原则对于实现模块化的 C#代码至关重要,这是有效软件设计的关键方面。SOLID 原则通过将系统分解为自包含的组件来促进模块化,使得代码不仅易于理解、维护和测试,而且符合 LSP(里氏替换原则)。

模块化的重要性在于其增强代码组织的能力。应用 SRP(单一职责原则)确保每个模块只有一个职责,促进专注和模块化的代码库。OCP(开闭原则)支持在不更改现有模块的情况下扩展代码,允许无缝添加新功能。LSP(里氏替换原则)确保派生类可以在不影响程序行为的情况下替换其基类,促进了 Unity 代码的一致性和可预测性。

在 Unity 开发中,有效的代码组织涉及深思熟虑地使用命名空间和类。ISP(接口隔离原则)将接口定制为特定功能,促进简洁和模块化的设计。依赖注入DI),由 DIP 倡导,创建了松散耦合的模块,增强了适应性。总之,SOLID 原则,包括 LSP,指导了 Unity 中模块化和灵活的 C#代码的创建,确保了健壮、可维护和一致的代码库。在下一节中,我们将探讨游戏开发中的设计模式,并学习如何在我们的代码库中有效地实现它们。

理解游戏开发中的设计模式

设计模式是解决软件开发中常见问题的既定解决方案。在游戏开发中,它们为构建健壮、可维护和高效的游戏提供了宝贵的工具。下一节将概述设计模式及其类型。

有许多设计模式,每种都适用于特定的情况。以下是游戏开发中常见的一些类型。

创建型模式

创建型模式是一组设计模式,它们提供了结构化的对象创建方法,确保了灵活性和可重用性,同时保持了对象创建和使用代码之间的清晰分离。以下是一些这些模式:

  • 单例模式:确保在整个游戏过程中只存在一个类的实例。对于全局对象,如游戏管理器或音频播放器,非常有用。

  • 工厂方法模式:创建对象而不指定确切的类,促进灵活性和代码重用。

  • 对象池:预先分配和重用对象以提高性能,特别是对于经常创建的对象,如炮弹或敌人。

结构模式

结构模式关注于组织类和对象以形成更大的结构,从而在管理系统内实体之间的复杂关系时提供更好的组合和灵活性。以下是一些这些模式:

  • 享元模式:通过在它们之间转换调用,使不兼容的接口协同工作。对于集成外部库或自定义代码非常有用。

  • 装饰器:动态地向对象添加功能,而不需要通过子类化它,从而促进灵活的对象行为。

行为模式

行为模式关注于系统内对象之间的通信和交互,侧重于对象如何协作和分配责任以实现期望的行为和功能。这些模式有助于管理算法、关系和责任,以促进软件设计中的灵活性和可扩展性。以下是一些这些模式:

  • 观察者:允许对象订阅并通知其他对象的变化,促进通信和事件处理。

  • 策略模式:定义了一组算法,并将它们封装起来,以便在运行时进行切换。对于处理不同的玩家动作或敌人行为非常有用。

  • 状态:封装了基于对象内部状态的行为,允许根据状态进行行为变化。对于处理角色状态,如行走、跳跃或攻击,非常有用。

在接下来的几节中,我们将讨论每种类型的一个设计模式。

单例设计模式

单例模式确保一个类只有一个实例,并在整个应用程序中提供一个全局访问点到该实例。图 2**.1展示了单例结构:

图 2.1 – 单例设计模式结构

图 2.1 – 单例设计模式结构

在下面的代码块中,你可以看到 Unity 中 Singleton 实现的示例:

public class GameManager : MonoBehaviour
{
    // Static reference to the instance
    private static GameManager _instance;
    // Public property to access the instance
    public static GameManager Instance
    {
        get
        {
            if (_instance == null)
            {
                // If the instance is null, create a new instance
                _instance = new GameObject("GameManager").AddComponent<GameManager>();
            }
            return _instance;
        }
    }
    // Other GameManager properties and methods
    public void StartGame()
    {
        Debug.Log("Game Started!");
    }
}

在下面的代码块中,我们可以看到单例模式的使用示例:

public class PlayerController : MonoBehaviour
{
    private void Start()
    {
        // Accessing the GameManager instance
        GameManager.Instance.StartGame();
    }
}

在这个例子中,GameManager是一个单例,负责管理游戏状态。PlayerController类访问单个实例以启动游戏。虽然单例提供了全局访问和延迟初始化的好处,但开发人员应仔细考虑潜在的缺点,尤其是在大型项目中。

以下是一些使用单例模式的优点:

  • 全局访问:提供一个单一的全局访问点来管理和控制游戏中的特定方面,例如游戏状态或设置

  • 延迟初始化:实例仅在首次需要时创建,直到需要时才节省资源

  • 易于实现:Singleton 模式易于实现且广为人知,这使得开发者容易理解和使用

使用 Singleton 模式的一些缺点如下:

  • 全局状态:Singletons 引入了全局状态,过度使用可能导致紧密耦合和难以管理的全局状态

  • 滥用可能性:开发者可能会过度使用 Singletons,导致全局实例的激增,从而减少封装的好处

  • 难以测试:依赖于 Singletons 的代码测试可能具有挑战性,因为全局状态可能会影响单元测试的结果

然而,存在一个原则来解决 Singleton 问题,它被称为依赖注入。

DI 是一种设计模式,通过提供对象依赖项而不是让它们创建依赖项来解决与紧密耦合和全局状态相关的担忧。在 Unity 中,这通常通过构造函数注入或属性注入来实现。

以下是一些依赖注入(DI)的好处:

  • 减少耦合:通过注入依赖项,类对特定实现的依赖性降低,减少了紧密耦合

  • 可测试性:具有注入依赖项的类通常更容易测试,因为你可以为这些依赖项提供模拟或特定于测试的实现

  • 灵活性:可以注入依赖项的不同实现,从而在不修改现有代码的情况下轻松交换组件

DI 可以通过以下方式帮助缓解与 Singleton 模式相关的一些问题:

  • 减少全局状态:通过注入依赖项,你可以避免创建全局 Singletons,从而减少应用程序中的整体全局状态

  • 易于测试:依赖于注入依赖项的代码通常更容易测试,因为你可以用模拟对象或特定于测试的实例替换真实实现

  • 提高模块化:依赖注入鼓励模块化设计,其中组件松耦合,这使得理解和维护代码库更容易

Singleton 设计模式提供了一个类全局可访问的唯一实例,提供了便利,但可能导致紧密耦合和测试困难等问题。依赖注入(DI)通过允许对象从外部获得其依赖项来解决这些担忧,减少了对外部状态的依赖。这促进了松耦合,增强了可测试性,并通过解耦组件和简化对象生命周期的管理来提高代码的可维护性。

Flyweight 设计模式

Unity 中的 Flyweight 模式提供了一种通过在多个对象间共享公共数据来优化内存使用的解决方案。它允许你通过外部存储共享数据并在需要时引用它来高效地管理资源。图 2.2展示了 Flyweight 的结构:

图 2.2 – Flyweight 设计模式结构

图 2.2 – Flyweight 设计模式结构

在下面的代码块中,您将看到一个可以应用 Flyweight 模式的示例场景。

IWeapon 接口代表了武器的共享属性和行为:

 // Flyweight interface for weapons
public interface IWeapon
{
    void Fire();
}

Weapon 类实现了 IWeapon 接口,并作为一个具体的 Flyweight 类,代表单个武器:

// Concrete flyweight class for shared weapon properties
public class Weapon : IWeapon
{
     private string name;
     private int damage;
     private string sound;
     public Weapon(string name, int damage, string sound)
     {
         this.name = name;
         this.damage = damage;
         this.sound = sound;
     }
     public void Fire()
     {
          Debug.Log($"{name} fired - Damage: {damage} - Sound: {sound}");
     }
}

WeaponFactory 类作为 Flyweight 工厂,根据特定的键(例如,武器类型)管理和重用 flyweight 对象:

// Flyweight factory class to manage and reuse flyweight objects
public class WeaponFactory
{
    private Dictionary<string, IWeapon> weapons;
    public WeaponFactory()
    {
        weapons = new Dictionary<string, IWeapon>();
    }
    public IWeapon GetWeapon(string key)
    {
        if (!weapons.ContainsKey(key))
        {
            switch (key)
            {
                case "pistol":
                    weapons[key] = new Weapon("Pistol", 30, "Bang!");
                    break;
                case "shotgun":
                    weapons[key] = new Weapon("Shotgun", 50, "Boom!");
                    break;
                case "rifle":
                    weapons[key] = new Weapon("Rifle", 40, "Pew Pew!");
                    break;
                default:
                    throw new ArgumentException("Invalid weapon key");
            }
        }
        return weapons[key];
    }
}

GameClient 类展示了如何使用从工厂检索的 flyweight 对象,展示了 Flyweight 模式的可重用性和内存效率:

public class GameClient : MonoBehaviour
{
    void Start()
    {
        WeaponFactory weaponFactory = new WeaponFactory();
        // Using flyweight objects
        IWeapon pistol = weaponFactory.GetWeapon("pistol");
        pistol.Fire();
        IWeapon shotgun = weaponFactory.GetWeapon("shotgun");
        shotgun.Fire();
        IWeapon rifle = weaponFactory.GetWeapon("rifle");
        rifle.Fire();
        // Reusing flyweight objects
        IWeapon anotherPistol = weaponFactory.GetWeapon("pistol");
        anotherPistol.Fire();
    }
}

此示例模拟了一个游戏场景,其中不同类型的武器被表示为 flyweight 对象,工厂有效地管理这些共享对象以优化内存使用并提高性能。

以下使用 Flyweight 模式的优点:

  • 内存优化:通过共享常见数据,该模式减少了内存消耗,特别是对于大量类似对象。

  • 提高性能:共享减少了创建和管理冗余数据的开销,从而提高了性能

  • 简化代码:分离共享和独特数据,促进更干净、更易于维护的代码

以下使用 Flyweight 模式的缺点:

  • 复杂性:实现 Flyweight 模式引入了额外的复杂性,尤其是在管理共享和独特状态时

  • 潜在开销:虽然该模式提高了内存和性能,但它可能由于管理共享资源而引入开销

尽管有这些考虑,Flyweight 模式仍然是 Unity 项目中有效资源管理的宝贵工具,尤其是在需要内存优化的类似对象场景中。

Observer 设计模式

Unity 中的 Observer 模式通过允许对象订阅事件并在事件发生时接收通知,促进了对象之间的松耦合。这样,对象可以响应变化,而无需了解引发事件的特定对象细节。

图 2**.3 展示了 Observer 的结构。

图 2.3 – Observer 设计模式结构

图 2.3 – Observer 设计模式结构

让我们在 Unity 中创建一个简单的示例,以演示使用 Observer 模式实现健康系统的实现。

在下面的代码块中,我们可以看到 IHealthObserver Observer 接口和 IHealthSubject 主题接口:

// Observer interface
public interface IHealthObserver
{
    void OnHealthChanged(int health);
}
// Subject interface
public interface IHealthSubject
{
    event Action<int> OnHealthChanged;
}

在下面的代码块中,我们可以看到 HealthManager 类实现了 IHealthSubject

public class HealthManager : MonoBehaviour, IHealthSubject
{
    private int currentHealth;
    public int MaxHealth { get; private set; } = 100;
    // Event to notify observers when health changes
    public event Action<int> OnHealthChanged;
    private void Start()
    {
        currentHealth = MaxHealth;
    }
    // Method to damage the character
    public void TakeDamage(int damage)
    {
        currentHealth -= damage;
        currentHealth = Mathf.Clamp(currentHealth, 0, MaxHealth);
        // Notify observers about the health change
        OnHealthChanged?.Invoke(currentHealth);
        // Check for death condition
        if (currentHealth == 0)
        {
            Debug.Log("Character has died!");
            // Additional logic for character death...
        }
    }
}

在下面的代码块中,我们可以看到 UIObserver 类实现了 IHealthObserver

public class UIObserver : MonoBehaviour, IHealthObserver
{
    public void OnHealthChanged(int health)
    {
        // Update UI elements based on the received health value
        Debug.Log($"Health UI Updated: {health}");
        // Additional UI update logic...
    }
}

在下面的代码块中,我们可以看到 GameplayObserver 类实现了 IHealthObserver

public class GameplayObserver : MonoBehaviour, IHealthObserver
{
    public void OnHealthChanged(int health)
    {
        // Update gameplay mechanics based on the received health value
        Debug.Log($"Gameplay Updated: {health}");
        // Additional gameplay update logic...
    }
}

在下面的代码块中,我们可以看到在 Unity 中 Observer 模式的使用示例:

public class GameExample : MonoBehaviour
{
    private void Start()
    {
        HealthManager healthManager = new HealthManager();
        UIObserver uiObserver = new UIObserver();
        GameplayObserver gameplayObserver = new GameplayObserver();
        // Register observers with the HealthManager
        healthManager.OnHealthChanged += uiObserver.OnHealthChanged;
        healthManager.OnHealthChanged += gameplayObserver.OnHealthChanged;
        // Simulate damage to the character
        healthManager.TakeDamage(20);
    }
}

在此示例中,HealthManager类代表主题,而UIObserverGameplayObserver类代表观察者。当角色受到伤害时,HealthManager触发OnHealthChanged事件,通知所有已注册的观察者。每个观察者随后根据接收到的健康值更新其状态,展示了观察者模式的应用。

使用观察者模式的优点如下:

  • 提高解耦性:对象不依赖于彼此的实现细节,促进松散耦合和模块化

  • 增强可维护性:由于事件处理是集中的,并且观察者是解耦的,因此代码更容易理解和修改

  • 增加灵活性:允许动态地添加和删除观察者,使系统更能适应不断变化的需求

使用观察者模式的缺点如下:

  • 增加复杂性:与直接通信相比,引入了额外的抽象层,这可能会略微增加代码复杂性

  • 性能开销:事件处理涉及方法调用和可能的数据传输,这可能导致一些性能开销

总体而言,观察者模式是 Unity 中促进对象间通信和管理动态变化的有力工具。在涉及事件驱动行为的多数情况下,松散耦合和灵活性的好处超过了缺点。

此外,这里还有一些 Unity 中观察者模式的其它应用:

  • 为角色和敌人实现状态机

  • 根据游戏状态的变化(例如,得分、等级和库存)更新 UI 元素

  • 根据游戏中的特定事件触发动画或音效

记住,您可以在 GitHub 上找到所有示例以供参考。

并非在所有代码中都必须使用这些模式。它们是解决常见问题的解决方案,但有时,实现模式可能会无谓地使事情复杂化。关键在于首先关注解决当前的问题,然后再寻找最佳解决方案。在下一节中,我们将深入探讨编码约定并探讨编写清晰代码的最佳实践。

编码约定和最佳实践

保持代码的一致性和清晰性对于有效的开发和协作至关重要。遵循公认的约定和最佳实践可以增强代码的清晰度、可维护性和可读性。以下是 C#编码的关键方面概述:

C#命名约定:理解和实现 C#命名约定对于保持代码一致性和清晰性至关重要。让我们深入了解命名变量、方法、类和命名空间的最佳实践,以确保我们的代码既易于阅读又具有表现力:

  • 变量: 采用 camelCase (例如,playerScore, enemyHealth) 并选择描述性名称,传达变量的目的(例如,currentLevel, isGameOver)。除非广泛理解,否则避免缩写(例如,fps 代表每秒帧数)。

  • 方法: 使用 PascalCase (例如,StartGame, MovePlayer) 并确保方法名称精确反映其功能。对于面向动作的方法使用动词(例如,CalculateDamage, LoadLevel)。

  • : 使用 PascalCase 为类命名(例如,Player, EnemyController) 并避免使用通用名称,如 MyClassNewClass。选择描述性名称,代表类的目的。

  • 命名空间: 使用 PascalCase 为命名空间命名(例如,MyGame.Characters, Utility.Math) 并将代码组织成有意义的分层命名空间。

  • 有意义的和描述性的名称: 构建有意义的和描述性的名称是编写清晰易懂代码的基本要素。让我们探讨选择名称的指南,这些名称能够准确传达变量目的和类型,避免歧义并提高代码可读性:

    • 选择准确反映所表示实体的名称。

    • 避免使用模糊的名称,如 tempdata

    • 使用前缀和后缀来澄清变量类型(例如,isJumping, playerPosition)。

  • 代码格式化: 掌握代码格式化是编写干净和有组织代码的基本方面。让我们探讨关键元素,如缩进、间距和注释,以增强编程工作中的可读性和结构:

    • 缩进: 使用一致的缩进来提高可读性和结构。

    • 间距: 在运算符、关键字和括号周围引入适当的间距。

    • 注释: 包含注释以阐明复杂逻辑、澄清算法和记录代码功能。

  • 错误处理和异常管理: 错误处理和异常管理是软件开发的关键方面,确保在处理意外情况时具有鲁棒性和可靠性。让我们深入了解有效的策略,例如使用 try-catch 块和提供有意义的反馈,以优雅地管理错误并提高用户体验:

    • 实施健壮的错误处理,以优雅地管理意外情况。

    • 使用 try-catch 块来捕获异常并提供有意义的用户反馈。

    • 避免忽略错误以防止不可预测的行为。

  • 方法和类长度: 当涉及到方法和类长度时,在简洁性和清晰性之间保持平衡对于培养可维护的代码库至关重要。让我们探讨保持方法和类简洁的同时确保它们保持专注和易于理解,从而提高代码的可读性和可维护性的策略:

    • 努力使方法和类简洁且专注。

    • 避免创建处理所有内容的“单体类”,这会使它们难以理解和维护。

    • 将复杂的功能提取到单独的方法中,以提高清晰度和可重用性

  • 额外最佳实践:为了追求健壮和可维护的代码,除了基本准则之外,还需要接受额外的最佳实践。让我们深入了解策略:

    • 使用有意义的常量而不是魔法数字

    • 最小化全局变量的使用

    • 避免深层嵌套的代码和过多的缩进

在探索基本的编码约定和最佳实践的过程中,我们探讨了基本方面,如 C#命名约定,其中清晰和一致性是至高无上的。我们讨论了有意义的和描述性的名称的细微差别,掌握了代码格式的艺术,导航错误处理和异常管理,以及优化方法和类长度,同时发现了额外的最佳实践来改进代码库的健壮性和清晰度。

让我们通过示例探索一些重构技术。

重构技术

重构技术包括将长且复杂的方法分解成更小、更专注的函数,并消除重复代码,以遵循如不要重复自己(DRY)和保持简单,傻瓜(KISS)的原则,最终使 Unity 项目更加整洁和易于维护。

让我们看看 Unity 项目中可能表明需要重构的代码异味的一些例子。

示例 1:长且复杂的方法

在下面的代码块中,我们可以看到PlayerController类有一个长方法:

public class PlayerController : MonoBehaviour
{
    public void HandlePlayerInput()
    {
        // ... (code for handling input)
        if (isMoving)
        {
            // ... (code for player movement)
        }
        if (isShooting)
        {
            // ... (code for shooting logic)
        }
        // ... (more complex logic)
        if (isJumping)
        {
            // ... (code for jumping)
        }
        // ... (more code)
        if (isDucking)
        {
            // ... (code for ducking)
        }
        // ... (more code)
        if (isInteracting)
        {
            // ... (code for interacting with objects)
        }
        // ... (even more code)
    }
}

这里的代码异味是HandlePlayerInput方法过长,处理了多个任务,这使得维护变得困难。将其重构为针对特定玩家动作的较小、专用函数,例如移动、射击和跳跃。

重构后,在下面的代码块中,我们可以看到PlayerController类为每块逻辑都有方法,而不是一个大方法:

public class PlayerController : MonoBehaviour
{
    public void HandlePlayerInput()
    {
        HandleMovement();
        HandleShooting();
        HandleJumping();
        HandleDucking();
        HandleInteracting();
    }
    private void HandleMovement()
    {
        // ... (code for player movement)
    }
    private void HandleShooting()
    {
        // ... (code for shooting logic)
    }
    private void HandleJumping()
    {
        // ... (code for jumping)
    }
    private void HandleDucking()
    {
        // ... (code for ducking)
    }
    private void HandleInteracting()
    {
        // ... (code for interacting with objects)
    }
}

示例 2:重复代码

在下面的代码块中,我们可以看到EnemyAI类有重复的逻辑:

public class EnemyAI : MonoBehaviour
{
    public void AttackPlayer()
    {
        // ... (code for attacking player)
    }
    public void AttackAlly()
    {
        // ... (same code for attacking ally)
    }
    public void AttackBoss()
    {
        // ... (same code for attacking boss)
    }
}

这里的代码异味是攻击玩家、盟友和 Boss 的重复代码给维护带来了障碍。通过创建一个用于攻击的方法,并用不同的参数调用它来消除冗余。

重构后,在下面的代码块中,我们可以看到EnemyAI有用于攻击的通用代码:

public class EnemyAI : MonoBehaviour
{
    public void Attack(Entity target)
    {
        // ... (common code for attacking)
    }
    // Usage examples:
    // enemyAI.Attack(player);
    // enemyAI.Attack(ally);
    // enemyAI.Attack(boss);
}

这些重构示例遵循 DRY 和 KISS 原则,从而使得 Unity 代码更加整洁和易于维护。在接下来的几个要点中,我们将探讨 DRY 和 KISS 的定义:

  • DRY 原则:DRY 原则是一个软件开发概念,主张避免代码重复。它强调系统中的每一块知识或逻辑都应该有一个单一、明确的表示,以减少冗余。通过遵循 DRY,开发者旨在提高可维护性,减少错误发生的可能性,并提高代码的可读性。

  • KISS 原则:KISS 原则建议在设计决策中,简单性应是一个关键目标。它鼓励开发者优先选择简单、直接的解决方案,而不是复杂的解决方案。KISS 断言,简单性通常会导致更好的可理解性、可维护性,并降低出错的可能性。这个原则是对避免在解决问题时引入不必要的复杂性的提醒。

通过识别和解决 Unity 项目中的代码异味,我们确保代码更干净、更易于维护。通过例如分解长方法和消除重复代码的示例,我们遵循 DRY 和 KISS 原则,从而提高代码质量和可读性。

是时候展示你的知识了!尝试这些问题和挑战。

问题

  • 编写干净代码的主要目标是什么?

  • 实现一个单例模式来管理游戏设置,如音量、音乐音量和屏幕分辨率。确保在整个游戏中只有一个设置管理器实例。

  • 创建一个单例得分管理器,用于跟踪玩家在多个游戏关卡或场景中的得分。确保得分管理器实例在场景变化之间保持持久。

  • 在射击游戏中使用对象池实现轻量级模式,用于子弹。轻量级模式应有效地管理子弹对象的创建和重用,以最小化游戏过程中的内存开销。

  • 设计一个轻量级模式,用于在 2D 游戏中渲染基于瓦片的地图。通过重用类似瓦片类型的轻量级瓦片对象(如草地、水域和岩石)来优化渲染过程。

  • 开发一个基于观察者模式的系统,用于处理游戏中的事件,如玩家死亡、增益物品拾取和关卡完成。实现不同类型事件的观察者,并确保高效的事件广播。

  • 创建一个观察者模式实现,根据游戏事件动态更新 UI 元素。例如,使用玩家健康变化、得分增加和物品拾取的观察者来更新生命值条、得分显示和库存图标。

  • 结合单例、轻量级和观察者模式来设计玩家角色系统。使用单例模式处理玩家输入,使用轻量级模式高效管理玩家动画,使用观察者处理玩家状态变化(例如,健康、库存)。

  • 设计一个游戏系统(例如,库存管理、任务追踪)并选择最合适的设计模式(单例、轻量级、观察者等)来实现系统的各个方面。根据 SOLID 原则和可扩展性来论证你的设计决策。

摘要

为了总结本章内容,请记住,编写整洁且组织良好的 C#代码对于成功的 Unity 游戏开发至关重要。你获得的一些技能,如合理命名事物和逻辑地排列代码,将使你的游戏创作之旅更加顺畅。保持简单并避免重复代码可以使你的工作更容易理解和维护。应用这些想法将导致代码清晰易懂的游戏,使你成为一个更高效和有效的游戏开发者。重要的是要注意,向干净代码和最佳实践迈进是一个持续的过程。你不必在每一个项目中应用所有原则,但持续地将它们融入你的编码思维中将随着时间的推移提高你的技能。

现在,准备好进入下一章,你将探索 Unity 插件。你将了解如何识别和评估各种类型的插件,并将它们无缝集成到你的项目中。这些知识将使你能够增强游戏功能,节省开发时间,并使用 C#实现新的机制。因此,准备好进入第三章,在那里你将扩展你的 Unity 工具集并提升你的游戏开发技能。

第二部分:Unity 中高级 C#游戏开发技术

在本部分,你将探索各种 Unity 插件以增强游戏功能,使用 C#无缝集成插件,并丰富游戏机制。你将分析游戏机制,实现挑战系统,并使用 C#创建 AI 逻辑,以提供引人入胜的游戏体验。你还将应用 UI 设计原则,使用 C#制作视觉上吸引人的界面,确保在 Unity 游戏中提供最佳的用户体验和交互。

本部分包括以下章节:

  • 第三章, 使用 Unity 插件扩展功能

  • 第四章, 在 Unity 中使用 C#实现引人入胜的游戏机制

  • 第五章, 使用 C#为 Unity 游戏设计优化用户界面

第四章:使用 Unity 插件扩展功能

欢迎来到 第三章,我们将深入探讨 Unity 插件的世界。我们将探讨如何有效地将这些插件集成到您的项目中。本章旨在使您熟悉 Unity 生态系统中可用的不同类型插件,并指导您无缝地整合它们。通过掌握插件集成的基础知识,您将能够充分利用现有解决方案,增强游戏功能,并节省宝贵的发展时间。让我们开始探索,看看 Unity 插件如何显著提升您的游戏开发能力。

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

  • 理解 Unity 插件

  • 集成 Unity 插件

  • 使用 Unity 插件的最佳实践

技术要求

您需要以下内容来跟随本章:

理解 Unity 插件

在游戏开发的世界里,Unity 插件就像是开发者可以添加到他们的工具箱中的便捷工具。把它们想象成特殊的附加组件或额外功能,使构建游戏变得更加容易和有趣。

让我们在这里探讨这些可选升级如何提升您的游戏开发努力:

  • 对游戏开发的提升:Unity 插件是游戏开发世界中的必备工具。这些紧凑的代码包作为 Unity 的宝贵补充,就像无缝集成到您游戏中的专用成分。由同行开发者打造,这些插件在 Unity 社区中慷慨共享,为所有人带来益处。

  • Unity 插件在游戏开发中的重要性:Unity 插件的吸引力在于它们能够轻松地增强游戏开发。想象一下:您正在构建一个游戏,您想象着拥有极其流畅的动作或令人叹为观止的特殊效果,如耀眼的爆炸。而不是与复杂的代码搏斗以实现这些元素,Unity 插件提供了一个解决方案。它们通过利用熟练开发者的专业知识来节省时间和精力,让您享受成果。

  • 扩展功能 – 将游戏提升到新的高度:通过插件扩展功能就像赋予你的游戏超级能力。它超越了基础功能,允许你无需深入研究从头开始的所有复杂性,就能整合各种功能。这就像是给你的游戏升级,使其更加有趣和吸引人。

Unity 插件在游戏开发领域发挥着至关重要的作用,为开发者提供了一套工具箱,以增强 Unity 的功能。区分两个主要的插件类别至关重要:核心插件和额外插件。核心插件是 Unity 固有的,提供开箱即用的基础功能。另一方面,额外插件作为可选升级,允许你根据项目的具体需求定制工具箱。为了更好地理解这些区别,让我们考虑以下比较:

插件类型 描述
核心插件 随 Unity 预包装,提供基础功能
额外插件 根据特定项目需求可选的升级选项

在本章中,我们将优先考虑额外的插件,因为它们在增强项目方面发挥着至关重要的作用,深入探讨如何无缝扩展和集成它们到你的项目中。我们将重点关注新的输入系统Cinemachine——作为重要的额外插件,它们是增强游戏而不深入研究核心功能的重要选择。

在下一节中,我们将开始介绍如何安装插件以及如何使用 C#脚本扩展它们。

集成 Unity 插件

让我们深入了解将外部插件无缝集成到 Unity 项目中的过程。发现这项技能如何解锁一个充满可能性的世界,通过精细的角色动作、电影般的视觉效果等,提升你的游戏开发体验。深入其中,提升你的项目。

我们将从新的输入系统及其如何用于处理角色的输入开始介绍。

新输入系统

Unity 通过两个系统处理输入:较旧的输入管理器,它是编辑器的一部分,以及较新的输入系统包。老旧的输入管理器是 Unity 核心的组成部分,如果你选择不安装输入系统包,它将直接可用。输入系统包提供了一个更新的视角,它允许你使用任何输入设备来控制你的 Unity 内容,取代了 Unity 的传统输入管理器。安装新的输入系统包非常简单——只需使用包管理器。我们将在本节中安装并使用它。

在以下表格中,我们将比较旧的输入系统与新的输入系统,突出它们的关键区别:

功能 输入管理器 输入系统
设备支持 限于键盘、鼠标和游戏手柄 针对所有设备的统一 API
输入动作 基本按钮和轴映射 带有触发器和组合的复杂动作
架构 基于轮询的连续检查在固定时间间隔(如每帧)检测输入状态的变化,以实时响应用户操作 事件驱动依赖于基于用户操作的触发回调或事件,促进模块化和高效处理输入事件,无需连续轮询
性能 可能会缓慢 高效且响应迅速
可扩展性 封闭系统 开源且可扩展

对更多探索感到兴奋吗?期待下一节,我们将深入探讨配置系统、创建个性化动作和释放其全部潜力。

注意

请注意,新输入系统与 Unity 2019.4 及以上版本兼容,并需要.NET 4 运行时。使用旧.NET 3.5 运行时的项目不受支持。

在以下图中,您可以在包管理器中选择输入系统并安装它:

图 3.1 – 包管理器面板中的输入系统包

图 3.1 – 包管理器面板中的输入系统包

完成安装过程后,Unity 将询问您是否想要启用新后端。通过选择,Unity 将启用新后端并禁用旧后端,Unity 编辑器将重新启动。您可以在以下图中看到警告信息:

图 3.2 – 安装新输入系统后的警告信息

图 3.2 – 安装新输入系统后的警告信息

请注意,您可以选择同时启用旧系统和新系统。为此,将Active Input Handling设置为Both

Player设置中找到相关设置(导航至Edit | Project Settings | Player),具体在Active Input Handling下。您可以根据需要调整此设置;然而,请注意,进行更改将需要重新启动编辑器。

图 3.3 – 项目设置中的输入设置

图 3.3 – 项目设置中的输入设置

现在我们已经介绍了新输入系统,让我们探索其实现。

实现 Unity 的新输入系统

在本小节中,我们的重点转向 Unity 新输入系统的实际应用。准备好通过我们引导的逐步实施过程进行动手学习。在本节结束时,您将具备将新输入系统无缝集成到 Unity 项目中的技能和知识,增强控制和响应性。让我们动手操作,一起深入了解实施此强大工具的实际步骤。

在 Unity 的新输入系统中,InputActions 对于定义和结构化输入控制(如键盘键、鼠标按钮和控制器输入)至关重要,这些控制绑定到特定的输入绑定,并组织成逻辑分组,称为 PlayerInput 组件。这些动作通过回调和事件集成到 GameObject 中,允许通过回调和事件高效地处理输入事件。InputActions 支持重新绑定和覆盖,使玩家能够自定义输入绑定,同时保持一个增强模块化和可重用性的输入系统架构,与 Unity 的传统输入系统相比。让我们使用新的输入系统。

  1. 在你的项目文件夹内创建一个新的输入动作(创建 | 输入动作),如图所示:

图 3.4 – 从创建面板选择输入动作

图 3.4 – 从创建面板选择输入动作

  1. 然后,你可以打开这个输入动作,并会出现一个新的面板,如图所示:

图 3.5 – 输入动作面板

图 3.5 – 输入动作面板

  1. 动作类型值更改为透传控制类型向量 2,如图所示:

图 3.6 – 动作属性

图 3.6 – 动作属性

  1. 然后,选择添加移动的绑定,正如你在下一张图中可以看到的:

图 3.7 – 选择移动绑定

图 3.7 – 选择移动绑定

  1. 在这里,你可以重命名移动绑定的名称。你还可以开始设置路径下拉菜单中每个过程的输入键,如图所示:

图 3.8 – 移动绑定

图 3.8 – 移动绑定

  1. 新输入系统的魔法在于你可以为同一动作在不同设备上分配多个绑定,如图所示:

图 3.9 – 箭头绑定

图 3.9 – 箭头绑定

  1. 你也可以为跳跃连击添加新的动作,但为此,你需要将动作类型值更改为按钮。你的最终设置应该看起来像这样:

图 3.10 – 添加跳跃和连击

图 3.10 – 添加跳跃和连击

  1. 作为此输入动作资产的最后一步,你需要回到检查器中的输入动作,并为其选择生成 C# 类

图 3.11 – 生成输入类

图 3.11 – 生成输入类

  1. 然后,我们将创建一个包装脚本,从输入中获取回调并调用新动作以执行移动:

    我们将创建一个包装器来使用新的输入系统,首先在我们的 PlayerInput 脚本中实现游戏玩法映射动作,如下所示:

    public class PlayerInput : MonoBehaviour , GamePlay.IGamePlayMapActions
    
  2. 此外,我们还将为这个动作注册回调并创建事件,以便我们可以在后续的 PlayerMovement 脚本中使用它们,如下面的代码块所示:

     private GamePlay gameplayControls;
     public static UnityAction onJump = delegate { };
     public static UnityAction onDash = delegate { };
     public static UnityAction<Vector2> onMovement = delegate { };
            private void OnEnable()
            {
                if (gameplayControls == null)
                {
                    gameplayControls = new GamePlay();
                    gameplayControls.GamePlayMap.SetCallbacks(this);
                }
                gameplayControls.GamePlayMap.Enable();
            }
            private void OnDisable()
            {
                gameplayControls.GamePlayMap.Disable();
            }
    
  3. 现在,魔法就在这里发生。当我们获取动作回调并调用我们的事件时,我们将在 PlayerMovement 脚本中订阅这些事件,如下面的代码块所示:

    public class PlayerMovement : MonoBehaviour
        {
            private void OnEnable()
            {
                PlayerInput.onJump += Jump;
                PlayerInput.onDash += Dash;
                PlayerInput.onMovement += MovementInput;
            }
            private void OnDisable()
            {
                PlayerInput.onJump -= Jump;
                PlayerInput.onDash -= Dash;
                PlayerInput.onMovement -= MovementInput;
            }
    

    这是 PlayerMovement 脚本中的移动功能:

      private void MovementInput(Vector2 input)
            {
                movementVector = input;
            }
            private void MovePlayer()
            {
                Vector3 movement = new Vector3(movementVector.x , 0f , movementVector.y) * moveSpeed * Time.deltaTime;
                transform.Translate(movement);
            }
            private void Jump()
            {
                if (isGrounded)
                {
                    playerRigidbody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
                    isGrounded = false;
                }
            }
            private void Dash()
            {
                if (canDash)
                {
                    Vector3 dashVector = new Vector3(movementVector.x, 0f, movementVector.y).normalized;
                    playerRigidbody.AddForce(dashVector * dashForce, ForceMode.Impulse);
                    canDash = false;
                    Invoke(nameof(ResetDash), dashCooldown);
                }
            }
            private void FixedUpdate()
            {
                MovePlayer();
                CheckGrounded();
            }
            private void CheckGrounded()
            {
                isGrounded = Physics.Raycast(groundChecker.position, Vector3.down, groundDistance, groundLayer);
            }
            private void ResetDash()
            {
                canDash = true;
            }
    

哇!新的输入系统现在被用于玩家移动。

讨论高级技术

在接下来的章节中,我们将讨论新输入系统的更多高级技术。让我们通过查看交互和处理器来探索 Unity 输入系统的功能,这些功能有助于在激活动作之前调整输入信号:

  • 交互:交互在原始输入信号到达动作之前对其进行修改或过滤。Unity 的输入系统提供了一系列内置的交互,如 Tap(轻触)、Slow Tap(慢触)和 Press(按下),每个都针对特定的用例。例如,我们可以使用 Multi Tap 来实现双跳或激活特殊能力,而 Press 可以帮助解决游戏中的部分谜题。

    在下面的图中,你可以找到一个应用交互的列表:

图 3.12 – 交互

图 3.12 – 交互

  • 处理器:处理器在交互之后但在动作触发之前应用于输入数据。它们允许你操纵输入数据,例如缩放、反转或平滑模拟值。处理器有助于输入行为的微调;你可以在控制、绑定和动作上应用它们。

    在下面的图中,你可以找到一个应用处理器的列表:

图 3.13 – 处理器

图 3.13 – 处理器

在深入了解 Unity 的输入系统后,我们现在理解了交互和处理器如何细化输入信号,增强我们游戏开发中的控制机制。在下一节中,让我们深入探讨 Cinemachine,这是 Unity 中的一个重要工具,它以电影化的风格彻底改变了游戏开发。

Cinemachine

让我们谈谈 Cinemachine,这是 Unity 中的一个游戏改变者,它将你的游戏开发提升到电影化的水平。如果你是 Unity 开发者,Cinemachine 是你用于轻松管理动态摄像机移动、制作电影场景以及增强玩家在虚拟世界中导航方式的必备工具。

在其核心,Cinemachine 引入了虚拟摄像机,它们就像你在数字舞台上的个人摄像团队。无需复杂的摄像机脚本——Cinemachine 简化了这一过程,使得引导玩家视角变得容易。无论你想要充满动作的镜头、宁静的风景,还是沉浸式的叙事时刻,Cinemachine 都让你能够轻松地扮演导演,无需头痛。

Cinemachine 的好处

按照以下方式探索 Cinemachine 在 Unity 游戏开发中提供的功能,以实现无缝和引人入胜的摄像机控制和叙事:

  • 直观控制:Cinemachine 提供了用户友好的界面和直观的控制,消除了复杂相机脚本的需求,使得所有技能水平的开发者都能使用。

  • 轻松的视角引导:使用 Cinemachine,您可以轻松引导玩家视角,从动态动作序列到宁静的风景,而无需深入研究复杂的代码。

  • 通过程序噪声实现逼真效果:Cinemachine 通过程序噪声将逼真感引入场景,提供诸如在关键时刻相机抖动等微妙而影响深远的效果,从而增强整体游戏体验。

  • 自动构图组件Composer 组件自动调整相机的位置和视野FOV)设置,确保关注关键元素,简化构图过程,并节省宝贵的发展时间。

  • 无缝时间轴集成:Cinemachine 与 Unity 的 Timeline 无缝集成,使得创建电影化序列变得简单,从而提供更沉浸和以叙事驱动的游戏体验。

  • 增强的叙事能力:除了相机系统之外,Cinemachine 还作为创意盟友,增强叙事能力,使游戏对玩家更具吸引力和难忘。

总结来说,Cinemachine 提供了一系列功能,旨在简化相机控制和增强 Unity 游戏开发中的叙事,提供直观的控制、轻松的视角引导、程序噪声带来的逼真效果、Composer 组件的自动化构图、无缝的时间轴集成和增强的叙事能力。

在我们的游戏中使用 Cinemachine

让我们以 Cinemachine 为起点,探索如何无缝将其集成到我们的游戏中。在本小节中,我们将学习如何安装 Cinemachine 并设置虚拟相机,以增强场景管理和动态相机运动。

在以下图中,您可以看到包管理器面板,您可以从其中选择和安装包,并等待编辑器完成脚本编译:

图 3.14 – 从包管理器安装 Cinemachine

图 3.14 – 从包管理器安装 Cinemachine

Unity 在 Cinemachine 包中提供了各种相机。让我们讨论一些这些相机及其相应的用途:

  • Freelook 相机(CinemachineFreeLook)

    • 用法:Freelook Camera 提供了在 3D 环境中创建动态和电影化相机运动的灵活控制。它常用于角色探索、动作序列和沉浸式游戏体验,其中流畅的相机运动增加了深度和参与感。

    • 主要功能:允许进行多轴旋转、可调整的跟随和注视目标、可定制的阻尼以实现平滑过渡,以及为不同相机行为定义多个机架配置的能力。

  • 虚拟相机(CinemachineVirtualCamera)

    • 用法:虚拟相机是 Cinemachine 中的基础相机工具,提供对构图、构图和行为精确控制的选项。它适用于各种场景,包括角色跟踪、场景构图、电影场景和脚本化相机序列。

    • 主要功能:提供目标跟踪选项、平滑过渡的阻尼设置、可自定义的 FOV、景深DOF)以及各种混合模式,以实现无缝的相机过渡和效果。

  • 2D 相机 (CinemachineVirtualCamera):

    • 用法:CinemachineVirtualCamera 的 2D 相机变体专门为 2D 游戏开发设计,提供与 3D 虚拟相机类似的功能,但针对 2D 环境进行了优化。它非常适合平台游戏、横版滚动游戏以及其他需要动态相机控制的 2D 项目。它非常适合平台游戏、横版滚动游戏和其他需要动态相机控制的 2D 项目。

    • 主要功能:支持 2D 特定设置,如正交模式、像素级相机设置、像素吸附、以及针对 2D 游戏机制优化的跟随和死区。允许平滑跟踪 2D 角色、视差效果和 2D 空间中的电影式相机运动。

在以下演示中,我们将实现虚拟相机,以突出从 Unity 默认相机切换到 Cinemachine 相机时发生的具体变化。

Cinemachine列表中选择虚拟相机。当你尝试添加组件时,主相机将由 Cinemachine 管理:

图 3.15 – 虚拟相机选项

图 3.15 – 虚拟相机选项

选择虚拟相机后,CinemachineBrain组件将被添加到MainCamera游戏对象中,如图图 3.16 所示。CinemachineBrain组件协调多个虚拟相机,管理它们的激活、混合和行为,以实现平滑过渡和动态相机控制,这对于在 Unity 项目中使用 Cinemachine 创建沉浸式和视觉上引人入胜的场景至关重要:

图 3.16 – CinemachineBrain 组件

图 3.16 – CinemachineBrain 组件

此外,它还会在场景中为这个虚拟相机创建一个新的游戏对象,该对象包含CinemachineVirtualCamera组件,如图所示:

图 3.17 – CinemachineVirtualCamera 组件

图 3.17 – CinemachineVirtualCamera 组件

总结来说,你成功地将 Cinemachine 集成到你的游戏中,允许进行简化的相机管理和动态虚拟相机运动,以增强你的游戏视觉体验。

使用 Cinemachine 增强游戏动态性 – 添加震动效果

利用 Cinemachine 提升我们的游戏体验,我们现在将探索效果的集成,特别是关注使用CinemachineImpulseListener组件来整合震动效果。这个 Unity Cinemachine 包中的关键组件作为其他 Cinemachine 模块脉冲信号的至关重要接收器,将它们转换成游戏中的影响力和听觉效果。其主要功能涉及监听由碰撞或爆炸等事件触发的脉冲信号,使开发者能够应用可定制的参数,如强度和持续时间,以提供沉浸式反馈体验。与 Cinemachine 模块无缝集成,CinemachineImpulseListener组件通过向游戏事件提供同步和动态响应,增强了游戏玩法和电影效果,从而显著提升了整体沉浸感和参与感玩家体验。

震动效果在游戏设计中是一个极具影响力的元素,对整体玩家体验的贡献显著。无论应用于模拟火焰、碰撞或其他游戏元素,此效果都增加了一层动态性和参与感。有效地集成震动效果可以增强玩家在游戏中的沉浸感,创造一个更具吸引力和愉悦的体验。

我们将在以下步骤中开始实现此功能:

  1. 我们将首先点击添加扩展。它将显示如下菜单:

图 3.18 – 扩展菜单

图 3.18 – 扩展菜单

  1. 然后,我们点击CinemachineImpulseListener组件将其添加到我们的相机中。

    我们可以调整此组件内的值以获得更好的游戏效果,如图所示:

图 3.19 – CinemachineImpulseListener 组件

图 3.19 – CinemachineImpulseListener 组件

您可以在 Unity 官方文档中了解更多关于这些值的信息:docs.unity3d.com/Packages/com.unity.cinemachine@2.3/manual/

小贴士

对于大多数 Unity 组件,当鼠标悬停在变量名上时,会显示工具提示。

然后,我们需要将CinemachineImpulseSource添加到一个游戏对象中。在我们的例子中,我们可以轻松地将其添加到玩家游戏对象中,因为大多数交互都将来自这个玩家。Unity 的 Cinemachine 包中的CinemachineImpulseSource组件是一个多功能的工具,用于生成脉冲信号,以模拟游戏中的影响性事件。通过定义强度和持续时间等参数,我们可以创建一系列效果,如相机震动、控制器震动或屏幕闪烁。与 Cinemachine 的其他组件无缝集成,CinemachineImpulseSource通过允许对事件进行动态响应和调整效果以实现沉浸式和吸引人的玩家体验来增强游戏玩法和电影体验。其定制选项和脚本功能使我们能够根据游戏的美学和机制定制效果,为整体游戏世界增添深度和交互性。

我们需要在我们的脚本中引用CinemachineImpulseSource以开始使用它:

图 3.20 – CinemachineImpulseSource 组件

图 3.20 – CinemachineImpulseSource 组件

  1. 现在,我们将使用PlayerEffects脚本,该脚本将订阅PlayerShoot射击事件以生成脉冲。

    在下面的代码中,我们将生成脉冲,当玩家射击火焰时:

    [RequireComponent(typeof(CinemachineImpulseSource))]
        public class PlayerEffects : MonoBehaviour
        {
            private CinemachineImpulseSource cinemachineImpulse;
            private void OnEnable()
            {
                PlayerShoot.onFire += ApplyShootFireEffect;
            }
            private void OnDisable()
            {
                PlayerShoot.onFire -= ApplyShootFireEffect;
            }
            private void Start()
            {
                cinemachineImpulse = GetComponent<CinemachineImpulseSource>();
            }
            private void ApplyShootFireEffect()
            {
                cinemachineImpulse.GenerateImpulse();
            }
        }
    

就这样!现在,每当玩家射击时,效果都会被应用。

动态电影体验 - 使用 Cinemachine 实现无缝相机融合

Cinemachine的另一个应用在于其能够同时管理多个相机并在运行时无缝地在它们之间切换。这种功能在需要为特定游戏事件创建专用相机的场景中非常有价值。例如,你可以设计一个特殊的相机,当玩家遇到 Boss 时触发,玩家进入 Boss 房间时自动激活。

在下面的图中,你可以在 Cinemachine 组件中找到添加自定义融合的选项。点击创建资产以生成一个新的可脚本化对象,该对象将负责管理相机之间的过渡,以及协调过渡过程中的缓动动作:

图 3.21 – Cinemachine 组件中的自定义融合部分

图 3.21 – Cinemachine 组件中的自定义融合部分

如以下图所示,使用可脚本对象 CinemachineBlenderSettings,您可以在相机之间添加过渡。Unity Cinemachine 包中的 CinemachineBlenderSettings 组件在场景中协调虚拟相机之间的平滑过渡中起着关键作用。通过定义混合曲线、权重参数和混合技术,我们可以控制相机混合的速度、风格和视觉主导性,从而在游戏玩法或电影序列中实现无缝且引人入胜的相机运动。支持优先级、触发机制和广泛的定制选项,CinemachineBlenderSettings 使我们能够创建符合游戏美学和叙事的动态和沉浸式相机过渡,从而增强整体玩家体验和沉浸感:

图 3.22 – CinemachineBlenderSettings 可脚本对象

图 3.22 – CinemachineBlenderSettings 可脚本对象

要利用此功能,您可以通过增加当前相机的优先级并降低前一个相机的优先级来启用或禁用相机的游戏对象或调整当前相机的优先级。

SwitchCamera() function, which accepts an Enum parameter representing the camera type. This enables us to selectively activate the desired camera based on the specified type:
   public class CameraManager : Singlton<CameraManager>
    {
        // Dictionary to map enum values to Cinemachine virtual cameras
        public GenericDictionary<CameraType, CinemachineVirtualCamera> cameraDictionary = new GenericDictionary<CameraType, CinemachineVirtualCamera>();
        // Reference to the currently active virtual camera
        private CinemachineVirtualCamera currentCamera;
        void Start()
        {
            SwitchCamera(CameraType.PlayerCamera);
        }
        // Function to switch between virtual cameras using the enum
        public void SwitchCamera(CameraType newCameraType)
        {
            // Disable the current camera
            if (currentCamera != null)
            {
                currentCamera.gameObject.SetActive(false);
            }
            // Enable the new camera based on the enum
            if (cameraDictionary.ContainsKey(newCameraType))
            {
                currentCamera = cameraDictionary[newCameraType];
                currentCamera.gameObject.SetActive(true);
            }
            else
            {
                Debug.LogWarning("Camera of type " + newCameraType + " not found in the dictionary.");
            }
        }
    }
    // Enum to represent different cameras
    public enum CameraType
    {
        PlayerCamera,
        BossCamera,
        // Add more camera types as needed
    }

您可以在我们的 GitHub 仓库中找到所有代码,其链接在 技术要求 部分中提及。

注意

我选择使用枚举而不是字符串以提高性能效率。

以下代码概述了各种相机类型的枚举:

public enum CameraType
    {
        PlayerCamera,
        BossCamera,
        // Add more camera types as needed
    }
PlayerCollision class:
namespace FusionFuryGame
{
    public class PlayerCollision : MonoBehaviour
    {
        private void OnTriggerEnter(Collider other)
        {
            if (other.CompareTag("BossArea"))
            {
                CameraManager.Instance.SwitchCamera(CameraType.BossCamera);
            }
        }
        private void OnTriggerExit(Collider other)
        {
            if (other.CompareTag("BossArea"))
            {
                CameraManager.Instance.SwitchCamera(CameraType.PlayerCamera);
            }
        }
    }
}

小贴士

由于 Unity 插件众多,选择适合您项目的正确插件至关重要。

总结来说,Cinemachine 为 Unity 开发者提供了直观的相机控制、轻松的视角引导、通过程序噪声实现的逼真效果、自动构图、无缝时间轴集成以及增强的叙事能力。在本节结束时,我们探讨了其功能和优势,为在游戏开发中利用其力量做准备。

使用 Unity 插件的最佳实践

在将插件集成到您的项目中之前,彻底探索其功能、理解其文档、评估兼容性和潜在影响、评估特定功能、跟踪发布、检查版本兼容性、维护项目完整性、设置测试环境、为将来参考记录集成文档以及跟踪和解决集成过程中遇到的问题至关重要:

  • 探索插件功能:在添加插件之前,彻底探索其功能和功能

  • 理解文档:深入插件文档以清晰了解其功能

  • 兼容性和影响评估:评估插件与您的项目是否兼容,考虑性能和潜在冲突等方面

  • 功能评估:评估特定功能以确保它们符合您的项目需求

  • 保持插件更新:了解您集成插件的更新、错误修复和新功能

  • 版本兼容性检查: 确认插件与当前 Unity 版本兼容,在 Unity 更新期间保持谨慎

  • 维护项目完整性: 在对项目进行重大更改之前,备份整个项目以避免数据丢失

  • 测试环境: 创建一个专门的测试环境以评估插件更新或修改

  • 未来参考文档: 创建详细的集成文档,包括配置、设置和故障排除步骤

  • 问题跟踪和解决: 记录遇到的问题及其解决方案,以供将来参考

总结来说,探索、理解、评估、更新、检查兼容性、维护完整性、测试、记录文档和跟踪问题是在 Unity 项目中有效集成和管理插件的关键步骤。

摘要

在结束本章之前,我们已经涵盖了 Unity 插件的方方面面,了解了它们提供的基本和扩展功能。您还学习了如何使用 C#集成新的 Input System 和 Cinemachine,获得了增强游戏开发项目的实用技能。我们强调了在处理插件时采用最佳实践的重要性,为更高效地集成到项目中奠定了基础。当您反思保持代码整洁有序时,这些技能将在您继续成为熟练的 Unity 开发者之路上变得非常有价值。

展望下一章,“使用 Unity 中的 C#创建有趣的游戏机制”,准备好扩展您的游戏开发工具集。在了解清洁编码实践的基础上,您将探索如何通过富有表现力的 C#编程语言为您的游戏带来兴奋感。想象一下,将您对插件和组织化代码的知识无缝地融入引人入胜的游戏机制创作中。即将到来的章节承诺带来令人兴奋的挑战和发现,这将进一步增强您的 Unity 开发技能。准备好深入探索制作沉浸式和有趣游戏机制的世界,第四章。在这个技能提升的持续旅程中,祝您编码愉快!

第五章:在 Unity 中使用 C#实现引人入胜的游戏机制

欢迎来到第四章,我们将邀请您进入游戏开发的动态世界。随着您深入本章,您的旅程将从探索推动成功游戏机制的基础原则开始。这一初步步骤包括对游戏机制的了解,让您掌握构成引人入胜游戏玩法骨干的基本概念。

在此之后,您将无缝过渡到玩家行为和 AI 领域,然后再深入研究挑战和奖励系统。这段旅程揭示了制作互动和响应式游戏体验的艺术。

到本章结束时,您将不仅具备理论洞察力,还将拥有使用 C#在 Unity 中塑造沉浸式游戏冒险的实用技能。

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

  • 介绍游戏机制

  • 使用 C#实现玩家行为和 AI 逻辑

  • 使用 C#实现挑战和奖励系统

技术要求

您可以访问我们的专用 GitHub 仓库中的代码示例和项目文件:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp/tree/main/Assets/Chapter%2004

复制或下载仓库,以便您能够轻松访问本章中展示的代码。

介绍游戏机制

游戏机制是塑造游戏玩法规则的规则和系统。把它们想象成定义玩家体验的后台机制。它们对于制作引人入胜的游戏玩法至关重要,影响着从移动和战斗到故事展开的方方面面。成功的游戏,如拥有跳跃机制的马里奥或拥有方块排列挑战的俄罗斯方块,展示了精心设计的机制如何为玩家创造难忘且愉快的体验。

游戏机制不仅使游戏能够运行,而且通过玩家如何互动来微妙地讲述故事。无论是在复杂的战斗游戏中还是在解谜的平台游戏中,这些机制都增添了整体故事。当玩家遵循游戏规则时,他们不仅仅是旁观者;他们成为了故事的一部分。

因此,理解和创造游戏机制就像成为互动叙事的高手。游戏中每个按下的按钮或移动的动作都帮助故事推进,使每一次游玩都成为独特且个性化的旅程。

在下一节中,我们将讨论您需要了解的游戏机制的基本原则。

游戏机制的基本原则

现在,让我们谈谈使游戏有趣的重要元素。我们谈论的是塑造玩家如何享受乐趣的基本规则。这一切都关于找到正确的平衡,提供反馈,并确保玩家感到掌控。这些简单的事情将游戏转变为激动人心的冒险,每个动作都增加了乐趣。

平衡

游戏中的平衡就像确保每个人都能公平地享受乐趣。想象一下一款一个角色超级强大,其他人无法享受的游戏——这显然是不公平的,对吧?游戏开发者努力创造一个平衡的体验,让每个玩家或角色都有机会发光。以《守望先锋》为例。在这款游戏中,每个英雄都有独特的技能,没有哪个角色过于强大或弱小。正是这种谨慎的平衡确保了公平的竞技场,让每个人都能享受乐趣并为游戏的激动人心做出贡献。

此外,平衡不仅体现在角色上;它还扩展到整体游戏体验。想象一下一款关卡要么过于简单要么难以置信地困难的游戏——玩家很快就会失去兴趣。在挑战、难度和奖励上实现平衡,可以保持玩家的参与度。能够达到这种微妙平衡的游戏,为所有技能水平的玩家提供令人满意和愉快的体验,使他们想要继续玩游戏并探索游戏所能提供的内容。

反馈

在游戏世界中,反馈是游戏与你交流的方式,让你知道你的表现。当你做得正确时,它就像一个鼓励;当你需要改进时,它就像一个温柔的提醒。例如,在《我的世界》中,当你成功开采资源时,伴随着动作会有一个令人满意的声响。那个声音就是反馈,一个小的庆祝,告诉你你已经完成了任务。

想象一下在没有任何反馈的情况下玩赛车游戏——没有欢呼的人群,没有速度表指针的上升——这会有些奇怪,对吧?良好的反馈,无论是视觉的、听觉的还是触觉的,对于让玩家感到成就感并引导他们通过游戏挑战都是至关重要的。

此外,反馈还扩展到游戏中的叙事。你做出的选择应该有后果,游戏应该让你知道你的决定如何影响故事。有效的反馈在玩家和游戏世界之间建立了一种动态的联系,使每个动作都感觉有意义。无论是成功任务后的胜利音乐,还是根据你的决定在环境中发生的微妙变化,反馈都为游戏体验增添了深度,确保玩家保持参与并投入到他们正在探索的虚拟世界中。

玩家能动性

玩家自主权就像在游戏中拥有方向盘——你可以做出选择并控制你的虚拟冒险。在《上古卷轴 V:天际》中,这一原则非常突出。从一开始,你就决定你的角色将成为谁。你想要成为一个勇敢的战士、一个狡猾的小偷,还是一个强大的法师吗?游戏不会强迫你走一条特定的道路;相反,它让你自己塑造自己的故事。

这种选择自由不仅限于角色创建。随着你在游戏中前进,你会遇到各种任务和挑战,在这里,玩家自主权再次成为焦点。你可以决定如何应对情况——你想谈判、战斗还是悄悄绕过敌人?你的选择不仅影响直接的结果,还影响整个故事。你的《上古卷轴 V:天际》版本可能与别人的完全不同,因为玩家自主权允许多样化的体验。

拥有玩家自主权将游戏转变为不仅仅是预设结果的固定路径。它将其转变为你的故事,你的决定至关重要,游戏会适应你的选择。这种控制感和塑造独特冒险的能力使得玩家自主权成为创造沉浸式和具有个人意义的游戏体验的关键原则。

在下一节中,我们将了解代码和游戏机制之间的关系。

代码与游戏机制之间的联系

现在,这里有个有趣的部分——代码是将想法转化为游戏行动的关键。它就像一本规则手册,告诉游戏该做什么。如果你想创建一个玩家可以射击激光的游戏,你需要编写代码来实现。因此,代码和游戏机制之间的关系就像厨师为美味佳肴准备的食谱——代码引导游戏做到我们想要的样子。

理解这些编码基础就像是拥有了游戏制作王国的钥匙。它让你能够将你的游戏想法变为现实,并创造出各种酷炫的东西。此外,你越了解 C#,你就能让你的游戏做更多令人惊叹的事情。所以,准备好进入本章的编码世界吧——它并没有听起来那么复杂,而且是让游戏如此有趣的关键因素!

使用 C#实现玩家行为和 AI 逻辑

在本节中,我们将探讨游戏是如何制作的,观察玩家在游戏中的行为以及计算机控制的角色(我们称之为 AI)是如何思考的。弄清楚玩家的行为和游戏角色的反应是制作有趣游戏的关键。这就像给玩家一个剧本去遵循,就像戏剧中的演员一样。玩家让游戏变得生动,有点像演员使故事变得有趣。我们将探讨不同类型的游戏,检查玩家在各种情况下的行为,从大冒险到战术战斗。

然后,我们将检查 AI,这是制作游戏中的敌人、朋友和其他角色感觉真实的智能计算机技术。AI 不仅仅是代码;它就像魔法,使挑战变得有趣,敌人变得狡猾,朋友变得有帮助。我们将解释这种数字魔法的背后基本理念,有点像理解指挥家给管弦乐队提供的提示,引导游戏中的所有行动和反应。

因此,我们正踏上使用 C#——一种与计算机交流的时髦方式——使玩家行为和 AI 逻辑更不神秘的旅程。你将准备好制作感觉真实的游戏,保持玩家的兴趣,让你的游戏超级刺激!

  • 理解玩家行为设计:在玩家行为设计的领域,制作一个反应灵敏的游戏涉及到健康管理这一基本方面。健康,作为一个既适用于玩家也适用于敌人的通用概念,是极大地影响整体游戏体验的核心行为。为了实现一个模块化和可扩展的系统,我们必须引入一个IHealth接口,它封装了基本功能,例如跟踪最大健康值、当前健康值、受到伤害和恢复健康。通过采用这个接口,我们可以建立一个统一的方法来管理健康,这适用于玩家和敌人。这不仅简化了代码库,还允许随着游戏的演变轻松地进行扩展和修改。

  • 射击机制:除了健康之外,我们还将深入研究射击机制,这是射击游戏中一个关键玩家行为。我们不会选择一个简单的射击脚本,而是采用模块化方法,为子弹、弹丸和武器创建单独的组件。这种模块化设计提供了灵活性和可扩展性,使得引入新武器、调整弹丸行为和增强整体游戏动态变得更加容易。

  • AI 逻辑简介:过渡到 AI 逻辑,我们将探讨赋予游戏中的敌人生命力的基本概念。基本的 AI 原则包括理解 AI 在创造动态和具有挑战性的游戏中的作用。AI 系统成为决定敌人行为的关键组件,从简单的游荡到复杂的攻击模式。通过深入研究这些概念,我们将了解驱动 AI 控制实体的决策过程,从而丰富游戏体验的整体丰富性。

  • 编码玩家行为和 AI:从理论转向实践,我们将开始使用 C#脚本实现玩家行为和 AI 逻辑的实际应用。IHealthIDamage接口成为实现与健康相关的功能的基础,确保在多样化的游戏元素中保持一致和可管理的做法。随着我们学会分别处理子弹、弹丸和武器,模块化的射击系统将形成,这促进了代码的可重用性和可维护性。

为了加强学习,我们将在接下来的章节中进行实际操作演示,展示创建用于玩家行为和 AI 逻辑的 C#脚本的逐步过程。我们将熟练掌握设计和实现响应式玩家行为,并了解如何在 Unity 游戏开发环境中使动态 AI 角色栩栩如生。

让我们从编写IHealth接口并建立其基本逻辑开始。

编写 IHealth 和 IDamage 接口

在下面的代码块中,我们引入了IHealth接口,该接口包含最大和当前健康属性,以及设置最大健康、造成伤害和促进治疗的必要函数。在这里,我们将创建一个接口来管理整个游戏中的健康逻辑。我设计它,以便一旦实体实现此接口,它就会监督每个实体的健康。这种方法将简化实体之间的通信,并在我们创建IDamage接口时证明是有益的。在负责造成伤害的组件中实现IDamage将使我们能够无缝地影响健康组件:

namespace FusionFuryGame
{
    public interface IHealth
    {
        float MaxHealth { get; set; }   // Property for maximum health
        float CurrentHealth { get; set; }  // Property for current health
        void TakeDamage(float damage);  // Method to apply damage
        void SetMaxHealth();  // Method to set current health to max health
        void Heal();            // Method to apply healing
    }
}

接下来,我们将创建IDamage接口,它将包含一个处理伤害的中心函数。后续的类将实现此接口,内部处理伤害计算,并将结果伤害值传递给其他类,如下面的代码块所示:

namespace FusionFuryGame
{
    public interface IDamage
    {
        float GetDamageValue();  // Method to retrieve the damage value
    }
}

现在,我们必须将IHealth接口集成到玩家中。因此,我们将生成PlayerHealth组件并将其附加到玩家的GameObject上。PlayerHealth类将管理与玩家健康相关的所有功能,包括设置最大健康和处理伤害。当玩家的健康降至零或以下时,玩家死亡。它被设计为一个独立的类,你可以将其附加到玩家的 GameObject 上,以便与敌人进行通信,如下面的代码块所示:

namespace FusionFuryGame
{
    public class PlayerHealth : MonoBehaviour, IHealth
    {
        public static UnityAction onPlayerDied = delegate { };
        public float startingMaxHealth = 100;  // Set a default starting maximum health for the player
        public float healInterval = 2f;  // Time interval for healing
        public float healAmount = 5f;    // Amount of healing per interval
        private WaitForSeconds healIntervalWait;  // Reusable WaitForSeconds instance
        private Coroutine healOverTimeCoroutine;
        public float MaxHealth { get; set; }
        public float CurrentHealth { get; set; }

在前面的代码中,我包含了与玩家健康和治疗相关的必需变量。此外,一个协程将确保玩家在PlayerHealth类中逐渐恢复健康:

        void OnDestroy()
        {
            // Ensure to stop the healing coroutine when the object is destroyed
            if (healOverTimeCoroutine != null)
                StopCoroutine(healOverTimeCoroutine);
        }
        void Start()
        {
            SetMaxHealth();  // Set initial max health
            healIntervalWait = new WaitForSeconds(healInterval);
            StartHealingOverTime();
        }

让我们看看前面的代码:

  • 销毁时:此方法确保在玩家对象被销毁时停止治疗协程,以防止内存泄漏

  • 开始:此方法初始化玩家的健康参数,设置其最大健康值,创建用于治疗间隔的WaitForSeconds实例,并启动治疗协程:

        public void TakeDamage(float damage)
        {
            // Implement logic to handle taking damage
            CurrentHealth -= damage;
            // Check for death or other actions based on health status
            if (CurrentHealth <= 0) onPlayerDied.Invoke();
        }
        public void SetMaxHealth()
        {
            MaxHealth = startingMaxHealth;
        }
        public void Heal()
        {
            CurrentHealth += healAmount;
            CurrentHealth = Mathf.Min(CurrentHealth, MaxHealth);
        }
        private void StartHealingOverTime()
        {
            healOverTimeCoroutine = StartCoroutine(HealOverTime());
        }
        private IEnumerator HealOverTime()
        {
            while (true)
            {
                yield return healIntervalWait;
                Heal();
            }
        }
    }
}

让我们分析前面的代码:

  • 受到伤害:此方法处理玩家受到伤害时扣除健康的逻辑。它还会检查玩家的健康是否已达到零,如果需要,触发onPlayerDied事件。

  • 设置最大健康:此方法将玩家的最大健康设置为指定的起始最大健康值。

  • Heal:这个方法恢复玩家的健康。它通过指定的治疗量增加当前健康值,并确保玩家的当前健康值不超过最大健康值。

  • StartHealingOverTime:这个方法启动负责在一段时间内逐渐恢复玩家健康的协程。

  • HealOverTime:这个协程无限期地等待指定的治疗间隔,然后调用Heal方法来恢复玩家的健康。

现在,让我们检查PlayerCollision组件,了解玩家如何受到伤害。以下代码块展示了玩家直接从敌人或其投射物受到伤害的过程。这个类充当玩家健康组件和碰撞对象IDamage接口之间的桥梁。我们可以利用PlayerHealth并通过使用OnCollisionEnter方法通过IDamage接口获取伤害:

namespace FusionFuryGame
{
    public class PlayerCollision : MonoBehaviour
    {
        private PlayerHealth playerHealth;
        private IDamage enemyDamage;
        private void Start()
        {
            playerHealth = GetComponent<PlayerHealth>();
        }
        private void OnCollisionEnter(Collision collision)
        {
            if (collision.gameObject.CompareTag("Enemy") || collision.gameObject.CompareTag("EnemyProjectile"))
            {
                if (collision.gameObject.TryGetComponent(out enemyDamage))
                {
                    playerHealth.TakeDamage(enemyDamage.GetDamageValue());
                }
            }
        }
    }
}

现在,让我们看看这里使用的不同变量:

  • private PlayerHealth playerHealth;:这是同一 GameObject 上附加的PlayerHealth组件的引用。该组件管理玩家的健康。

  • private IDamage enemyDamage;:这是一个处理敌人或敌人投射物造成的伤害的接口引用。

  • Start:这个方法在初始化期间检索附加到同一 GameObject 上的PlayerHealth组件。

  • OnCollisionEnter:当发生涉及 GameObject 的碰撞时,这个方法会自动调用。它通过比较标签来检查碰撞是否涉及敌人或敌人投射物。如果碰撞涉及敌人或敌人投射物,它将尝试使用TryGetComponent从碰撞对象中检索IDamage组件。如果成功,它将调用PlayerHealth组件的TakeDamage方法来对玩家的健康造成伤害。

总体来说,这个脚本处理玩家角色与敌人实体或敌人投射物的碰撞。碰撞发生时,它会从碰撞对象中检索伤害值并将其应用于玩家的健康,确保游戏中的伤害管理得当。

在下一节中,我们将探讨玩家如何射击敌人。然而,在深入探讨之前,我们将实现一个射击系统,以确保玩家和敌人都能使用它。

实现射击系统

在本节中,我们将创建用于射击的类。在这里,我们将IDamage接口纳入BaseProjectile类中,该类作为所有弹药类型的基石。这允许我们计算将应用于健康组件的伤害。

在接下来的代码块中,我们正在配置伤害值,这将用于玩家或敌人。这是一个适用于所有对象的通用系统:

namespace FusionFuryGame
{
    public abstract class BaseProjectile : MonoBehaviour, IDamage
    {
        private float damage;
        public virtual void SetDamageValue(float value)
        {
            damage = value;
        }
        public float GetDamageValue()
        {
            return damage;
        }
    }
}

让我们看看这里使用的变量:

  • private float damage;这个变量存储与弹体相关的伤害值。

  • public virtual void SetDamageValue(float value):这个方法允许子类为弹体设置伤害值。它接受一个表示要设置的伤害的浮点参数value。当被调用时,它将提供的值分配给伤害变量。virtual关键字表示此方法可以被子类重写,以提供所需的专业行为。

  • GetDamageValue 方法:这个方法检索弹体的伤害值。它简单地返回存储在伤害变量中的值。

总体而言,这个抽象类为游戏中的弹体对象提供了一个蓝图。子类可以继承这个类,并通过重写SetDamageValue方法或添加所需的其他功能来自定义弹体的行为。GetDamageValue方法允许其他游戏组件在需要时访问弹体的伤害值,从而在整个游戏中实现一致的伤害处理。

接下来,我们可以创建BaseWeapon脚本,它足够灵活,可以被玩家和敌人使用,考虑到敌人也将拥有武器。每个武器都将关联一个附加的弹体,从而允许创建各种弹体类型。

此外,还有一个名为weaponPower的概念,这是一个变量,它因武器而异,影响施加的伤害。muzzleTransform作为射击弹体的点,而projectileForce决定了弹体的运动。

最后,我们必须定义Shoot函数,如下面的代码块所示:

namespace FusionFuryGame
{
    public abstract class BaseWeapon : MonoBehaviour
    {
        [SerializeField] protected BaseProjectile attachedProjectile;
        [SerializeField] protected float weaponPower;
        [SerializeField] protected Transform muzzleTransform;
        [SerializeField] protected float projectileForce;
        public virtual void Shoot( float fireDamage)
        {
          // Instantiate the projectile from the object pool
          GameObject projectileObject = ObjectPoolManager.Instance.GetPooledObject(attachedProjectile.tag);
          if (projectileObject != null)
          {
            // Set the position of the projectile to the gun's muzzle position
            projectileObject.transform.position = muzzleTransform.position;
            // Get the rigid body component from the projectile
            Rigidbody projectileRb = projectileObject.GetComponent<Rigidbody>();
            if (projectileRb != null)
            {
               // Apply force to the projectile in the forward vector of the weapon
               projectileRb.AddForce(muzzleTransform.forward * projectileForce, ForceMode.Impulse);
               // Modify the fire damage by adding the current weapon's power
               float modifiedDamage = fireDamage + weaponPower;
               // Apply damage and other logic to the projectile (consider implementing IDamage interface)
               attachedProjectile.SetDamageValue(modifiedDamage);
            }
            else
            {
               // Handle if the projectile doesn't have a rigid body
               Debug.LogWarning("Projectile prefab is missing Rigidbody component.");
            }
          }
        }
    }
}

让我们看看序列化字段:

  • protected BaseProjectile attachedProjectile:这指的是附加到武器上的弹体类型。它被序列化,以便在 Unity 的Inspector视图中进行赋值。

  • protected float weaponPower:这指的是武器的功率。它被序列化,以便在Inspector视图中进行调整。

  • protected Transform muzzleTransform:这指的是弹体生成的位置,通常是武器的枪口。

  • protected float projectileForce:这指的是当弹体从武器射出时施加的力。

  • Shoot:这个方法负责射击武器。首先,它尝试从对象池管理器中获取一个池化的弹体对象。如果检索到弹体对象,它将设置其位置为武器的枪口,并添加力以推动它向前。此方法还通过添加武器的功率来修改火灾伤害。最后,它将修改后的伤害和任何其他逻辑应用于弹体,可能通过实现IDamage接口。

总体而言,这个抽象类为在游戏中实现不同类型的武器提供了一个基础。子类可以继承这个类来创建特定的武器类型并按需自定义其行为。Shoot 方法处理项目的生成和发射,允许灵活和动态的武器功能。

下面的代码块提供了一个使用 BaseWeaponSimpleGun 类的示例。我们将用它来让玩家射击敌人,因此它将作为玩家的武器使用:

namespace FusionFuryGame
{
    public class SimpleGun : BaseWeapon
    {
        public override void Shoot( float fireDamage)
        {
            base.Shoot( fireDamage );
            //Add here special logic for the gun if needed
        }
    }
}

让我们分解一下代码:

  • SimpleGun:这个类代表游戏中的一种特定类型的枪。它继承自 BaseWeapon 类,表明它与其他武器共享特性和功能,但可能具有特定的行为。

  • Override Shoot

    • public override void Shoot(float fireDamage):此方法覆盖了在 BaseWeapon 类中定义的 Shoot 方法

    • base.Shoot(fireDamage) 语句调用了基类(BaseWeapon)中的 Shoot 方法,允许枪支执行在基类中定义的标准射击行为

总结来说,SimpleGun 类通过提供自己的 Shoot 方法实现扩展了 BaseWeapon 类的功能。这允许在利用基类提供的通用功能的同时实现特定的行为。

现在,让我们介绍 PlayerShoot 组件,它包含了射击逻辑。在这个上下文中,玩家等待输入动作并拥有当前武器。在 第六章,该章节专注于数据处理,我们将为武器统计数据创建可脚本化的对象。这样,我们可以用武器的统计数据替换武器的力量,利用从它那里衍生出的力量。我们还可以为同一武器设置不同的统计数据,如下面的代码块所示:

namespace FusionFuryGame {
    public class PlayerShoot : MonoBehaviour
    {
        public static UnityAction onFire = delegate { };
        [SerializeField] BaseWeapon currentWeapon;
        [SerializeField] private float fireDamage;
        [SerializeField] private float shootingInterval = 0.5f;  // Set the shooting interval in seconds
        private float timeSinceLastShot = 0f;
        private void Update()
        {
            timeSinceLastShot += Time.deltaTime;
        }
        private void OnEnable()
        {
            PlayerInput.onShoot += OnShootFire;
        }
        private void OnDisable()
        {
            PlayerInput.onShoot -= OnShootFire;
        }
        private void OnShootFire()
        {
            // Check if enough time has passed since the last shot
            if (timeSinceLastShot >= shootingInterval)
            {
                // Shoot in the forward vector of the weapon and pass player power stat
                currentWeapon.Shoot(fireDamage);
                // Reset the timer
                timeSinceLastShot = 0f;
                // Invoke the onFire event
                onFire.Invoke();
            }
        }
    }
}

让我们分解前面的代码:

  • onFire 事件

    • public static UnityAction onFire = delegate { };:这个静态事件在玩家射击时被触发。其他脚本可以订阅此事件,以便在玩家射击时执行操作。
  • 序列化字段

    • currentWeapon:此字段持有玩家当前使用的武器的引用

    • fireDamage:此字段表示玩家射击的伤害值

    • shootingInterval:此字段指定了连续射击之间的时间间隔

  • 事件订阅

    • OnEnable():当对象启用时,将 OnShootFire 方法订阅到 onShoot 事件

    • OnDisable():当对象禁用时,从 onShoot 事件中取消订阅 OnShootFire 方法

  • OnShootFire:此方法在玩家执行射击动作(onShoot 事件)时被调用。它检查自上次射击以来是否已经过去了足够的时间。如果是这样,它将触发当前武器的 Shoot 方法,重置射击计时器,并调用 onFire 事件。

总体而言,PlayerShoot类通过控制射击间隔、管理射击动作的事件以及将射击逻辑委托给当前武器来促进玩家的射击机制。

注意

确保为与玩家和敌人相关的弹道分配不同的标签。这可以防止它们相互碰撞时发生冲突。

目前,玩家具有射击和承受伤害的能力。在下一小节中,我们将深入探讨我们游戏的 AI 逻辑。

深入 AI 逻辑

欢迎来到AI 逻辑的世界!在本节中,我们将探讨将智能带入游戏角色的算法和决策过程。我们将了解 AI 逻辑如何增强导航、策略和动态交互,提升整体游戏体验。加入我们,一起揭开为更沉浸式虚拟世界构建智能行为的秘密。此外,我们将深入研究有限状态机的实现,为所有敌人创建不同的状态,实现行为之间的无缝转换。

在接下来的步骤中,我们将把NavMesh包集成到我们的项目中。然而,在深入 AI 逻辑之前,将导航包包含到项目中是至关重要的。按照以下步骤操作:

注意

在 Unity 2022 之前,导航是预实现的;然而,从 Unity 2022 开始,必须通过包管理器添加。

  1. 通过包管理器安装AI 导航包,如图图 4.1*所示:

图 4.1 – 通过包管理器安装 AI 导航

图 4.1 – 通过包管理器安装 AI 导航

  1. 安装完成后,您会注意到一个新菜单,该菜单允许您切换NavMesh表面的可见性并访问其他关于AI 导航的选项。此菜单集成在场景工具栏中,如图图 4.2所示。2

图 4.2 – 场景视图中的 AI 导航菜单

图 4.2 – 场景视图中的 AI 导航菜单

额外阅读

您可以探索AI 导航设置或在官方 Unity 文档中查找更多信息:docs.unity3d.com/Packages/com.unity.ai.navigation@1.1/manual/index.html

  1. 要开始使用此功能,我们需要将一个NavMesh Surface属性集成到场景中。您可以从创建菜单中选择,如图图 4.3*所示:

图 4.3 – 从创建菜单中选择 NavMesh Surface

图 4.3 – 从创建菜单中选择 NavMesh Surface

  1. 随后,NavMesh Surface将被包含,此时您可以继续烘焙表面。这指的是预先计算并存储导航数据以供 AI 路径查找的过程,如图图 4.4*所示:

Figure 4.4 – NavMeshSurface 组件中的 Bake 操作

图 4.4 – NavMeshSurface 组件中的 Bake 操作

注意

在开始烘焙过程之前,移除玩家和动态对象至关重要,以防止在最终烘焙中创建空隙。

  1. 您也可以通过在顶部栏的 Window 下的 Navigation 选项卡中导航来包含额外的 AI 代理。选择 AI 然后选择 Navigation,避免选择 Navigation (Obsolete) 选项,如图 图 4.5 所示:

Figure 4.5 – 选择导航

图 4.5 – 选择导航

  1. Navigation 选项卡中,您可以选择包含更多具有各种设置的 代理,从而增加敌人行为的多样性,如图 图 4.6 所示:

Figure 4.6 – 通过导航选项卡添加更多代理

图 4.6 – 通过导航选项卡添加更多代理

  1. 此外,您还有灵活性引入更多的 区域,为您的游戏提供不同的玩法。如图所示,您可以将区域指定为 可通行不可通行,甚至作为 跳跃 区域,以适应您游戏的具体需求:

Figure 4.7 – 导航选项卡中的区域

图 4.7 – 导航选项卡中的区域

  1. 现在我们已经熟悉了添加额外的区域或代理,我们不会对它们进行进一步修改。我仅提及它们作为信息。现在,让我们继续将 AI 集成到我们的游戏中。为此,我们必须将 Nav Mesh Agent 组件附加到敌人上以启用导航,如图 图 4.8 所示。我们有灵活性调整适合我们游戏的值,例如更改速度和 AI 达到目标时停止的条件。有关更多详细信息,请参阅官方 Unity 文档

Figure 4.8 – 敌人之一的 Nav Mesh Agent 组件

图 4.8 – 敌人之一的 Nav Mesh Agent 组件

安装包后,我们将启动 AI 逻辑。我们将首先建立 BaseEnemy 类,并为状态系统打下基础,因为我们打算构建一个有限状态机。

让我们从状态接口开始。以下代码块提供了基础结构:

namespace FusionFuryGame
{
    public interface IEnemyState
    {
        void EnterState(BaseEnemy enemy);
        void UpdateState(BaseEnemy enemy);
        void ExitState(BaseEnemy enemy);
    }
}

让我们更仔细地看看这段代码:

  • void EnterState(BaseEnemy enemy):

    • 此方法负责在敌人进入此状态时设置初始条件和行为。它接受一个 BaseEnemy 对象作为参数,使我们能够访问敌人的属性和方法。
  • void UpdateState(BaseEnemy enemy):

    • 此方法在敌人处于此状态时重复调用。它定义了敌人在此状态下应执行的逻辑和动作。同样,它接受一个 BaseEnemy 对象作为参数来操纵敌人的行为。
  • void ExitState(BaseEnemy enemy):

    • 当敌人退出此状态时,将调用此方法。它负责清理任何资源或重置与此状态相关的任何变量。与其他方法一样,它也接受一个 BaseEnemy 对象参数。

通过实现此接口,代表特定敌人状态的类可以定义它们进入、更新和退出这些状态的独特行为。这种方法允许模块化和组织化地管理敌人行为,使得在游戏中根据需要添加、删除或修改状态变得更容易。

让我们继续创建敌人组件,从 EnemyHealth 开始。此组件将集成 IHealth 接口,并处理所有与敌人相关的健康逻辑。查看以下代码块:

    public class EnemyHealth : MonoBehaviour, IHealth
    {
        [SerializeField] float startingMaxHealth = 100;  // Set a default starting maximum health for the Enemy
        private float maxHealth;
        private float currentHealth;
        [SerializeField] float healAmount = 5f;    // Amount of healing per interval
        [SerializeField] float healInterval = 2f;  // Time interval for healing
        private WaitForSeconds healIntervalWait;  // Reusable WaitForSeconds instance
        private Coroutine healOverTimeCoroutine;
        public UnityAction onEnemyDied = delegate { };
        public float MaxHealth
        {
            get { return maxHealth; }
            set { maxHealth = value; }
        }
        public float CurrentHealth
        {
            get { return currentHealth; }
            set
            {
                currentHealth = Mathf.Clamp(value, 0, MaxHealth);
                if (currentHealth <= 0)
                {
                    onEnemyDied.Invoke();
                }
            }
        }
        private void Start()
        {
            SetMaxHealth();  // Set initial max health
            healIntervalWait = new WaitForSeconds(healInterval);
            StartHealingOverTime();
        }
        public void SetMaxHealth()
        {
            MaxHealth = startingMaxHealth;
        }
        public void TakeDamage(float damage)
        {
            // Implement logic to handle taking damage
            CurrentHealth -= damage;
        }
        //we can also just heal in some states only
        public void Heal()
        {
            CurrentHealth += healAmount;
            CurrentHealth = Mathf.Min(CurrentHealth, MaxHealth);
        }
        private void StartHealingOverTime()
        {
            healOverTimeCoroutine = StartCoroutine(HealOverTime());
        }
        private IEnumerator HealOverTime()
        {
            while (true)
            {
                yield return healIntervalWait;
                Heal();
            }
        }
    }

让我们了解 EnemyHealth 组件:

  • startingMaxHealth: 敌人的默认起始最大生命值。

  • healAmount: 每个间隔的治疗量

  • healInterval: 治疗的时间间隔

  • healIntervalWait: 用于治疗的可重用 WaitForSeconds 实例

  • healOverTimeCoroutine: 治疗协程

  • maxHealth: 敌人的最大生命值

  • currentHealth: 敌人的当前生命值

  • TakeDamage(float damage): 处理敌人受到的伤害

  • SetMaxHealth(): 设置敌人的最大生命值

  • Heal(): 随时间治疗敌人

  • StartHealingOverTime(): 启动治疗协程

  • HealOverTime(): 治疗协程方法

接下来是 EnemyAnimations 组件,它负责管理敌人的动画。让我们深入其代码块:

  public class EnemyAnimations : MonoBehaviour
  {
      private Animator animator;
      private void Start()
      {
          animator = GetComponent<Animator>();
      }
      public void StartAttackAnimations()
      {
          animator.SetBool("IsAttacking", true);
      }
      public void StopAttackAnimations()
      {
          animator.SetBool("IsAttacking", false);
      }
  }

在这里,StartAttackAnimations()StopAttackAnimations() 管理攻击动画。

接下来,我们将在 EnemyCollision 类中实现敌人的碰撞逻辑。此类将处理与玩家的碰撞,使敌人能够受到伤害。查看以下代码块以获取详细信息:

    public class EnemyCollision : MonoBehaviour
    {
        private IDamage playerDamage;
        private EnemyHealth healthComponent;
        private void Start()
        {
            healthComponent = GetComponent<EnemyHealth>();
        }
        //we can also make layers for them and reduce calculations of collision in layer matrix in project settings
        private void OnCollisionEnter(Collision collision)
        {
            if (collision.gameObject.CompareTag("PlayerProjectile"))
            {
                if (collision.gameObject.TryGetComponent(out playerDamage))
                {
                    healthComponent.TakeDamage(playerDamage.GetDamageValue());
                }
            }
        }
    }

让我们看看 EnemyCollision 类做了什么:

  • playerDamage: 表示玩家造成的伤害

  • damage: 敌人与玩家碰撞时造成的伤害

  • OnCollisionEnter(Collision collision): 处理与玩家投射物的碰撞

敌人逻辑中的最后一个组件是 EnemyShoot。它负责使用附加武器发射投射物。在此处实现 IDamage 接口,以便将伤害值传递给玩家。请参阅以下代码块以获取详细信息:

    public class EnemyShoot : MonoBehaviour , IDamage
    {
        [SerializeField] float damage; //when the enemy collide with the player
        public BaseWeapon attachedWeapon;  // Reference to the attacted Weapon
        [SerializeField] float fireDamage; //when the enemy shoot the player
        public void FireProjectile()
        {
            attachedWeapon.Shoot(fireDamage);
        }
        public float GetDamageValue()
        {
            // You can implement more sophisticated logic here based on enemy stats
            return damage;
        }
    }

让我们看看 EnemyShoot 做了什么:

  • fireDamage: 敌人射击玩家时造成的伤害

  • attachedWeapon: 敌人附加武器的引用

  • FireProjectile(): 启动附加武器的射击

  • GetDamageValue(): 获取伤害值

现在,让我们创建一个BaseEnemy类,它是一个抽象类,定义了游戏中敌人基本的功能和属性。它将利用状态机逻辑,并包含对敌人射击和动画组件的引用。这个类促进了敌人不同状态之间的通信,使其适用于所有敌人:

[RequireComponent(typeof(EnemyHealth) , typeof(EnemyAnimations) , typeof(EnemyShoot)) ]
    [RequireComponent(typeof(EnemyCollision))]
    public abstract class BaseEnemy : MonoBehaviour
    {
        public Transform player;
        [HideInInspector] public NavMeshAgent navMeshAgent;
        // Reference to the current state
        protected IEnemyState currentState;
        // Define the different states
        public IEnemyState wanderState;
        public IEnemyState idleState;
        public IEnemyState attackState;
        public IEnemyState deathState;
        public IEnemyState chaseState;
        public float attackRange = 5f;
        [SerializeField] internal float chaseSpeed;
        [SerializeField] internal float rotationSpeed;
        internal EnemyAnimations animationComponent;
        internal EnemyShoot shootComponent;
        internal EnemyHealth healthComponent;
        protected virtual void Start()
        {
            // Initialize states
            wanderState = new WanderState();
            idleState = new IdleState();
            attackState = new AttackState();
            chaseState = new ChaseState();
            deathState = new DeathState();
            // Set initial state
            currentState = wanderState;
            // Get references
            player = GameObject.FindGameObjectWithTag("Player").transform;
            navMeshAgent = GetComponent<NavMeshAgent>();
            animationComponent = GetComponent<EnemyAnimations>();
            shootComponent = GetComponent<EnemyShoot>();
            healthComponent = GetComponent<EnemyHealth>();
            healthComponent.onEnemyDied += OnDied;
        }
        protected virtual void Update()
        {
            // Update the current state
            currentState.UpdateState(this);
        }

让我们更仔细地看看这段代码,以便我们理解它:

  • player: 对玩家Transform值的引用

  • navMeshAgent: 对导航NavMeshAgent组件的引用

  • currentState: 对敌人当前状态的引用

  • wanderState, idleState, attackState, deathState, chaseState: 敌人的不同状态(分别是游荡、空闲、攻击、死亡和追逐)

  • attackRange: 敌人可以攻击的范围

  • chaseSpeed: 敌人追逐玩家的速度

  • rotationSpeed: 敌人旋转的速度

  • Start()方法: 初始化状态,设置初始状态,并获取引用

  • Update()方法: 更新敌人的当前状态

现在,让我们深入了解负责在状态之间转换的状态机逻辑:

        public bool PlayerInSight()
        {
            Vector3 directionToPlayer = player.position - transform.position;
            float distanceToPlayer = directionToPlayer.magnitude;
            // Create a ray from the enemy's position towards the player
            Ray ray = new Ray(transform.position, directionToPlayer.normalized);
            RaycastHit hit;
            // Check if the ray hits something
            if (Physics.Raycast(ray, out hit, distanceToPlayer))
            {
                // Check if the hit object is the player
                if (hit.collider.CompareTag("Player"))
                {
                    // The player is in sight
                    return true;
                }
            }
            // No direct line of sight to the player
            return false;
        }
        public bool PlayerInRange()
        {
            Vector3 directionToPlayer = player.position - transform.position;
            float distanceToPlayer = directionToPlayer.magnitude;
            // Check if the player is within the attack range
            if (distanceToPlayer <= attackRange)
            {
                // Calculate the angle between the enemy's forward direction and the direction to the player
                float angleToPlayer = Vector3.Angle(transform.forward, directionToPlayer.normalized);
                // Set a cone angle to define the attack range
                float attackConeAngle = 45f; // Adjust this value based on your game's requirements
                // Check if the player is within the cone angle
                if (angleToPlayer <= attackConeAngle * 0.5f)
                {
                    // The player is in range and within the attack cone
                    return true;
                }
            }
            // Player is not within attack range or cone angle
            return false;
        }
        public bool IsIdleConditionMet()
        {
            return !PlayerInSight() && !PlayerInRange();
        }
        public void TransitionToState(IEnemyState newState)
        {
            currentState?.ExitState(this);
            currentState = newState;
            currentState?.EnterState(this);
        }
           private void OnDied()
        {
            healthComponent.onEnemyDied -= OnDied;
            // Trigger death logic if health reaches zero
            TransitionToState(deathState);
        }
    }

为了理解这段代码,让我们检查它的函数:

  • PlayerInSight()PlayerInRange(): 这些函数分别检查玩家是否在视线或范围内

  • IsIdleConditionMet(): 检查是否满足空闲的条件

  • TransitionToState(): 转换到新状态

  • OnDied()方法: 转换到死亡状态

现在,让我们转向状态。首先,我们将实现IEnemyStates,这样我们就可以包含基本方法。然后,在随后的代码块中,我们将开发IdleState的逻辑,详细说明敌人在此状态下将采取的行动。IdleState是所有敌人的默认状态。在这里,我们只是检查其他状态的条件,以便在满足相应逻辑时转换到它们:

   public class IdleState : IEnemyState
    {
        private float idleTime = 3f; // Set the duration for which the enemy stays idle
        private float timer; // Timer to track the idle time
        public void EnterState(BaseEnemy enemy)
        {
            timer = 0f;
        }
        public void ExitState(BaseEnemy enemy)
        {
            //Logic for Exit
        }
        public void UpdateState(BaseEnemy enemy)
        {
            // Logic to be executed while in the idle state
            timer += Time.deltaTime;
            if (timer >= idleTime)
            {
                enemy.TransitionToState(enemy.wanderState);
            }
            else if (enemy.PlayerInSight())
            {
                enemy.TransitionToState(enemy.chaseState);
            }
            else if (enemy.PlayerInRange())
            {
                enemy.TransitionToState(enemy.attackState);
            }
        }
    }

让我们更仔细地看看前面的代码:

  • Variables:

    • idleTime: 这个变量决定了敌人保持空闲的时间长度

    • timer: 这个变量跟踪敌人处于空闲状态时的经过时间

  • Methods:

    • EnterState(BaseEnemy enemy): 当敌人进入空闲状态时调用此方法。在这里,它初始化计时器。

    • ExitState(BaseEnemy enemy): 当敌人退出空闲状态时调用此方法。目前它是空的,但如果有需要,你可以在这里添加逻辑。

    • UpdateState(BaseEnemy enemy): 这个方法在每一帧都会被调用以更新敌人的状态。以下是发生的情况:

      • 计时器通过自上一帧以来经过的时间增加

      • 如果空闲时间超过指定的持续时间(idleTime),敌人将转换到游荡状态,表示它准备四处移动

      • 如果敌人检测到玩家在其视线范围内(PlayerInSight()),它将转换到追逐状态以追捕玩家

      • 如果玩家在攻击范围内(PlayerInRange()),敌人将过渡到攻击状态以对抗玩家

此代码确保在空闲状态下,敌人表现如预期,根据特定条件(如经过的时间和玩家接近度)过渡到其他状态。

在下面的 AttackState 类中,我们还实现了 IEnemyState,以便我们可以修改基方法,使其适合攻击状态。攻击状态是所有敌人在攻击玩家时都会进入的状态。它包括跟踪玩家位置并向玩家发射弹丸的逻辑,以及处理相关的动画:

    public class AttackState : IEnemyState
    {
        private float attackTimer;  // Timer to control the attack rate
        private float timeBetweenAttacks = 1.5f;  // Adjust as needed based on your game's requirements
        public void EnterState(BaseEnemy enemy)
        {
            enemy.animationsComponent.StartAttackAnimations();
            attackTimer = 0f;
        }
        public void UpdateState(BaseEnemy enemy)
        {
            LookAtPlayer(enemy);
            attackTimer += Time.deltaTime;
            if (attackTimer >= timeBetweenAttacks)
            {
                AttackPlayer(enemy);
                attackTimer = 0f;  // Reset the timer after attacking
            }
        }
        public void ExitState(BaseEnemy enemy)
        {
            enemy.animationsComponent.StopAttackAnimations();
        }
        private void LookAtPlayer(BaseEnemy enemy)
        {
            Vector3 lookDirection = enemy.player.position - enemy.transform.position;
            lookDirection.y = 0;  // Keep the enemy's rotation in the horizontal plane
            Quaternion rotation = Quaternion.LookRotation(lookDirection);
            enemy.transform.rotation = Quaternion.Slerp(enemy.transform.rotation, rotation, Time.deltaTime * enemy.rotationSpeed);
        }
        private void AttackPlayer(BaseEnemy enemy)
        {
            enemy.shootComponent.FireProjectile();
        }
    }

下面是对此代码的解释:

  • 方法

    • EnterState:初始化攻击状态,开始攻击动画,并重置攻击计时器。

    • UpdateState:根据攻击之间的时间检查是否是攻击的时间。它确保敌人面向玩家,并在条件满足时启动攻击。

    • ExitState:在退出状态时停止攻击动画。

    • LookAtPlayer:计算看向玩家的方向,并平滑地将敌人旋转到玩家方向。

    • AttackPlayer:使敌人执行攻击动作,例如发射弹丸。

通过这些,你已经学会了如何创建状态,使你可以轻松地添加更多状态,以便你可以定制你的游戏。

现在我们已经建立了敌人和玩家之间的交互循环,允许他们互相射击,下一步涉及为每个敌人创建一个预制体 - 例如,实现 ShooterEnemy 基类并将此组件添加到相应的 GameObject 中,将其转换为预制体。同样,对于弹丸,请记住,根据它们是为玩家还是敌人而修改标签是至关重要的。

使用 C# 实现挑战与奖励系统

挑战为游戏体验注入活力,充满激情和不可预测性,引导玩家通过需要技能、策略和决心的关键时刻。这些障碍确保玩家完全沉浸在游戏世界中,打造一个动态的景观,使每次游戏都变成一场冒险,充满意想不到的转折和变化。

在像 Dark Souls 这样的游戏中,挑战以强大的敌人和复杂的关卡设计的形式出现。玩家在战斗技能和适应性方面受到考验,创造了一个紧张而有益的经验。超级马里奥兄弟 通过精确的平台跳跃、时机和击败敌人引入挑战。每个关卡都提供了一套新的挑战,逐渐增加复杂性。

挑战与任务/探险

虽然挑战、任务和任务在吸引玩家方面有共同点,但它们的性质不同。挑战通常指的是测试玩家能力的特定障碍或任务,例如在时间限制内完成一个关卡或击败一个强大的对手。另一方面,任务和任务是更广泛的目标,它们有助于游戏的叙事,并涉及一系列可能包括挑战的任务。区别在于挑战的专注和技能测试性质,使它们成为动态游戏的关键组成部分。

在《塞尔达传说:荒野之息》中,挑战可能包括解决复杂的神殿谜题并测试玩家的解决问题的能力。相比之下,任务可能是游戏整体叙事的一部分,例如营救角色或找回特殊物品。挑战提供即时的、基于技能的障碍,而任务则有助于整体进度和叙事。

平衡难度级别以吸引更广泛的受众

实现和谐的难度曲线对于满足不同技能水平的玩家至关重要。平衡挑战确保新手和经验丰富的玩家都能找到乐趣,而不会遇到过多的挫折。例如,《Celeste》等游戏巧妙地平衡了难度,从简单的挑战开始,逐渐引入更复杂的挑战,让玩家随着游戏的复杂性一起成长。

成功的游戏通常采用自适应难度调整或可选挑战等技术,以适应不同玩家的技能水平。这种谨慎的平衡既防止了新手感到气馁,也为寻求更大挑战的老玩家提供了满意的体验。

探索奖励系统

奖励系统在游戏中就像是在玩家克服艰难挑战后等待胜利者的珍贵奖品。这些系统与挑战动态紧密相连,作为推动玩家前进的动力。奖励形式多样——包括增强、升级或游戏内货币,以及叙事进度和外观物品,每种都有其独特的吸引力。

在《塞尔达传说》等游戏中,征服棘手的地下城或击败强大的 Boss 通常会奖励玩家新的工具或能力,以便在故事中前进。在《巫师 3》等 RPG 游戏中,完成支线任务不仅提供经验点和游戏内货币,还能解锁新的故事线或装备。挑战与奖励之间的联系确保了克服障碍不仅考验玩家的技能,还承诺了有价值的激励,从而提高玩家的参与度和满意度。

将奖励成功融入游戏循环确保挑战不仅仅是障碍,而是成长的机会。这培养了成就感与进步感。玩家为了获得更多重大奖励的承诺,会更有动力接受更具挑战性的挑战,从而创造令人满意的游戏体验。奖励的无缝集成使游戏之旅变得充实和愉快。

C# 挑战和奖励的实现

从理论到实践,我们用 C# 实现挑战和奖励,将编码洞察力变为现实。通过示例代码片段,您将获得实践经验,使挑战变得生动,并给予玩家有意义的奖励。我们将讨论保持玩家参与和动力的微妙平衡,了解挑战难度与奖励幅度的相关性。

介绍挑战逻辑

让我们首先建立挑战的基础数据结构。这可以在以下类中看到。每个挑战都将共享这组通用数据,简化运行时跟踪,并允许分配奖品:

[Serializable]
 public class CommonChallengeData
 {
     public bool isCompleted;
     public RewardType rewardType; // Type of reward
     public int rewardAmount;      // Amount or value of the reward
     … other challenge Data
 }

让我们转到 BaseChallenge 类,它包含开始和完成挑战的逻辑。有关详细信息,请参考以下代码块。所有挑战都将从这个脚本派生出来,在其方法内自定义其逻辑:

public abstract class BaseChallenge : MonoBehaviour
 {
     public CommonChallengeData commonData;
     public abstract void StartChallenge();
     public abstract void CompleteChallenge();
 }

让我们更深入地了解一下:

  • public CommonChallengeData commonData:这是一个公共变量,类型为 CommonChallengeData。它持有可能跨越各种类型挑战的通用数据。它允许派生类访问和修改共享的挑战数据。

  • public abstract void StartChallenge():这是一个没有实现的具体方法声明。它指定了任何从 BaseChallenge 继承的类都必须为其 StartChallenge 方法提供自己的实现。此方法可能包含初始化或开始挑战的逻辑。

  • public abstract void CompleteChallenge():与 StartChallenge() 类似,这也是一个任何派生类都必须实现的抽象方法。它负责处理挑战的完成,可能涉及更新用户界面、颁发奖励或触发其他游戏事件。

总结来说,BaseChallenge 作为创建游戏不同类型挑战的模板。它定义了所有挑战应具备的通用功能,例如开始和完成挑战,同时允许特定实现根据挑战类型的不同而有所变化。

让我们过渡到 ChallengeManager,这是一个中央实体,它存储所有挑战并承担启动挑战的责任。目前,它包括一个字典,用于按其相应类型存储所有挑战组件,涵盖所有挑战类型。它还包含一个开始挑战的方法,该方法将由 LevelManager 调用。

因此,每个等级都可以有一个指定的挑战,并且管理器会维护对当前挑战的引用。所有这些功能都在以下代码脚本中详细说明:

    public class ChallengeManager : Singleton<ChallengeManager>
    {
        // Define different types of challenges
        public enum ChallengeType
        {
            EnemyWaves,
            TimeTrials,
            LimitedResources,
            NoDamageRun,
            AccuracyChallenge
        }
        public GenericDictionary<ChallengeType, BaseChallenge> challengeDictionary = new GenericDictionary<ChallengeType, BaseChallenge>();
        public void StartChallenge(ChallengeType challengeType)
        {
            if (challengeDictionary.TryGetValue(challengeType, out BaseChallenge challengeScript))
            {
                if (!challengeScript.commonData.isCompleted)
                {
                    SetCurrentChallenge(challengeScript);
                    currentChallenge.StartChallenge();
                }
                else
                {
                    Debug.Log("Challenge already completed!");
                }
            }
            else
            {
                Debug.LogError($"No challenge script found for ChallengeType {challengeType}");
            }
        }
        private BaseChallenge currentChallenge;
        private void SetCurrentChallenge(BaseChallenge challengeScript)
        {
            if (currentChallenge != null)
            {
                currentChallenge.CompleteChallenge();
            }
            currentChallenge = challengeScript;
        }
    }

下面是对 ChallengeManager 类的解释:

  • public enum ChallengeType:这是一个枚举,定义了游戏中可用的不同类型挑战。每种挑战类型代表一种特定的游戏玩法挑战,例如 EnemyWavesTimeTrialsLimitedResourcesNoDamageRunAccuracyChallenge

  • public GenericDictionary<ChallengeType, BaseChallenge> challengeDictionary:这是一个泛型字典,将 ChallengeType 枚举值映射到相应的 BaseChallenge 对象。它存储与各自挑战类型相关联的不同挑战脚本的实例。

  • public void StartChallenge(ChallengeType challengeType):此方法负责启动指定类型的挑战。它根据提供的挑战类型从字典中检索相应的挑战脚本,然后调用检索到的脚本中的 StartChallenge() 方法。

  • private BaseChallenge currentChallenge:这个私有字段持有当前活跃挑战的引用。它用于跟踪和管理正在进行的当前挑战的状态。

  • private void SetCurrentChallenge(BaseChallenge challenge Script):此方法将当前挑战设置为提供的参数。在设置新挑战之前,它通过调用其 CompleteChallenge() 方法确保任何现有挑战已完成。

总结来说,ChallengeManager 类简化了游戏中不同类型挑战的管理和执行。它提供了启动挑战、处理挑战完成以及跟踪当前活跃挑战的方法。使用单例模式确保了对挑战管理操作的集中控制。

现在,让我们将注意力转向单个挑战。以下代码块包含了一个实现 BaseChallenge 类的示例,它集成了针对 Enemy Waves 挑战的特定逻辑。当挑战开始时,敌人在玩家附近生成。此外,它还包括在完成挑战后奖励玩家的逻辑。这通过 RewardManager 类来实现:

    public class EnemyWavesChallenge : BaseChallenge
    {
        public int totalWaves = 5;  // Adjust as needed
        private int currentWave = 0;
        public override void StartChallenge()
        {
            if (!commonData.isCompleted)
            {
                StartCoroutine(StartEnemyWavesChallenge());
            }
            else
            {
                Debug.Log("Challenge already completed!");
            }
        }
        IEnumerator StartEnemyWavesChallenge()
        {
            while (currentWave < totalWaves)
            {
                yield return StartCoroutine(SpawnEnemyWave());
                currentWave++;
            }
            CompleteChallenge();
        }
        public override void CompleteChallenge()
        {
            if (!commonData.isCompleted)
            {
                RewardManager.Instance.GrantReward(commonData);
                commonData.isCompleted = true;
            }
            else
            {
                Debug.Log("Challenge already completed!");
            }
        }
        IEnumerator SpawnEnemyWave()
        {
            // Adjust spawn positions, enemy types, and other parameters based on your game
            Debug.Log($"Spawning Wave {currentWave + 1}");
            yield return new WaitForSeconds(2f);
        }
    }

下面是对 EnemyWavesChallenge 类的解释:

  • public int totalWaves = 5:这个变量确定敌对挑战的总波数。游戏设计师可以调整此值以设置所需的波数。

  • private int currentWave = 0:这个变量跟踪挑战中的当前波次。它从 0 开始,随着波的生成而递增。

  • public override void StartChallenge(): 这个方法覆盖了从 BaseChallenge 类继承的 StartChallenge() 方法。如果挑战尚未完成,它将启动敌军波次挑战。在此方法内部,启动一个名为 StartEnemyWavesChallenge() 的协程来处理波次生成过程。

  • IEnumerator StartEnemyWavesChallenge(): 这个协程函数管理敌军波次的生成。它运行直到当前波次计数达到指定的总波次数。在循环内部,它使用 SpawnEnemyWave() 协程等待生成一个波次。

  • public override void CompleteChallenge(): 这个方法覆盖了基类中的 CompleteChallenge() 方法。它通过使用 RewardManager 为完成挑战提供奖励,并将挑战标记为已完成。

  • IEnumerator SpawnEnemyWave(): 这个协程函数代表了生成敌军波次的逻辑。游戏设计师可以调整生成位置、敌军类型和其他参数来自定义波次生成过程。在这个例子中,它记录了一条消息,表明正在生成波次,并在生成下一个波次之前等待设定的时间。

总结来说,EnemyWavesChallenge 类定义了一个挑战,其中敌军波次是顺序生成的。它提供了启动挑战、生成敌军波次和处理挑战完成(通过提供奖励)的方法。游戏设计师可以根据游戏需求自定义波次生成过程并调整参数。

之前的例子只是提供的挑战之一。你可以在本书的 GitHub 仓库中找到所有挑战(见 技术要求 部分)。最后,这是 LevelManager,它负责为当前级别分配合适的挑战:

   public class LevelManager : Singleton<LevelManager>
   {
       public GenericDictionary<int, ChallengeType> levelChallengeMapping = new GenericDictionary<int, ChallengeType>();
       public int currentLevel;
       private void Start()
       {
           StartChallengeForCurrentLevel(currentLevel);
       }
       public void StartChallengeForCurrentLevel(int currentLevel)
       {
           if (levelChallengeMapping.TryGetValue(currentLevel, out ChallengeType challengeType))
           {
               // Start the challenge associated with the current level
               ChallengeManager.Instance.StartChallenge(challengeType);
           }
           else
           {
               Debug.LogError($"No challenge mapped for Level {currentLevel}");
           }
       }
   }

下面是对 LevelManager 类的解释:

  • public GenericDictionary<int, ChallengeType> levelChallengeMapping: 这个字典存储了级别与其对应挑战类型之间的映射。键代表级别编号,值代表与该级别关联的挑战类型。

  • public int currentLevel: 这个变量存储了游戏当前的级别。

  • private void Start(): 当 LevelManager 对象初始化时调用此方法。它自动启动与当前级别关联的挑战。

  • public void StartChallengeForCurrentLevel(int currentLevel): 这个方法为指定的当前级别启动挑战。它检查 levelChallengeMapping 字典中是否为当前级别映射了挑战。如果找到映射,它检索关联的挑战类型并使用 ChallengeManager 启动相应的挑战。

  • ChallengeManager.Instance.StartChallenge(challengeType):这一行代码调用ChallengeManager单例实例的StartChallenge方法,传递与当前级别关联的挑战类型作为参数。

总结来说,LevelManager类通过在levelChallengeMapping字典中查找与当前级别关联的挑战类型,然后调用ChallengeManager单例实例的StartChallenge方法,来促进基于游戏当前级别的挑战的启动。它确保每个级别都启动了正确的挑战。

实现奖励系统

现在,让我们深入了解奖励系统,这是游戏流程中的关键元素,允许用户获得奖励。这个功能对于用户动机和参与度至关重要。

这里是RewardManager,它的任务是根据挑战数据为用户提供奖励。正如我们所见,它与其他管理者进行通信,以便用户能够获得特定的奖励:

    public class RewardManager : Singlton<RewardManager>
    {
        // Define different types of rewards
        public enum RewardType
        {
            PowerUp,
            UnlockableWeapon,
            ScoreMultiplier,
            SecretArea,
            Coins
        }
        public void GrantReward(CommonChallengeData commonData)
        {
            // Add code here to handle the specific reward type
            switch (commonData.rewardType)
            {
                case RewardType.PowerUp:
                    // Grant temporary power-up
                    break;
                case RewardType.UnlockableWeapon:
                    // Unlock a new weapon
                    break;
                case RewardType.ScoreMultiplier:
                    ApplyScoreMultiplier(commonData.rewardAmount);
                    break;
                case RewardType.SecretArea:
                    // Grant items found in a secret area
                    break;
                case RewardType.Coins:
                    GrantCoins(commonData.rewardAmount);
                    break;
            }
        }
        private void ApplyScoreMultiplier(int multiplier)
        {
            ScoreManager.Instance.ApplyMultiplier(multiplier);
            Debug.Log($"Score Multiplier Applied: {multiplier}x");
        }
        private void GrantCoins(int coinAmount)
        {
            CurrencyManager.Instance.AddCoins(coinAmount);
            Debug.Log($"Coins Granted: {coinAmount}");
        }
    }

下面是对RewardManager类的解释:

  • public enum RewardType:这个枚举定义了可以授予玩家的不同奖励类型,例如升级、可解锁武器、得分倍数、在秘密区域找到的物品和硬币。

  • public void GrantReward(CommonChallengeData commonData):这个方法负责授予玩家奖励。它接受一个CommonChallengeData对象作为参数,其中包含有关要授予的奖励类型和数量的信息。

  • switch (commonData.rewardType):这个 switch 语句检查CommonChallengeData对象中指定的奖励类型,并根据RewardType执行相应的奖励逻辑。

  • case RewardType.PowerUp:这个情况允许临时授予玩家升级。

  • case RewardType.UnlockableWeapon:这个情况允许为玩家解锁新武器。

  • case RewardType.ScoreMultiplier:这个情况通过调用带有指定倍数值的ApplyScoreMultiplier方法,将得分倍数应用于玩家的得分。

  • case RewardType.SecretArea:这个情况允许在秘密区域找到物品。

  • case RewardType.Coins:这个情况通过调用带有指定硬币数量的GrantCoins方法,将硬币授予玩家。

  • private void ApplyScoreMultiplier(int multiplier):这个方法通过调用ScoreManager单例实例的ApplyMultiplier方法,将得分倍数应用于玩家的得分。

  • private void GrantCoins(int coinAmount):这个方法通过调用CurrencyManager单例实例的AddCoins方法,将硬币添加到玩家的货币余额中。

总体而言,RewardManager类提供了一个集中机制,用于管理和授予玩家在完成挑战后各种类型的奖励。

以下代码块包含 CurrencyManager,它负责监督游戏中的货币。然而,这里的重点是添加金币到玩家余额的部分:

   Public class CurrencyManager : Singlton<CurrencyManager>
   {
       private int currentCoins;
       public void AddCoins(int amount)
       {
           currentCoins += amount;
           Debug.Log($"Coins: {currentCoins}");
       }
   }

这里是对 CurrencyManager 类的解释:

  • private int currentCoins: 这个变量存储玩家当前拥有的金币数量。

  • public void AddCoins(int amount): 这个方法允许您向玩家的货币余额中添加金币。它接受一个名为 amount 的整数参数,表示要添加到当前余额中的金币数量。

  • currentCoins += amount: 这行代码将 currentCoins 变量增加指定的数量,从而向玩家的余额中添加金币。

总体而言,CurrencyManager 类为管理玩家的货币余额提供了简单而基本的功能,特别是向他们的总余额中添加金币。

以下代码块包含 ScoreManager,它负责管理玩家的分数并实现分数乘数:

    public class ScoreManager : Singlton<ScoreManager>
    {
        private float currentScore;
        private int scoreMultiplier = 1;
        public void ApplyMultiplier(int multiplier)
        {
            scoreMultiplier *= multiplier;
        }
        private void ResetMultiplier()
        {
            scoreMultiplier = 1;
        }
        public void AddScore(int scoreValue)
        {
            // Adjust score based on the current multiplier
            currentScore += scoreValue * scoreMultiplier;
            Debug.Log($"Score: {currentScore}");
        }
    }

这里是对 ScoreManager 类的解释:

  • private float currentScore: 这个变量存储玩家的当前分数。

  • private int scoreMultiplier = 1: 这个变量表示分数乘数,默认值为 1

  • public void ApplyMultiplier(int multiplier): 这个方法允许您将分数乘数应用于当前分数。它接受一个名为 multiplier 的整数参数,相应地调整分数乘数。

  • scoreMultiplier = multiplier: 这行代码将现有的分数乘数乘以指定的乘数,从而有效地调整分数乘数。

  • private void ResetMultiplier(): 这个方法将分数乘数重置为其默认值 1

  • public void AddScore(int scoreValue): 这个方法将指定的分数值添加到玩家的当前分数。它接受一个名为 scoreValue 的整数参数,表示要添加到当前分数的分数。

  • currentScore += scoreValue * scoreMultiplier: 这行代码根据分数值和当前分数乘数调整当前分数。它将分数值乘以分数乘数,并将其添加到当前分数。

总体而言,ScoreManager 类处理分数计算和更新,包括应用乘数和将分数值添加到玩家的总分数中。

这个例子突出了游戏开发中挑战和奖励系统之间的重要关系。挑战提供了吸引人的障碍和目标,促进了玩家的互动和进步。与奖励系统结合,完成挑战不仅是一种成就,也是一种令人满意的体验,提供了激励玩家继续前进的奖励。这种动态的互动增强了整体的游戏体验,确保玩家在整个游戏世界之旅中保持参与、动机和满足感。

第六章,本章专注于数据处理,我打算介绍一个存储所有相关数据的保存系统,包括我们迄今为止讨论过的所有元素。本章将深入探讨实现该系统的具体细节。

提供的脚本作为演示,你可以在本书的 GitHub 仓库中找到完整的逻辑。

摘要

在本章中,我们深入探讨了 Unity 游戏开发的基本原则,特别是针对 C#的游戏机制,我们强调了它们在塑造引人入胜的游戏玩法中的重要性,包括平衡、反馈和玩家自主权等方面。这为你提供了使用 C#进行有效游戏机制实现的实用编程技能。过渡到下一节,使用 C#实现玩家行为和 AI 逻辑,我们探讨了玩家行为设计和基础 AI 概念。此外,我们还强调了挑战和奖励系统在提升玩家体验和培养参与度中的关键作用。

第五章**,使用 C#为 Unity 游戏设计优化用户界面*中,你将深入 UI 设计原则和响应式 UI 元素领域。通过掌握使用 C#的 UI 设计技术,你将能够制作出视觉上吸引人且沉浸式的界面。本章旨在增强你在设计有效视觉层次、布局和响应式 UI 元素方面的技能,最终有助于提升用户体验,包括优化 UI 元素。

随着你开始这段技能提升的持续旅程,接下来的章节将带来令人兴奋的挑战和发现。祝您编码愉快!

第六章:使用 C#为 Unity 游戏设计优化用户界面

欢迎来到第五章,我们将学习如何利用 C#的多功能性优化 Unity 游戏的用户界面(UI)。本章为你提供了实用的技能,以提升 UI 的性能并确保流畅的用户体验。第一个技能侧重于利用 C#进行高效的 UI 优化,最大化 UI 元素的性能。随后,我们将深入研究在 C#中创建优化 UI 系统的策略,提供关于有效构建和管理视图的见解。在本章中,所提出的系统作为一个灵活的框架,允许你根据游戏独特的需求自定义和优化 UI 元素。让我们深入优化 UI 的世界,利用 C#的力量来提升 Unity 游戏界面的性能和功能。

在本章中,我们将介绍以下主要内容:

  • 游戏中的 UI 设计介绍

  • UI 的最佳实践和优化技术

  • 使用 C#的 UI 系统

技术要求

本章的所有代码文件都可以在以下位置找到:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp/tree/main/Assets/Chapter%2005

游戏中的 UI 设计介绍

在制作游戏的激动人心的世界中,UI 就像是玩家和游戏制作者创造的酷炫虚拟世界之间的联系。UI 设计不仅仅是关于让事物看起来好看;它在塑造玩家体验游戏的方式中起着至关重要的作用。本节讨论了为什么 UI 设计在游戏中非常重要,以及它如何真正影响玩家对游戏的投入程度和喜爱程度:

  • 第一印象:

    将 UI 视为游戏与玩家之间的第一次问候。一个精心制作的 UI 能够吸引注意力,设定氛围,并使游戏看起来和感觉独特。无论你是在主菜单还是玩游戏,每个部分都汇聚成你开始玩游戏时获得的第一个感觉。

  • 增强 玩家沉浸感:

    玩游戏应该感觉就像你真的身处其中。一个经过深思熟虑的 UI 与游戏无缝融合,让你专注于游戏,而不会被分散注意力。无论是酷炫的动作、匹配的主题还是简单的控制,UI 都成为游戏故事的重要组成部分。

  • 引导 用户交互:

    UI 就像一个有用的指南,向玩家展示在游戏中应该做什么。从生命值条到任务标记,每一项都提供了重要的信息,而不会让玩家感到困惑。使 UI 清晰简单非常重要,这样玩家就可以思考他们的动作和游戏的难点,而不是在研究按钮。

  • 玩家参与度 的影响:

    一个易于理解且看起来好的 UI 真的能保持玩家的兴趣。如果菜单操作流畅且游戏响应良好,玩家愿意花更多时间玩游戏。相反,一个制作糟糕的 UI 可能会让玩家感到沮丧,不想玩游戏,并破坏乐趣。

  • 适应 多种平台:

    由于我们现在在各种各样的设备上玩游戏,UI 必须在所有设备上都能良好工作。这就像为每种设备说不同的语言,确保无论您使用电脑、游戏机还是手机,游戏体验都是正确的。

在游戏中制作 UI 就像是找到让事物看起来好且工作良好的正确混合。这关乎选择颜色和字体,如何布局,以及确保一切运行顺畅。在 Unity 中使用 C# 让游戏制作者能够使用这些酷炫的设计理念,制作出不仅看起来很棒而且能很好地完成任务的界面。

在我们浏览这一章的过程中,我们将探讨 UI 设计背后的基本理念,并看看 C# 如何帮助制作出真正出色的 UI,使游戏体验更加精彩。

转向 UI 的最佳实践和优化技术,我们将探讨提高 UI 性能和响应性的策略。

UI 的最佳实践和优化技术

在本节中,我们将讨论一些 UI 优化技术以及一些最佳实践,以获得更好的性能。让我们开始吧。

分割画布

问题在 UI 画布上修改单个元素会触发整个画布的刷新, 影响性能。

Unity UI 依赖于画布作为其基本组件。它创建代表 UI 元素的网格,当有变化时刷新这些网格,并将绘制调用发送到 GPU 以进行实际的 UI 显示。

网格生成是资源密集型操作,需要将 UI 元素分组批量处理以提高绘制调用的效率。由于批量重生成的成本,最小化不必要的刷新至关重要。当画布上的单个元素发生变化时,就会引发挑战,因为这会促使对整个画布进行全面评估,以确定重新绘制其元素的最佳方式。

许多用户在一个画布上构建整个游戏的 UI,包含众多元素。仅更改一个元素就可能引起显著的 CPU 峰值,消耗多个毫秒。

解决方案:分割您的画布。

每个画布作为一个独立的实体运行,将其元素与其他画布上的元素隔离开。利用 Unity GUI 对多个画布的支持,通过分割您的画布,解决 Unity UI 中的批量处理挑战。

嵌套画布提供了另一种解决方案,允许在不考虑元素在画布间空间排列的情况下创建复杂的分层 UI。子画布还能保护内容不受其父画布和兄弟画布的影响。它们保持独立的几何形状并执行独立的批处理。一种有效的分割策略是基于元素的刷新频率。将静态 UI 元素放置在独立的画布上,为同时更新的动态元素保留较小的子画布。此外,确保每个画布上所有 UI 元素的 Z 值、材质和纹理的一致性。

在我们的游戏中,让我用一个例子来说明。我们将为整个场景设置一个画布,在这个主画布内,每个面板将作为一个独立的画布使用。这意味着当我们进行更新,例如在游戏过程中更新 HUD 画布时,我们不会影响暂停面板或其他任何面板。确保每个面板或视图都有其专用的画布组件,这对于在应用更新时防止性能问题是至关重要的。

以下图示展示了游戏场景画布的分割到更小的子画布中。

图 5.1 – GamePlayCanvas 层次结构

图 5.1 – GamePlayCanvas 层次结构

以下图示展示了包含所有画布的GamePlayCanvas

图 5.2 – GamePlayCanvas GameObject

图 5.2 – GamePlayCanvas GameObject

以下图示展示了PauseCanvas,它是GamePlayCanvas的子类:

图 5.3 – PauseCanvas GameObject

图 5.3 – PauseCanvas GameObject

避免使用过多的 Graphic Raycasters 并关闭 Raycast Target

问题#1没有很好地使用 Graphic Raycaster

Graphic Raycaster组件有助于将屏幕上的点击或触摸转换为游戏能够理解的内容。它就像是你动作和游戏 UI 之间的翻译者,确定你触摸的位置,并将该信息发送到游戏的正确部分。你需要在需要触摸的每个屏幕上使用这个组件,即使在大型屏幕内部的小屏幕上也是如此。但是,它需要检查屏幕上你触摸的所有位置,看它们是否在 UI 区域内,这可能会有些繁琐。

尽管它被称为 Graphic Raycaster,但它并不真正发射射线。默认情况下,它只关心 UI 图形。它查看所有希望知道你何时触摸的 UI 部分,并检查你触摸的位置是否与已设置响应的 UI 部分相匹配。

问题在于并非所有 UI 部分都希望在触摸时被打扰。

Image组件 – 我们只为无法与之交互的图像关闭它:

图 5.4 – RayCast Target 变量

图 5.4 – RayCast Target 变量

问题#2有时 Graphic Raycaster 所做的不仅仅是 翻译触摸

当你将 Canvas 的 渲染模式 设置为 世界空间相机屏幕空间相机 时,可以添加一个阻塞掩码。此掩码确定射线投射器是否使用 2D 或 3D 物理投射光线,从而确定是否有物理对象阻碍用户与 UI 的交互。

解决方案:通过 2D 或 3D 物理投射光线可能很耗费资源,因此请谨慎使用此功能。为了最小化图形射线投射器的数量,请从非交互式 UI 画布中排除它们,因为在这些情况下,没有必要检查交互事件。

在以下图中,你可以看到 图形 射线投射器 组件:

图 5.5 – 图形射线投射器组件

图 5.5 – 图形射线投射器组件

有效管理 UI 对象池

问题UI 对象池中的低效实践

经常有人通过首先更改父对象然后禁用它来池化 UI 对象,这导致不必要的复杂性。

解决方案:首先优化对象在池中重新分配父对象之前的禁用操作。

这种策略确保原始层次结构只被污染一次。一旦对象被重新分配父对象,就不再需要额外的层次结构更改,新的层次结构保持不受影响。当从池中提取对象时,首先重新分配其父对象,更新你的数据,然后激活它以保持效率。

正确隐藏画布的方法

问题不确定如何有效地隐藏 画布

有时候你希望将 UI 元素和画布隐藏起来。但如何有效地实现这一点呢?

Canvas 组件本身。

通过禁用 Canvas 组件,你停止了对 GPU 的绘制调用生成,使画布变得不可见。重要的是,画布保留了其顶点缓冲区,保留了所有网格和顶点。因此,重新启用它不会触发重建;它只是继续绘制。

此外,禁用 Canvas 组件可以避免在 Canvas 层次结构中启动资源密集型的 OnDisable/OnEnable 回调。只是在禁用执行每帧计算密集型代码的子组件时要小心。

UI 元素动画的有效实现

问题在 UI 上实现动画器

当动画器应用于 UI 时,它们会持续影响每一帧的 UI 元素,即使动画值保持不变。

解决方案:使用代码进行 UI 动画。

限制动画师的使用范围,仅限于动态 UI 元素,这些元素会经历持续的变化。对于不经常变化或由事件触发的临时更改的元素,可以选择编写动画代码或使用缓动系统,您可以通过代码创建该系统,或者可以使用第三方资源。在 Unity Asset Store 上可以找到各种有效的解决方案。对于我们的游戏,我们将使用免费的 DoTween 包来完成这项工作。

有效处理全屏 UI

问题全屏 UI 的性能问题

当暂停或开始屏幕占据整个显示时,游戏的其他元素将继续在后台渲染,这可能导致性能问题。

解决方案:隐藏所有其他内容。

如果您展示一个覆盖整个场景的屏幕,请禁用负责渲染 3D 场景的相机。同样,禁用位于顶部 Canvas 之下的 Canvas 元素。

当全屏 UI 启用时,考虑降低 Application.targetFrameRate,因为没有必要以 60 fps 的速率进行更新。

现在我们已经了解了 UI 的最佳实践和优化技术,让我们继续下一节,我们将探讨一些架构模式。

介绍架构模式(MVC 和 MVVM)

在游戏开发的世界里,组织和管理 UI 对于创建引人入胜且高效的体验至关重要。两种广泛使用的架构模式——即 模型-视图-控制器MVC)和 模型-视图-视图模型MVVM)——为以增强清晰性和可维护性方式结构化 UI 元素提供了框架。MVC 将应用程序分为三个相互关联的组件——用于数据和逻辑的 模型,用于用户界面的 视图,以及用于管理用户输入的 控制器。另一方面,MVVM 引入了 ViewModel 作为模型和视图之间的中介,简化了表示逻辑和数据绑定,即软件应用程序中 UI 和底层数据模型之间的数据自动同步。在本节中,我们将探讨这些模式在 Unity 游戏开发中的实际应用,提供见解和指导,帮助您在结构化游戏 UI 时做出明智的决定。

理解 MVC – 三角合作的三种角色

在下面的图中,您可以查看 MVC 模式的布局及其组件的交互方式。

图 5.6 – MVC 结构

图 5.6 – MVC 结构

让我们更深入地了解 MVC 结构:

  • 模型

    • 本质:代表应用程序的数据和业务逻辑

    • Unity 实现:通常实现为 ScriptableObject 或常规 C# 类实例

    • 角色:管理数据,执行业务规则,并将更改通知给视图

  • 视图

    • 本质:代表 UI 元素,负责向用户显示数据

    • Unity 实现:包括 Unity UI 组件,如CanvasTextImage

    • 角色:渲染模型中的数据并处理用户输入交互,将它们转发到控制器

  • 控制器:

    • 本质:作为模型和视图之间的中介,处理用户输入并相应地更新模型和视图

    • Unity 实现:附加到 Unity UI 元素或游戏对象的MonoBehaviour脚本。

    • 角色:监听用户输入,更新模型,并指示视图反映变化

  • 交互流程:

    • 用户输入:由控制器捕获

    • 模型更新:控制器根据用户输入更新模型

    • 视图更新:视图从模型接收通知并相应地更新 UI

  • Unity 中的优点:

    • 简单性:非常适合小型项目和简单的 UI 结构

    • Unity 兼容性:与 Unity 内置 UI 系统无缝对接

  • Unity 中的缺点:

    • 潜在复杂性:随着项目的增长可能会导致复杂性增加

    • 数据绑定挑战:实现高效的数据绑定可能需要额外的努力

让我们继续了解下一个架构模式,MVVM。

理解 MVVM – 视图和模型的混合

在以下图中,您可以看到 MVVM 模式的布局及其组件的交互方式:

图 5.7 – MVVM 结构

图 5.7 – MVVM 结构

让我们更深入地了解 MVVM 结构:

  • 模型:

    • 本质:代表数据和业务逻辑,类似于 MVC

    • Unity 实现ScriptableObject或常规的 C#类实例,类似于 MVC

    • 角色:管理数据,执行业务规则,并将更改通知视图

  • 视图:

    • 本质:代表 UI,负责显示数据

    • Unity 实现:Unity UI 组件,与 MVC 相同

    • 角色:渲染模型中的数据并处理用户输入交互,将它们转发到控制器

  • ViewModel:

    • 本质:作为模型和视图之间的中介,暴露属性和命令

    • Unity 实现:促进数据绑定的MonoBehaviour脚本

    • 角色:通过直接暴露数据和逻辑,简化了视图的数据绑定

  • 交互流程:

    • 用户输入:由视图或 ViewModel 直接捕获

    • 模型更新:ViewModel 根据用户输入更新模型

    • 视图更新:当模型中的数据发生变化时,它们会通过数据绑定自动更新视图

  • Unity 中的优点:

    • 增强的数据绑定:简化了根据底层数据变化更新 UI 的过程

    • 可测试性:ViewModel 组件可以独立测试,从而促进可维护性

  • Unity 中的缺点:

    • 学习曲线:对于不熟悉该模式的开发者来说可能存在学习曲线

    • 抽象的额外开销:对于较小的项目,引入 ViewModel 可能被认为是过度设计

现在我们已经了解了 MVC 和 MVVM 是什么,让我们探讨如何根据某些因素决定我们的项目之间如何选择它们。

选择合适的 Unity UI 路径

以下要点解释了为什么我们会选择在 Unity 中使用 MVC:

  • 良好的兼容性:Unity 内置的 UI 系统与 MVC 原则自然对齐

  • 适用于较小项目的简单性:对于较小的项目或当简单性至关重要时,MVC 可以是一个务实的选择

以下要点解释了为什么我们会选择在 Unity 中使用 MVVM:

  • 数据驱动的复杂性:MVVM 在需要高效数据绑定和复杂 UI 结构的场景中表现出色

  • 适用于更大项目的扩展性:MVVM 促进可扩展性和可维护性,使其成为较大项目的稳健选择

以下要点将帮助你选择你的方法:

  • 考虑项目规模:MVC 的简单性可能对较小的项目有利,而 MVVM 的增强数据绑定和可测试性对较大的项目有益

  • 评估数据绑定需求:如果高效的数据绑定至关重要,MVVM 可能是首选方案

Unity UI 开发之旅是一个动态探索过程,由 MVC 和 MVVM 的架构选择引导。虽然 MVC 提供了简单性和熟悉性,但 MVVM 引入了一层抽象,提高了数据绑定和可测试性。在导航 Unity UI 架构时,考虑你项目的具体需求、UI 的复杂性和开发团队的熟悉度。无论遵循 MVC 的清晰性还是拥抱 MVVM 的复杂性,你选择的路径不仅定义了 UI 的结构,也定义了沉浸式玩家体验的基础。对于我们的游戏,我们将采用 MVC 结构。

提高你的 UI 开发效率的实用建议

这里有一些在工作 UI 时可能对你有益的建议:

  • 为最常用的元素创建预制体:

    • 对于此,可以考虑使用标题文本作为示例,并将其组件附加到它上。这使得以后实施更改变得更容易,因为任何修改都将影响游戏中的所有元素。此外,你还可以创建图像和其他 UI 元素。
  • 使用 Sprite Atlas:

    • Sprite Atlas 是 Unity 中的一个功能,允许你将多个 Sprite 纹理打包成一个单独的纹理资产。这对于优化和提升游戏性能特别有用,因为它通过将多个 Sprite 合并到一个纹理中,减少了绘制调用的次数。

    这里是关于 Unity 中 Sprite Atlas 的一些关键点:

    • 绘制调用优化:通过使用 Sprite Atlas,Unity 可以高效地通过单个绘制调用渲染多个 Sprite,这可以显著提高性能,尤其是在移动设备上。

    • 纹理分组:Sprite Atlases 允许你将多个 Sprite 或纹理组合在一起,使管理游戏资源更容易。

    • 纹理打包:Unity 的 Sprite Atlas 系统执行自动纹理打包,将单个 Sprite 在 Atlas 中排列,以最小化浪费的空间并优化纹理使用。

    • Mipmapping:Sprite Atlases 支持 Mipmapping,有助于提高从远处查看纹理时的渲染质量。

    • Atlas 变体:Unity 允许你为不同的平台或屏幕分辨率创建不同的 Sprite Atlas 变体,确保在各种设备上实现最佳性能。

    • 与 Unity 编辑器的集成:你可以在 Unity 编辑器中直接创建和管理 Sprite Atlases,这使得游戏开发者能够方便地可视化并调整他们的资源。

  • 使用透明图像叠加设计对齐:

    • 当提供设计师视图样本时,你可以叠加一个表示最终结果的图像,具有轻微的不透明度。这允许你根据设计对视图进行对齐和组织。
  • 使用 UI 扩展包:

    • 发现 UI 扩展包,这是一个宝贵的工具包,显著增强了 Unity 原生 UI 系统的功能。以下是其关键功能的概述:

      • 广泛的控件:包含超过 70 个额外的 UI 控件,提供高级文本字段(自动完成、密码掩码、多行)、滑块、进度条、复选框、开关、颜色选择器、下拉菜单、列表视图、树视图、网格、工具提示、模态窗口、上下文菜单、工具栏、停靠面板等。

      • 定制灵活性:享受现有 UI 元素的广泛定制选项,包括添加阴影、轮廓和其他视觉效果,属性(位置、颜色、大小)的动画,以及创建自定义布局和交互。

      • 实用函数:利用各种有用的实用函数,轻松对元素进行对齐和定位。此外,发现了一个将元素锚定到角落的快捷方式,这对于管理多个分辨率尤其有用,尤其是在移动游戏中。

    此包提供了一套强大工具和功能,使 Unity 的 UI 开发更加灵活和高效。

现在我们已经了解了实用技巧和架构模式(MVC 和 MVVM)结构,让我们深入探讨使用 C# 创建 UI 系统,以有效地在我们的 Unity 项目中处理 UI 行为。

使用 C# 创建 UI 系统

在本节中,我们将创建一个 C# 系统,用于处理 UI 行为,利用优化技巧和 MVC 结构来实现我们的目标。这包括创建一个 UIManager 类来监督视图,一个包含核心视图逻辑的 BaseView 类,以及一个演示 UI 系统实现的实际示例。

UIManager

要启动这个系统,我们将创建一个名为 UIManager 的基类。这个类将处理视图的 showhide 函数的调用,并将作为所有视图的容器。对于每个场景,我们将创建 UIManager 的一个子类,负责控制该特定场景内的视图。这个特定场景的管理器将包含所有视图,为我们提供对这些视图的更好控制。这种设置允许我们隐藏所有视图,确保一次只有一个视图处于活动状态,这对性能有利。

下面的代码块提供了一个 UIManager 基类的示例,其中包含显示和隐藏视图的泛型函数:

public class UIManager : Singleton<UIManager>
{
    public GenericDictionary<Type, BaseView> views = new GenericDictionary<Type, BaseView>();
    private BaseView lastActiveView;
    protected override void Awake()
    {
        base.Awake();
    }
    // Register a view with the UIManager
    public void RegisterView<T>(T view) where T : BaseView
    {
        if (view != null && !views.ContainsKey(typeof(T)))
        {
            views.Add(typeof(T), view);
        }
    }
    // Show a view
    public void ShowView<T>() where T : BaseView
    {
        if (views.ContainsKey(typeof(T)))
        {
            var view = views[typeof(T)];
            // Show the new view
            view.Show();
            lastActiveView = view;
        }
        else
        {
            Debug.LogError("The View Of Type is Not Exist " + typeof(T).ToString());
        }
    }
    public void HideView<T>()
    {
        if (views.ContainsKey(typeof(T)))
        {
            var view = views[typeof(T)];
            if (view.IsVisible())
                view.Hide();
        }
    }
    // Hide the currently active view
    public void HideActiveView()
    {
        if (lastActiveView != null)
        {
            lastActiveView.Hide();
            lastActiveView = null;
        }
    }
    public BaseView GetView(Type viewType)
    {
        if (views.ContainsKey(viewType)) return views[viewType];
        else return null;
    }
    public T GetView<T>()
    {
        if (views.ContainsKey(typeof(T))) return (T)Convert.ChangeType(views[typeof(T)], typeof(T));
        else return (T)Convert.ChangeType(null, typeof(T));
    }
}

在前面的代码块中,我们使用了泛型函数来直接根据类型处理视图的显示和隐藏,以避免使用字符串以提高性能。

我们首先注册视图并将其添加到字典中,这样我们就可以在整个游戏过程中隐藏或显示它。此外,我们还有在需要时检索视图的函数,允许访问或对该视图执行特定操作。

利用 UIManager

要使用这个类,我们可以为每个场景创建子类,并将此组件附加到一个 GameObject 上,或者简单地将其放置在场景的主画布上。

如即将到来的代码块所示,我们有 HUDManager,它继承自 UIManager。我们将将其附加到游戏场景的主画布上,以管理和控制与该场景相关的所有视图:

    public class HUDManager : UIManager
    {
        //Override Methods or Add new Logic here
    }

在下面的图中,您将注意到 HUDManager 组件连接到了主画布:

图 5.8 – HUDManager 组件

图 5.8 – HUDManager 组件

现在,我们可以继续到 UI 系统的另一个关键组件,即 BaseView 类。

基础视图类

在 UI 系统中,我们需要一个基础类来处理所有视图,包括在 hideshow 操作期间定义视图行为、动作或逻辑的核心功能。虽然 UI 管理器负责调用这些函数,但底层逻辑位于基类中。我们主要利用 hideshow 画布过程以提高效率,并在这些操作期间利用 DoTween 包进行动画。

在下面的代码块中,您将找到 BaseView 类及其核心函数:

    public abstract class BaseView : MonoBehaviour
    {
        private Canvas canvas;
        public bool isActiveView;
        public GameData gameData;
        public UITween tweenComponent;
        protected virtual void Start()
        {
            canvas = GetComponent<Canvas>();
        }
        // Show this view
        public virtual void Show()
        {
            isActiveView = true;
            canvas.enabled = true;
            gameObject.SetActive(true);
            PlayTweens(true);
            ShowView();
        }
        public virtual void ShowView()
        {
        }
        // Hide this view
        public virtual void Hide()
        {
            PlayTweens(false);
        }
        private void OnOutTweenComplete()
        {
            isActiveView = false;
            canvas.enabled = false;
        }
        public virtual void HideCanvas()
        {
            canvas.enabled = false;
        }
        // Return true if this view is currently visible
        public bool IsVisible()
        {
            return canvas.enabled;
        }
        private void PlayTweens(bool state)
        {
            if (state)
            {
                tweenComponent?.PlayInTween();
            }
            else
            {
                if (tweenComponent == null)
                {
                    OnOutTweenComplete();
                }
                else
                {
                    tweenComponent.PlayOutTween(OnOutTweenComplete);
                }
            }
        }
    }

BaseView 类在我们的 UI 系统中充当基础元素,为 Unity 项目中管理视图提供关键功能。在其核心,这个类提供了显示和隐藏视图的方法。此外,它无缝地与 tween 组件集成,允许在视图转换期间应用动画。除了其基本功能外,BaseView 类提供了一套辅助方法,为我们的游戏实现复杂逻辑提供了宝贵的支持。

现在我们已经了解了 BaseView 类的功能,让我们继续了解 UITween 组件的功能,它用于对视图进行动画处理。

UITween 组件

UITween 组件是我创建的一个包装器,用于利用 DoTween,简化在检查器中添加和删除动画以显示或隐藏视图的过程。

您可以在 GitHub 仓库中找到所有这些类和附加资源,其链接在技术要求部分中提到。

在下面的图中,您将观察到 UITween 组件。这是一个示例类,我们可以根据游戏需求进行调整。

图 5.9 – UITween 组件

图 5.9 – UITween 组件

利用 BaseView

要使用 BaseView 类,您只需为游戏中的每个视图创建一个子类,从而启用核心功能的使用。之后,您可以为每个视图添加特定的逻辑。

在下面的代码块中,您将看到 BaseView 类的使用示例:

    public class TopBarView : BaseView
    {
        protected override void Start()
        {
            base.Start();
            StartUIManager.Instance.RegisterView<TopBarView>(this);
            Show();
        }
       //Add here logic for displaying the currencies for the player
    }

TopBar 类,作为一个视图类,将负责显示游戏中的玩家货币等元素。只需将 TopBar 类附加到游戏场景中的 TopBar Canvas GameObject 上,即可完成。

在下面的图中,您将观察到附加到其画布上的 TopBarView 组件:

图 5.10 – TopBarView 组件

图 5.10 – TopBarView 组件

这个系统作为 UI 系统的抽象框架,提供了一个可以自定义或根据游戏需要扩展逻辑的基础。请根据您游戏的具体要求调整和扩展这个结构。

接下来,让我们通过一个例子来看一下 MVVM 的实现方式。

实现 MVVM

在 Unity 中实现 MVVM 结构涉及将逻辑和数据管理从 UI 元素中分离出来。让我们看看 Unity 中 MVVM 的一个示例实现。

在下面的代码块中,PlayerData 代表数据结构,例如玩家等级和分数:

// Model
public class PlayerData
{
    public int playerLevel;
    public int playerScore;
}

在下面的代码块中,PlayerViewModel 作为模型和视图之间的中介。它包含数据操作的逻辑,并公开视图可以绑定的属性:

// ViewModel
public class PlayerViewModel : MonoBehaviour
{
    private PlayerData playerData;
    // Properties for data binding
    public int PlayerLevel => playerData.playerLevel;
    public int PlayerScore => playerData.playerScore;
    private void Start()
    {
        playerData = new PlayerData();
    }
    public void UpdatePlayerData(int level, int score)
    {
        playerData.playerLevel = level;
        playerData.playerScore = score;
    }
}

在下面的代码块中,PlayerView 代表 UI 元素,并负责显示 ViewModel 中的数据。它订阅 ViewModel 事件并根据 ViewModel 的变化更新 UI 元素:

// View
public class PlayerView : MonoBehaviour
{
    [SerializeField] private PlayerViewModel playerViewModel;
    private void Start()
    {
        // Subscribe to ViewModel events
        playerViewModel.UpdatePlayerData(1, 100); // Example initialization
    }
    private void Update()
    {
        // Example of data binding
        Debug.Log("Player Level: " + playerViewModel.PlayerLevel);
        Debug.Log("Player Score: " + playerViewModel.PlayerScore);
    }
}

这种结构允许清晰地分离关注点,其中 ViewModel 负责逻辑和数据操作,而视图则专注于 UI 表示。数据绑定确保 ViewModel 中的更改自动反映在视图中,从而促进更组织化和可维护的代码库。

摘要

在本章中,我们学习了如何使用 C# 提升我们的游戏界面。我们首先通过提高 UI 各部分的运行速度来提升技能。然后,我们找到了组织和控制游戏内不同视图的方法。在这里学到的技能为你提供了根据游戏需求使 UI 看起来美观且运行流畅的工具。

在即将到来的第六章中,我们将深入探讨使用 C# 处理游戏数据。我们将学习如何在 Unity 中组织和保存游戏信息。这些技能将帮助我们管理游戏进度、保存和加载游戏状态,以及创建使用存储数据的特性。随着你继续这段编码之旅,你将发现使用 C# 有效处理游戏数据的新方法。为即将到来的章节中的更多编码挑战做好准备。祝编码愉快!

第三部分:使用 C#在 Unity 中的数据管理和代码协作

在这部分,你将深入了解使用 C#在 Unity 中高效处理和管理游戏数据,组织并序列化数据以实现流畅的存储和检索。你将探索实现保存和加载系统以有效管理游戏进度。此外,你将通过使用 C#创建基于存储数据的数据驱动元素来增强游戏深度和交互性。过渡到代码管理,你将学习如何利用版本控制系统在 Unity 项目中高效管理代码仓库。你还将学习如何通过共享代码仓库有效地进行协作,解决冲突,并在团队合作中使用 C#保持代码质量。

本部分包括以下章节:

  • 第六章, 使用 C#在 Unity 中有效处理和管理游戏数据

  • 第七章, 使用 C#在 Unity 中为现有代码库做出贡献

第七章:在 Unity 中使用 C# 进行有效的游戏数据处理和管理

欢迎来到 第六章,我们将深入探讨在 Unity 中使用 C# 进行有效的游戏数据处理和管理。通过实际探索,您将学习如何无缝地组织、存储和检索游戏数据。从理解数据组织和序列化到实现保存和加载系统,您将能够赋予玩家保存进度并构建动态、数据驱动的游戏体验的能力。到本章结束时,您将掌握使用 C# 进行高效游戏数据管理的艺术,解锁沉浸式游戏的无尽可能性。让我们共同踏上这段旅程,掌握游戏数据管理将开启游戏开发中无限创造力的门户。

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

  • 使用 C# 进行数据组织和序列化

  • 使用 C# 创建保存和加载系统

  • 使用 C# 的数据驱动游戏玩法

技术要求

要完成本章,您必须具备以下条件:

  • Unity 版本 2022.3.13:下载并安装 Unity,选择 2022.3.13 版本以获得与提供内容的最优兼容性。

  • 主要 IDE – Visual Studio 2022:本章中的教程和代码示例都是使用 Visual Studio 2022 制作的。请确保已安装,以便您可以无缝地跟随。如果您更喜欢 Rider 或其他 IDE,请随意探索,但请注意,提供的说明是为 Visual Studio 定制的。

  • 代码示例的 GitHub 仓库:您可以通过本书的专用 GitHub 仓库访问本章的代码示例和项目文件:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp/tree/main/Assets/Chapter%2006。克隆或下载仓库,以便您可以轻松访问本章提供的代码。

使用 C# 进行数据组织和序列化

在本节中,我们将深入探讨 Unity 中使用 C# 进行数据组织和序列化。在这里,我们将学习如何高效地管理游戏数据。首先,我们将讨论选择合适的数据结构,例如数组和列表,以及如何创建自己的数据结构。然后,我们将介绍序列化,它有助于保存和加载游戏数据。接着,我们将探索 Unity 的选项,例如 JavaScript 对象表示法JSON)和 可扩展标记语言XML)。通过一些简单的示例,我们将向您展示如何使用 C# 整洁地组织和保存游戏数据。让我们开始学习如何掌握游戏数据管理吧!

理解数据结构

选择适合你在游戏中存储信息的数据结构需要考虑诸如数据类型、访问频率以及你需要对该数据进行哪些操作等因素。以下是一些示例,说明你可以如何选择正确的方法:

  • 数组:当你有一个固定大小的相同类型元素集合时,请使用数组。

    比如,如果你有一个具有固定等级数量的游戏,如下面的代码所示,你可能使用数组来存储等级数据,例如每个等级的分数或完成状态:

    int[] levelScores = new int[10]; // An array to store scores for 10 levels
    
  • 列表:列表是动态数组,可以在运行时增长或缩小。当你需要频繁地添加或删除元素时,它们是合适的。

    例如,如果你有一个具有动态添加或删除物品的库存系统的游戏,列表会更合适。看看以下代码的例子:

    List<string> inventoryItems = new List<string>(); // A list to store inventory items
    inventoryItems.Add("Sword");
    inventoryItems.Add("Potion");
    
  • 字典:当你需要将键与值关联时,字典很有用。它们在需要根据特定键快速查找值的情况下非常理想。

    • 比如,如果你有一个排行榜的游戏,你可能使用字典将玩家名字映射到他们的分数,如下所示:

      Dictionary<string, int> leaderboard = new Dictionary<string, int>(); leaderboard.Add("Player1", 1000);
      leaderboard.Add("Player2", 1500);
      
  • 自定义数据结构:有时,内置的数据结构都不完全适合你的需求。在这种情况下,你可以创建定制的、符合你特定要求的数据结构。

    • 例如,如果你正在开发一个复杂的角色扮演游戏(RPG),你可能创建一个自定义数据结构来表示角色属性和能力。Character类就是这样一种自定义数据结构:

      public class Character {
        public string Name;
        public int Health;
        public int AttackDamage;
      }
      

通过仔细考虑你的数据和你需要执行的操作,你可以为你的游戏选择最合适的数据结构,确保最佳性能和高效的数据管理。

通过适当选择数据结构来增强游戏性能

在游戏制作的世界里,速度和效率最为重要,选择合适的数据结构至关重要。这有助于游戏制作者实现更流畅、更快的游戏体验,使他们的游戏对玩家来说更加有趣。

选择合适的数据结构可以从几个方面显著提高游戏性能:

  • 优化内存使用:使用正确的数据结构有助于最小化内存使用,这对于性能至关重要,尤其是在资源密集型游戏中。

    • 例如,如果你的游戏只需要存储固定数量的相同类型元素,使用数组而不是列表可以节省内存,因为数组具有固定的大小。
  • 更快的访问和检索:某些数据结构提供了更快的访问和检索时间,这可以提高整体游戏响应速度。

    • 比如,字典提供了常数时间的查找,这使得它们在需要根据键快速检索值的情况下非常理想,例如在排行榜中访问玩家数据。
  • 高效的数据操作:选择合适的数据结构可以简化数据操作,从而带来更流畅的游戏体验。

    • 例如,列表允许高效地插入和删除元素,这使得它们适合动态场景,如管理经常添加或删除物品的库存。
  • 增强代码可读性和可维护性:使用合适的数据结构可以使你的代码更易于阅读和维护,从而简化调试和未来的更新。针对游戏特定需求定制的自定义数据结构可以改善代码组织性和清晰度,使你和其他开发者更容易理解和修改代码库。

    • 例如,如果你的游戏包含挑战,你可以设计一个专门用于管理这些挑战的自定义类。在这个类中,你可以包含诸如奖励列表和每个挑战的唯一标识符等属性。这种方法增强了代码库的可读性。
  • 减少处理开销:最佳数据结构有助于减少处理开销,从而带来更流畅的游戏体验和更好的整体性能。

    • 例如,如果你的游戏需要按特定顺序遍历元素集合,使用列表而不是字典可以消除不必要的键值对查找,从而缩短迭代时间。

总结来说,选择合适的数据结构对于优化游戏性能至关重要,因为它有助于最小化内存使用,提高访问时间,简化数据操作,增强代码可读性,并减少处理开销。通过了解不同数据结构的特性和优势,游戏开发者可以为玩家设计更高效和响应迅速的游戏体验。

在接下来的小节中,我们将更深入地探讨 Unity 的序列化,探讨其在高效保存和加载数据中的作用。

Unity 中的序列化

序列化是将复杂对象或数据结构转换为易于存储或传输的格式,并在以后重新构造的过程。在 Unity 游戏开发中,序列化在保存和加载游戏数据方面发挥着关键作用。通过序列化游戏对象及其属性,Unity 可以将它们存储为可以保存到磁盘或通过网络传输的格式,从而实现会话间的持久性,并启用保存游戏和网络多人游戏等功能。

序列化是游戏开发的一个基本方面,原因有以下几点:

  • 会话间的持久性:序列化允许游戏状态和玩家进度在游戏会话之间保存和加载。这使得保存和加载游戏进度、维护玩家库存和保留游戏设置等功能成为可能。

  • 网络通信:序列化有助于在网络中传输游戏数据,这对于多人游戏、客户端-服务器架构和在线功能至关重要。通过序列化游戏对象和消息,Unity 可以高效地在客户端和服务器之间发送数据。

  • 数据交换:序列化允许数据在不同系统和平台之间交换。例如,游戏数据可以序列化为标准化的格式,如 JSON 或 XML,使其能够与其他应用程序共享或与网络服务集成。

  • 数据持久性:序列化允许数据以结构化格式(如文件或数据库)存储,确保即使在游戏不运行时数据也能持久存在。这对于保存和加载用户偏好、高分和游戏配置等功能至关重要。

通过理解序列化的原理并掌握 Unity 的序列化选项,我们可以实现强大且灵活的数据管理系统,实现如保存游戏、网络多人游戏和数据驱动游戏机制等功能。序列化是我们工具箱中的基本工具,使我们能够创建跨平台和类型的沉浸式和动态游戏体验。序列化是我们工具箱中的基本工具,赋予我们创建跨平台和类型的沉浸式和动态游戏体验的能力。

在 Unity 中,序列化无缝集成到引擎的工作流程中,使我们能够轻松地使用内置的 API 和实用工具保存和加载游戏数据。Unity 提供了各种序列化选项,包括 JSON、XML 和二进制序列化,每个都适合不同的用例和需求。正如我们在这个部分所看到的。

让我们探索 Unity 中可用的选项,以便我们将它们集成到我们的游戏中。我们将从定义每个选项并提供示例以更好地理解开始。我们将深入探讨每一个,并展示它们的用法。

JSON 序列化和反序列化

JSON 是一种轻量级的数据交换格式,常用于在服务器和 Web 应用程序之间传输数据。在 Unity 中,JSON 序列化和反序列化对于需要与外部系统或网络服务交换数据的场景非常有用。

示例:在下面的代码块中,我们正在使用 Unity 内置的系统来序列化和反序列化数据:

// Serialize object to JSON string
string jsonString = JsonUtility.ToJson(myObject);
// Deserialize JSON string back to object
MyClass deserializedObject = JsonUtility.FromJson<MyClass>(jsonString);

XML 序列化和反序列化

XML 是一种多用途的格式,用于数据交换和配置设置。Unity 支持 XML 序列化和反序列化,使其适用于需要与遗留系统或使用 XML 作为数据交换格式的平台集成的场景。

示例:以下代码块演示了如何将数据序列化和反序列化到 XML。

要使用这种类型的序列化,您必须包含using System.IO;using System.Xml.Serialization;命名空间,如下所示:

// Serialize object to XML string
XmlSerializer serializer = new XmlSerializer(typeof(MyClass));
StringWriter writer = new StringWriter();
serializer.Serialize(writer, myObject);
string xmlString = writer.ToString();
// Deserialize XML string back to object
StringReader reader = new StringReader(xmlString);
MyClass deserializedObject = (MyClass)serializer.Deserialize(reader);

让我们更详细地看看这里提供的 XML 序列化和反序列化:

  • 序列化过程

    • 序列化是将对象转换为易于存储或传输的格式,并在以后重建的过程。

    • XmlSerializer 是 .NET 框架提供的一个类,用于将对象序列化和反序列化为 XML 格式。

    • XmlSerializer serializer = new XmlSerializer(typeof(MyClass));: 这行代码创建一个 XmlSerializer 类的实例,指定要序列化的对象类型(MyClass)。

    • StringWriter writer = new StringWriter();: 这行代码创建一个 StringWriter 对象,用于将 XML 内容作为字符串写入。

    • serializer.Serialize(writer, myObject);: 这行代码将 MyClass 类型的 myObject 实例序列化为 XML 格式,并将其写入 StringWriter

    • string xmlString = writer.ToString();: 这行代码将写入到 StringWriter 的 XML 内容转换为字符串表示形式,并存储在 xmlString 变量中。

  • 反序列化过程

    • 反序列化是从其序列化的 XML 表示形式重建对象的过程。

    • StringReader reader = new StringReader(xmlString);: 这行代码创建一个 StringReader 对象,用于从字符串中读取 XML 内容。

    • (MyClass)serializer.Deserialize(reader);: 这行代码将 StringReader 中的 XML 内容反序列化为 MyClass 类型的对象。此操作使用 XmlSerializer 类的 Deserialize 方法完成。

    • 反序列化的对象随后被分配给 deserializedObject 变量,以便在程序中使用。

总结来说,提供的代码块演示了如何使用 C# 中的 XmlSerializer 类将 MyClass 类型的对象序列化为 XML 字符串,然后使用 XmlSerializer 类将 XML 字符串反序列化为相同类型的对象。这个过程允许对象以 XML 格式轻松持久化到存储或通过网络传输,并在以后重建以供应用程序使用。

二进制序列化和反序列化

二进制序列化和反序列化非常适合需要高效保存和加载游戏数据的场景,例如实现游戏保存或在本机设备上存储配置设置。与基于文本的格式(如 JSON 或 XML)相比,二进制序列化提供了数据紧凑的表示形式,并且读写速度更快。

示例:以下代码块展示了如何将数据序列化和反序列化为二进制格式。

要使用此类序列化,必须包含 using System.IO;using System.Runtime.Serialization.Formatters.Binary; 命名空间,如下所示:

// Serialize object to binary format
BinaryFormatter formatter = new BinaryFormatter();
MemoryStream stream = new MemoryStream();
formatter.Serialize(stream, myObject);
byte[] binaryData = stream.ToArray();
// Deserialize binary data back to object
stream = new MemoryStream(binaryData);
MyClass deserializedObject = (MyClass)formatter.Deserialize(stream);

让我们更详细地看看这个代码块:

  • 序列化过程

    • 序列化是将对象转换为易于存储或传输的格式,并在以后重建的过程。

    • BinaryFormatter 是 .NET 框架提供的一个类,用于将对象序列化和反序列化为二进制格式。

    • BinaryFormatter formatter = new BinaryFormatter();: 这行代码创建了一个 BinaryFormatter 类的实例,用于二进制序列化

    • MemoryStream stream = new MemoryStream();: 这行代码创建了一个 MemoryStream 对象,用于在内存中存储二进制数据

    • formatter.Serialize(stream, myObject);: 这行代码将 myObject 实例序列化为二进制格式并写入 MemoryStream

    • byte[] binaryData = stream.ToArray();: 这行代码将写入到 MemoryStream 的二进制数据转换为字节数组,可以轻松存储或传输

  • 反序列化过程:

    • 反序列化是从其序列化的二进制表示中重建对象的过程。

    • stream = new MemoryStream(binaryData);: 这行代码创建了一个新的 MemoryStream 对象,初始化时使用存储在 binaryData 字节数组中的二进制数据。

    • (MyClass)formatter.Deserialize(stream);: 这行代码将 MemoryStream 中的二进制数据反序列化为 MyClass 类型的对象。这里使用 BinaryFormatter 类的 Deserialize 方法。

    • 反序列化的对象随后被分配给 deserializedObject 变量,以便在程序中使用。

总结来说,前面的代码块展示了如何使用 C# 中的 BinaryFormatter 类将 MyClass 类型的对象序列化为二进制格式,然后将二进制数据反序列化为同一类型的对象。这个过程允许对象以二进制格式轻松持久化到存储或通过网络传输,并在稍后重建以供应用程序使用。

ScriptableObject 序列化

ScriptableObjects 是 Unity 资产,允许你在序列化格式中存储数据,并在 Unity 编辑器中创建用于修改该数据的自定义编辑器界面。它们对于管理配置设置、定义游戏参数以及创建可在多个游戏对象之间共享的可重用组件非常有用。

示例: 在下面的代码块中,我们将探索脚本对象数据的示例。然后,我们将演示如何使用脚本对象进行序列化和反序列化。

第一个代码块包含一个用于游戏设置的 ScriptableObject,封装了与游戏操作相关的必要数据:

[CreateAssetMenu(fileName = "NewSettings", menuName = "Game Settings")]
public class GameSettings : ScriptableObject {
    public int playerHealth;
    public int enemyCount;
    public float playerSpeed;
}

对于第二个代码块,SettingsManager 类包含对 GameSettings 数据的引用,并包括保存和加载设置的函数:

public class SettingsManager : MonoBehaviour {
    public GameSettings gameSettings;
    // Serialize the GameSettings ScriptableObject to a file
    public void SaveSettings() {
        string jsonSettings = JsonUtility.ToJson(gameSettings);
        System.IO.File.WriteAllText(Application.persistentDataPath + "/settings.json", jsonSettings);
    }
    // Deserialize the GameSettings ScriptableObject from a file
    public void LoadSettings() {
        if (System.IO.File.Exists(Application.persistentDataPath + "/settings.json")) {
            string jsonSettings = System.IO.File.ReadAllText(Application.persistentDataPath + "/settings.json");
            gameSettings = JsonUtility.FromJson<GameSettings>(jsonSettings);
        }
    }
}

让我们更详细地看看 SettingsManager 类:

  • SaveSettings() 方法:

    • public void SaveSettings() { ... }: 这个方法负责将游戏设置保存到文件

    • JsonUtility.ToJson(gameSettings): 这个方法将 gameSettings 对象序列化为 JSON 格式

    • System.IO.File.WriteAllText(...): 这个方法将序列化的 JSON 数据写入应用程序持久数据路径中名为 settings.json 的文件

  • LoadSettings() 方法:

    • public void LoadSettings() { ... }:此方法负责从文件中加载游戏设置

    • System.IO.File.Exists(...):此方法检查持久数据路径中是否存在 settings.json 文件

    • System.IO.File.ReadAllText(...):此方法从 settings.json 文件中读取 JSON 数据

    • JsonUtility.FromJson(jsonSettings):此方法将 JSON 数据反序列化回 GameSettings 对象,并将其分配给 gameSettings 变量

总体来说,SettingsManager 类提供了使用 JSON 序列化和反序列化来保存和加载游戏设置的函数。它展示了在 Unity 中处理持久数据的文件 I/O 操作的基本方法。

关于 ScriptableObjects,我们有使用 XML 或二进制格式保存它们的灵活性,并将它们视为封装特定数据的自定义类。

总结来说,在 Unity 中选择合适的序列化和反序列化选项取决于数据交换需求、性能考虑以及与外部系统的集成。了解每个选项的优势和局限性,使开发者能够做出明智的决定,并在他们的 Unity 项目中实施高效的数据管理解决方案。

在理解数据组织的重要性之后,让我们考虑保存和加载系统在游戏管理中的作用。

使用 C# 创建保存和加载系统

保存和加载系统在管理游戏进度和确保无缝玩家体验方面发挥着关键作用。在本节中,我们将深入探讨各种方法,从基本的 PlayerPrefs 到更健壮的基于文件的保存系统,使我们作为开发者能够高效地保存和检索 Unity 中的玩家数据。

PlayerPrefs

Unity 中的 PlayerPrefs 作为存储键值对的简单解决方案,这对于保存简单的游戏数据至关重要。理解 PlayerPrefs 对于在 Unity 项目中高效管理基本玩家偏好和进度是基本的。作为键值存储,PlayerPrefs 特别设计用于在游戏会话之间存储玩家偏好和小量数据。其简单的界面简化了数据的设置和检索,使其非常适合管理设置、用户偏好和基本游戏进度。

使用技巧

让我们探索在 Unity 游戏开发中最大化 PlayerPrefs 优势的必要使用技巧,包括数据序列化、加密和安全措施,以及性能优化技术:

  • 数据序列化:虽然 PlayerPrefs 本地支持存储基本数据类型,如整数、浮点数和字符串,但更复杂的数据结构需要序列化。我们可以将自定义数据结构序列化成与 PlayerPrefs 兼容的格式,从而实现存储和检索复杂游戏数据。

  • 加密和安全:在将敏感玩家数据存储在PlayerPrefs之前,可以通过实现加密机制来保护它们。通过加密PlayerPrefs数据,我们可以防止未经授权的访问并保护玩家隐私。

  • 优化性能PlayerPrefs访问涉及磁盘 I/O 操作,这可能会影响性能,尤其是在资源密集型游戏中。为了减轻性能开销,尽可能批量执行PlayerPrefs操作,并在游戏过程中最小化频繁的读写操作。

结合这些使用技巧将帮助我们优化PlayerPrefs的使用,确保数据安全、性能高效,并在 Unity 项目中有效处理复杂游戏数据。

现在,让我们看看一个示例,演示如何利用PlayerPrefs保存和加载数据。

在这个第一个代码块中,GameData类是一个自定义类,包含必须保存和加载的游戏数据字段:

// Define a class for game data serialization
[System.Serializable]
public class GameData {
    public int playerLevel;
    public int playerExperience;
    // Additional game data fields...
}

第二个代码块包含保存和加载函数,它们使用GameData类和PlayerPrefs

// Save game data to PlayerPrefs
public void SaveGame() {
    GameData gameData = new GameData();
    // Populate game data with current game state
    gameData.playerLevel = PlayerController.instance.level;
    gameData.playerExperience = PlayerController.instance.experience;
    // Serialize game data to JSON
    string jsonData = JsonUtility.ToJson(gameData);
    // Save serialized data to PlayerPrefs
    PlayerPrefs.SetString("GameData", jsonData);
    PlayerPrefs.Save();
}
// Load game data from PlayerPrefs
public void LoadGame() {
    if (PlayerPrefs.HasKey("GameData")) {
        // Retrieve serialized data from PlayerPrefs
        string jsonData = PlayerPrefs.GetString("GameData");
        // Deserialize JSON data to game data object
        GameData gameData = JsonUtility.FromJson<GameData>(jsonData);
        // Apply loaded game data to game state
        PlayerController.instance.level = gameData.playerLevel;
        PlayerController.instance.experience = gameData.playerExperience;
    }
}

让我们更详细地看看保存和加载函数:

  • SaveGame():

    • 此函数负责保存游戏数据。

    • 它初始化GameData类的新实例,该实例可能包含表示游戏状态各个方面的字段。

    • 当前游戏状态随后被捕获并存储在GameData实例中。在这个例子中,它似乎是从PlayerController单例实例中捕获玩家的等级和经验。

    • 接下来,使用JsonUtility.ToJson()将游戏数据序列化为 JSON 格式。

    • 最后,使用PlayerPrefs.SetString()GameData键将序列化的 JSON 数据存储在PlayerPrefs中,并调用PlayerPrefs.Save()以持久化更改。

  • LoadGame():

    • 此函数加载保存的游戏数据。

    • 首先,它检查是否通过使用GameData键和PlayerPrefs.HasKey()PlayerPrefs中存储了现有的游戏数据。

    • 如果有保存的数据,它将使用PlayerPrefs.GetString("GameData")PlayerPrefs中检索序列化的 JSON 字符串。

    • 然后使用JsonUtility.FromJson()将 JSON 数据反序列化回GameData对象。

    • 最后,加载的游戏数据应用于游戏状态。在这个例子中,它似乎是将玩家的等级和经验恢复到保存的值。

总体而言,这些函数提供了一个简单的机制,用于使用PlayerPrefs保存和加载数据,允许在会话之间基本持久化游戏状态。

既然我们已经讨论了PlayerPrefs的显著好处,让我们看看它可能不是最佳选择的情况。

探索PlayerPrefs的限制和替代方案

虽然PlayerPrefs为 Unity 游戏存储少量数据提供了便利和简单性,但它也有几个限制和缺点,这可能会促使我们探索替代解决方案:

  • 存储容量有限:PlayerPrefs 的存储容量有限,这使得它不适合存储大量数据或复杂数据结构。尝试在 PlayerPrefs 中存储过多数据可能导致性能问题和内存限制。

  • 安全担忧:PlayerPrefs 数据以纯文本形式存储在玩家的注册表中(在 Windows 上)或plist文件中(在 macOS 和 iOS 上),这使得它容易受到篡改和未经授权的访问。对于需要增强安全措施或符合数据保护法规的应用程序,PlayerPrefs 可能无法为敏感数据提供充分保护。

  • 平台依赖性:PlayerPrefs 的存储位置和行为可能因不同平台和设备而异。这种平台依赖性在将游戏部署到多个平台时可能会引入不一致性和兼容性问题,需要开发者实现特定平台的处理或替代存储解决方案。

  • 数据类型有限:PlayerPrefs 支持有限的数据类型,包括整数、浮点数和字符串。复杂数据结构、数组或自定义对象不能直接存储在 PlayerPrefs 中,除非进行序列化和转换,这会导致额外的复杂性和潜在的性能开销。

  • 持久性挑战:PlayerPrefs 数据在游戏会话之间持续存在,但它可能不会在不同的设备或安装之间持续存在。卸载或重新安装游戏、清除应用程序数据或切换设备可能导致 PlayerPrefs 数据丢失,影响玩家进度和偏好。

  • 性能开销:访问 PlayerPrefs 涉及磁盘 I/O 操作,这可能会引入性能开销,尤其是在频繁读取或写入大量数据时。对于需要高性能数据存储或实时数据访问的应用程序,PlayerPrefs 可能无法满足性能要求。

由于这些限制和考虑,我们可能会选择替代的数据存储解决方案,例如二进制序列化、JSON 序列化、数据库系统或基于云的存储服务。这些解决方案为管理游戏数据提供了更大的灵活性、可伸缩性、安全性和性能,尤其是在涉及大量数据集、复杂数据结构或严格安全要求的情况下。虽然 PlayerPrefs 对于简单的数据存储需求来说是一个方便的选项,但在为 Unity 游戏设计数据管理系统时,我们应该仔细评估我们的需求并考虑替代方案。

自定义保存系统

在游戏制作不断变化的世界中,对强大且灵活的保存系统的需求越来越明显。现在,请进入自定义保存系统——这是一个旨在以技能和效率处理数据管理复杂性的智能解决方案。与通常的方法,如 PlayerPrefs 不同,自定义保存系统为我们提供了一套多功能工具,包括加密、云保存选项以及与可脚本化对象的平滑集成。

自定义保存系统功能

让我们来看看我们可以在自定义保存系统中使用哪些功能:

  • 通用功能:自定义保存系统具有用于保存和加载数据的通用功能,允许与各种可脚本化对象无缝集成。通过针对每种数据类型的特定需求定制的动态参数和返回值,它确保了数据管理中的适应性和多功能性。

  • 加密和解密:在游戏开发的世界中,安全性至关重要。利用加密和解密机制,自定义保存系统确保敏感玩家数据的机密性和完整性。通过强大的加密算法,它保护免受未经授权的访问和篡改,培养玩家的信任和信心。

我们将首先通过创建可脚本化对象来管理系统的数据来开始。我将草拟 PlayerData 脚本,该脚本将存储关键玩家信息,如下面的代码块所示:

  [CreateAssetMenu(fileName = "PlayerData", menuName = "Data/Player Data")]
  public class PlayerData : ScriptableObject
  {
      public string playerName;
      public int playerLevel;
      public float playerExperience;
  }

此外,我还会为 GameSettings 创建一个脚本,该脚本将存储相关游戏数据,如下面的代码块所示:

    [CreateAssetMenu(fileName = "GameSettings", menuName = "Data/Game Settings")]
    public class GameSettings : ScriptableObject
    {
        public int soundVolume;
        public bool isFullScreen;
        public int graphicsQuality;
    }

创建这些脚本后,您可以右键单击,选择 Data 并将实例放入其中。一旦完成,它们的结构将与 图 6.1 中显示的设置实例类似:

图 6.1 – 游戏设置 ScriptableObject 实例

图 6.1 – 游戏设置 ScriptableObject 实例

这些数据文件仅用于演示目的,允许您根据项目需求进行调整。

接下来,我们将创建一个保存管理脚本。但在深入之前,我们必须集成 2022.3.13。请按照以下步骤操作:

  1. 导航到顶部栏并打开 Window 菜单,然后选择 Package Manager。点击位于左上角的 Add 按钮。此操作将弹出一个菜单,如图 图 6.2 所示:

图 6.2 – 在包管理器面板中添加来自 git URL 的包

图 6.2 – 在包管理器面板中添加来自 git URL 的包

  1. com.unity.nuget.newtonsoft-json 粘贴到提供的面板中,然后点击 Add,如图 图 6.3 所示:

图 6.3 – 在包管理器面板中添加 git URL 的链接

图 6.3 – 在包管理器面板中添加 git URL 的链接

在创建SaveManager脚本之前,等待安装完成并让 Unity 编辑器编译。

SaveManager脚本

这个脚本是我们游戏中负责保存和加载数据的基本组件。如前所述,这种方法不是标准的,而是我们将用于我们游戏的一种方法。请随意采用这种方法或根据您的需求实现自己的方法。以下代码块包含SaveManager脚本:

    public class SaveManager : MonoBehaviour
    {
        private const string saveFileName = "saveData1.dat";
        private const string cloudSaveFileName = "cloudSaveData.dat";
        private static byte[] key = Convert.FromBase64String("kwAXmhR48HenPp04YXrKSNfRcFSiaQx35BlHnI7kzK0=");
        private static byte[] iv = Convert.FromBase64String("GcVb7iqWex9uza+Fcb3BCQ==");
        public static void SaveData(string key, string data)
        {
            string filePath = Path.Combine(Application.persistentDataPath, saveFileName);
            // Load existing data
            Dictionary<string, string> savedData = LoadSavedData();
            // Add or update data based on its key
            savedData[key] = data;
            // Serialize the entire dictionary
            string jsonData = JsonConvert.SerializeObject(savedData);
            byte[] encryptedData = EncryptData(jsonData);
            // Write the serialized data to the file
            using (FileStream fileStream = new FileStream(filePath, FileMode.Create))
            {
                fileStream.Write(encryptedData, 0, encryptedData.Length);
            }
        }
        public static string LoadData(string key)
        {
            string filePath = Path.Combine(Application.persistentDataPath, saveFileName);
            // Load existing data
            Dictionary<string, string> savedData = LoadSavedData();
            // Extract data based on its key
            if (savedData.ContainsKey(key))
            {
                return savedData[key];
            }
            else
            {
                Debug.LogWarning("No save data found for key: " + key);
                return null;
            }
        }

让我们更详细地看看其他脚本将使用的保存和加载方法:

  • SaveData:

    • 将键值对保存到文件中

    • 根据密钥加载现有数据,更新或添加新数据,并将字典序列化为 JSON

    • 将 JSON 数据加密并写入文件

  • LoadData:

    • 根据提供的密钥从保存文件中加载数据

    • 检查密钥是否存在于字典中,并返回相应的值

    • 如果密钥不存在,记录警告并返回null

以下代码块包含处理输入/输出操作的静态方法,用于加载和保存游戏数据:

        private static Dictionary<string, string> LoadSavedData()
        {
            string filePath = Path.Combine(Application.persistentDataPath, saveFileName);
            if (File.Exists(filePath))
            {
                byte[] encryptedData = File.ReadAllBytes(filePath);
                string jsonData = DecryptData(encryptedData);
                return JsonConvert.DeserializeObject<Dictionary<string, string>>(jsonData);
            }
            else
            {
                Debug.LogWarning("No save data found.");
                return new Dictionary<string, string>();
            }
        }
        public static void DeleteSaveData()
        {
            string filePath = Path.Combine(Application.persistentDataPath, saveFileName);
            if (File.Exists(filePath))
            {
                File.Delete(filePath);
                Debug.Log("Save data deleted.");
            }
            else
            {
                Debug.LogWarning("No save data found to delete.");
            }
        }

让我们更详细地看看之前的代码块:

  • LoadSavedData:

    • 从文件中加载保存的数据,并将其作为字典返回

    • 读取加密数据,解密它,将 JSON 反序列化为字典,并返回它

  • DeleteSaveData:如果存在,则删除保存数据文件

在以下代码块中,我们处理加密和解密以保护数据:

        private static byte[] EncryptData(string data)
        {
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = key;
                aesAlg.IV = iv;
                ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);
                using (MemoryStream msEncrypt = new MemoryStream())
                {
                    using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
                    {
                        using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
                        {
                            swEncrypt.Write(data);
                        }
                        return msEncrypt.ToArray();
                    }
                }
            }
        }
        private static string DecryptData(byte[] encryptedData)
        {
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = key;
                aesAlg.IV = iv;
                ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);
                using (MemoryStream msDecrypt = new MemoryStream(encryptedData))
                {
                    using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
                    {
                        using (StreamReader srDecrypt = new StreamReader(csDecrypt))
                        {
                            return srDecrypt.ReadToEnd();
                        }
                    }
                }
            }
        }
    }

让我们考虑加密方法(EncryptDataDecryptData):

  • 使用AES加密算法加密和解密数据

  • 使用加密密钥和IVEncryptData)对输入数据进行加密

  • 使用相同的密钥和IVDecryptData)解密加密数据

在这个脚本中,有一个名为SaveData的方法用于保存数据。它需要数据以字符串格式提供,以及相应的密钥。我们选择将数据存储在字符串和字符串的字典中,这样我们就可以轻松地管理各种文件的加载和保存数据。

对于加载,我们解密字符串数据然后加载它。

此外,我还实现了AES 加密方法来在加密和解密过程中保护数据。虽然 AES 加密的细节超出了本书的范围,但如果你对学习加密技术感兴趣,鼓励你进一步探索。

为了方便生成密钥和 IV,我准备了一个辅助脚本。您可以通过将此脚本附加到场景中的 GameObject 来使用此脚本。此脚本将使您能够为加密目的创建必要的密钥和 IV。您可以在SaveManager脚本中找到生成的密钥;无需重复创建过程,因为它只发生一次。

你可以从以下代码块中获取KeyAndIVGenerator脚本:

public class KeyAndIVGenerator : MonoBehaviour
{
    public static void GenerateKeyAndIV()
    {
        using (Aes aes = Aes.Create())
        {
            aes.GenerateKey();
            aes.GenerateIV();
            // Convert key and IV to base64 strings for easy storage and usage
            string base64Key = Convert.ToBase64String(aes.Key);
            string base64IV = Convert.ToBase64String(aes.IV);
            Debug.Log("Generated Key: " + base64Key);
            Debug.Log("Generated IV: " + base64IV);
        }
    }
    private void Start()
    {
        GenerateKeyAndIV();
    }
}

以下是KeyAndIVGenerator类的说明:

  • KeyAndIVGenerator 类包含一个名为 GenerateKeyAndIV 的方法,该方法负责生成加密密钥和 初始化 向量IV)。

  • GenerateKeyAndIV 方法内部,KeyAndIVGenerator 类使用 Aes.Create() 方法创建一个 Aes 类的实例,它代表 AES 算法。

  • 然后,它调用 Aes 实例上的 GenerateKey()GenerateIV() 方法来生成随机的加密密钥和 IV

  • 在生成密钥和 IV 后,它使用 Convert.ToBase64String() 方法将它们转换为 base64 字符串。Base64 编码用于方便地存储和使用密钥和 IV

  • 最后,它使用 Debug.Log() 将生成的密钥和 IV 记录到控制台,用于调试目的。

  • KeyAndIVGenerator 对象被初始化时,会调用 Start 方法,并调用 GenerateKeyAndIV 方法来生成密钥和 IV

总体而言,此代码块展示了如何使用 AES 算法生成加密密钥和 IV 值,并将它们转换为 base64 字符串以进行存储和使用。这在密码学中常用于保护数据。

下面的代码块提供了一个 GameManager 脚本的示例,该脚本利用 SaveManager 中的保存和加载方法来管理保存和加载 PlayerDataGameSettings 的过程:

    public class GameManager : MonoBehaviour
    {
        public PlayerData playerData;
        public GameSettings gameSettings;
        private void Start()
        {
            LoadGameData();
        }
        private void OnApplicationQuit()
        {
            SaveGameData();
         }

让我们考虑 Unity 回调函数:

  • Start 方法中,当游戏开始时,它会调用 LoadGameData 函数来加载玩家数据和游戏设置。

  • 当应用程序即将退出时,会调用 OnApplicationQuit 方法,并在退出前调用 SaveGameData 函数来保存玩家数据和游戏设置。

下面的代码块包含了加载和保存数据的逻辑:

        private void LoadGameData()
        {
            if (playerData == null)
            {
            }
            else
            {
                JsonUtility.FromJsonOverwrite(SaveManager.LoadData("playerData"), playerData);
            }
            if (gameSettings == null)
            {
                gameSettings = ScriptableObject.CreateInstance<GameSettings>();
            }
            else
            {  JsonUtility.FromJsonOverwrite(SaveManager.LoadData("gameSettings"), gameSettings);
            }
        }
        private void SaveGameData()
        {
            SaveManager.SaveData("playerData", JsonUtility.ToJson(playerData));
            SaveManager.SaveData("gameSettings", JsonUtility.ToJson(gameSettings));
        }
    }

让我们更详细地查看保存和加载函数:

  • LoadGameData 函数使用 SaveManager.LoadData 方法从保存文件中加载玩家数据和游戏设置。如果找不到数据,它将创建新的 PlayerDataGameSettings 实例。

  • SaveGameData 函数使用 SaveManager.SaveData 方法将玩家数据和游戏设置保存到保存文件中。在保存之前,它使用 JsonUtility.ToJson 将数据对象转换为 JSON 格式。

总体而言,此代码块展示了使用 JSON 序列化加载和保存游戏数据的基本实现。它确保玩家数据和游戏设置在游戏会话之间持久化,从而实现无缝的游戏体验。

在本节中,我们学习了如何保存和加载可脚本化对象,保存和加载到文件的技巧,使用 AES 加密以保护数据,以及如何生成加密密钥。最后,我们实际练习了使用这些概念。

现在,我们需要了解使用自定义方法而不是 PlayerPrefs 的好处。

相对于 PlayerPrefs 的优势

虽然 PlayerPrefs 提供了简单性,但自定义保存系统超越了其限制,提供了传统存储方法所不具备的各种功能和特性。通过减少性能开销和提高数据安全性,它标志着 Unity 游戏开发中数据管理新时代的到来。

总结来说,自定义保存系统代表了创新和创造力——这是对游戏开发中持续追求卓越的证明。凭借其简化数据管理、增强安全措施和提高玩家体验的能力,它已成为现代游戏开发实践的基础,每次保存都在重塑这一领域。

在下一节中,我们将实现游戏进度部分的 ScriptableObjects,同时保存和加载数据。

使用 C# 进行数据驱动游戏

数据驱动设计是一种游戏开发方法,其中游戏行为、内容和配置由外部数据文件定义和控制,而不是硬编码到游戏源代码中。这种方法提供了几个好处,包括提高灵活性、更容易的内容迭代和增强的可维护性。通过将游戏数据与代码分离,我们可以修改游戏行为、调整参数和添加新内容,而无需进行代码更改,从而加速迭代周期并赋予设计师在游戏机制方面进行实验的权力。

让我们从 ScriptableObjects 在管理数据方面的一个用途开始。

创建统计数据的数据

我们的项目中有一个 PlayerMovement 脚本,如下面的代码块所示,它处理玩家的移动:

   public class PlayerMovement : MonoBehaviour
   {
       public float moveSpeed = 5f;
       public float jumpForce = 5f;
       public float dashForce = 10f;
       public float dashCooldown = 2f;
       public Transform groundChecker;
       public LayerMask groundLayer;
       public float groundDistance;
       public Rigidbody playerRigidbody;
       private bool isGrounded = true;
       private bool canDash = true;
       private Vector3 movementVector;
  private void MovePlayer()
  {
      Vector3 movement = new Vector3(movementVector.x , 0f , movementVector.y) * moveSpeed * Time.deltaTime;
      transform.Translate(movement);
  }
//rest of code
}

在这里,我们可以创建一个 ScriptableObject,它将包含玩家移动的参数,例如速度和力量。然后,我们可以获取该 ScriptableObject 的引用。

以下代码块包含一个名为 PlayerStats 的 ScriptableObject,它将存储移动数据:

[CreateAssetMenu(fileName = "PlayerStats", menuName = "Data/Player Stats")]
 public class PlayerStats : ScriptableObject
 {
     [SerializeField] float moveSpeed = 5f;
     [SerializeField] float jumpForce = 5f;
     [SerializeField] float dashForce = 10f;
     [SerializeField] float dashCooldown = 2f;
     public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
     public float JumpForce { get => jumpForce; set => jumpForce = value; }
     public float DashForce { get => dashForce; set => dashForce = value; }
     public float DashCooldown { get => dashCooldown; set => dashCooldown = value; }
 }

现在,我们可以在玩家移动脚本中将 PlayerStats ScriptableObject 作为变量使用,并相应地替换掉任何之前使用移动变量的逻辑:

  public class PlayerMovement : MonoBehaviour
  {
      public PlayerStats playerStats;
      public Transform groundChecker;
      public LayerMask groundLayer;
      public float groundDistance;
      public Rigidbody playerRigidbody;
      private bool isGrounded = true;
      private bool canDash = true;
      private Vector3 movementVector;
      private void MovePlayer()
      {
        Vector3 movement = new Vector3(movementVector.x , 0f , movementVector.y) * playerStats.MoveSpeed * Time.deltaTime;
        transform.Translate(movement);
      }
  //rest of code
  }

我们这样做是因为我们需要一个对所有移动参数的单个引用,并且以后从其他脚本(如特殊增益)应用或调整统计数据时,将方便地不修改玩家移动脚本内的代码。我们可以直接修改 ScriptableObject,这将影响玩家的移动。

此外,如果我们游戏中包含物品,将所有数据合并到每个物品类型的单独 ScriptableObject 中将简化使用和修改所有对象的过程。在不直接引用这些对象的情况下,我们可以更改数据文件,这将影响使用该数据的所有对象。

让我们考虑 ScriptableObjects 的另一种用途。

挑战系统

我们可以利用 ScriptableObjects 来跟踪挑战中的玩家进度,从而有效地跟踪挑战并保存所取得的进度。

我们将首先将CommonChallengeData迁移到 ScriptableObject 中,如下面的代码块所示:

    [CreateAssetMenu(fileName = "CommonChallengeData", menuName = "Data/Common Challenge Data")]
    [Serializable]
    public class CommonChallengeData : ScriptableObject
    {
        public bool isCompleted;
        public RewardType rewardType; // Type of reward
        public int rewardAmount;      // Amount or value of the reward
    }

然后,在完成挑战之后,我们可以利用之前建立的保存管理器来存储挑战统计数据。这可以在以下代码块中的EnemyWavesChallengeCompleteChallenge函数中看到:

    public class EnemyWavesChallenge : BaseChallenge
    {
        //Rest of Code
        public override void CompleteChallenge()
        {
            if (!commonData.isCompleted)
            {
                RewardManager.Instance.GrantReward(commonData);
                commonData.isCompleted = true;
                SaveManager.SaveData(challengeSavedKey, JsonUtility.ToJson(commonData));
            }
            else
            {
                Debug.Log("Challenge already completed!");
            }
        }
        //Rest of code
    }

我们在CompleteChallenge方法中包含了最后一行,以便在挑战的完成函数中保存挑战数据。因此,在开始挑战之前,我们将在挑战管理器中验证其完成状态。

此外,我们必须在ChallengeManager中的StartChallenge函数内使用SaveManager脚本的加载数据功能,如下面的代码块所示:

    public class ChallengeManager : Singlton<ChallengeManager>
    {
        //Rest of code
        public void StartChallenge(ChallengeType challengeType)
        {
            if (challengeDictionary.TryGetValue(challengeType, out BaseChallenge challengeScript))
            {
                JsonUtility.FromJsonOverwrite(SaveManager.LoadData(challengeScript.challengeSavedKey), challengeScript.commonData);
                if (!challengeScript.commonData.isCompleted)
                {
                    SetCurrentChallenge(challengeScript);
                    currentChallenge.StartChallenge();
                }
                else
                {
                    Debug.Log("Challenge already completed!");
                }
            }
            else
            {
                Debug.LogError($"No challenge script found for ChallengeType {challengeType}");
            }
        }
//Rest of code
    }

这是一个简单的方法,可以将现有代码转换为使用 ScriptableObjects 或改进的数据管理方法。此外,它集成了保存和加载功能,以监控挑战进度。虽然从头开始设计系统将提供更好的数据处理策略,但这个系统是按照不同的方式构建的。我们有机会在本章中对其进行修改,强调了适应现有代码库的重要性。我们将在第七章中深入探讨这些技能的细化。

总结来说,使用 C#进行数据驱动游戏提供了创建动态、可定制和沉浸式游戏体验的强大框架。通过采用数据驱动设计原则,并利用 ScriptableObjects 来构建模块化游戏元素,我们可以构建灵活、可扩展且吸引玩家的游戏,这些游戏能够经受时间的考验。

摘要

在本章中,我们探讨了对于高效游戏开发至关重要的概念和技术。我们首先探讨了数据结构的重要性及其对游戏性能的影响,强调了选择适当的数据结构以获得最佳结果的重要性。在导航 Unity 的序列化选项时,Unity 的序列化成为焦点,包括 XML、JSON 和二进制序列化方法。我们讨论了每种序列化方法的细微差别及其在不同场景中的适用性,为 Unity 项目中稳健的数据管理奠定了基础。

我们继续深入探讨 Unity 游戏开发中 PlayerPrefs 及其局限性,为自定义存档系统铺平了道路。通过详细的讨论和实际案例,我们揭示了自定义存档系统相较于 PlayerPrefs 的优势和特点,强调了其在提升数据驱动游戏体验中的作用。《SaveManager》脚本成为了一个关键组件,它通过提高效率和灵活性,实现了无缝的数据保存和加载操作。通过利用 C#和 scriptableObjects,我们得以拥抱数据驱动的游戏机制,并优化挑战系统以增强用户参与度和进步。通过细致的探索和动手学习,本章为我们提供了掌握 Unity 中 C#数据组织和序列化的知识和工具。

第七章中,我们将进入协作游戏开发和版本控制系统领域。基于我们的基础知识,我们将探讨如何有效地为现有代码库做出贡献,并使用 C#在开发团队中进行协作。从理解版本控制系统到掌握代码合并和冲突解决技术,我们将掌握在共享代码库中无缝导航的必要技能。通过实际案例和逐步指导,我们将了解与共享代码库协作的复杂性,以及在团队环境中维护代码质量。加入我们,深入了解游戏开发的协作方面,为增强团队合作和代码管理实践铺平道路。

第八章:使用 C#在 Unity 中为现有代码库做出贡献

欢迎来到第七章。本章将为您提供在 C#开发团队中工作所需的基本协作技能。我们将探讨版本控制系统VCSs)、代码合并和冲突解决,以实现无缝团队合作。我们还将涵盖掌握版本控制、使用共享仓库协作以及使用 C#解决冲突。最后,我们将了解现有代码库,以便导航结构、审查文档和有效沟通。掌握所有这些技能将促进有效的团队贡献,并在 Unity 项目中保持代码质量。

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

  • 介绍 VCSs

  • 使用 C#协作和解决冲突

  • 理解现有代码库

技术要求

您需要以下内容来跟随本章:

  • 主要 IDE - Visual Studio 2022:教程和代码示例使用 Visual Studio 2022 制作。请确保已安装,以便无缝跟随。如果您更喜欢 Rider 或其他 IDE,请随意探索,尽管说明是为 Visual Studio 量身定制的。

  • GitHub Desktop:请确保已安装,以便无缝跟随。

介绍 VCSs

VCS 是软件开发中用于管理文件更改的工具。它就像一个详细的记录员,记录项目文件所做的每一项更改。

这里是为什么它很重要:

  • 跟踪更改:在协作项目中,多个人可能对同一文件进行工作。VCS 记录所有修改,确保透明度和问责制。

  • 促进协作:使用 VCS,团队成员可以看到谁在何时进行了特定更改。这种透明度促进了顺畅的协作并防止了冲突。

  • 撤销更改:错误是难免的,但 VCS 允许我们在需要时回滚到文件的早期版本。它充当安全网,提供撤销错误的方法。

  • 保持组织:VCS 通过分类更改并提供管理项目文件的结构化方法来帮助保持秩序。它使团队能够高效工作并保持组织。

总结来说,VCS 是软件开发团队的关键工具。它有助于跟踪更改,鼓励团队合作,允许修复错误,并确保项目保持组织。

在接下来的部分中,我们将深入了解理解 VCSs,探讨两种主要类型:分布式和集中式系统。

理解 VCSs

当您在 VCS 中创建新仓库时,您将打开主分支。这也被称为主干 master。主干 master 作为主代码库的起点,然后经过编译和部署到达最终用户。

但分支呢?分支是在从主分支提取代码以创建单独路径时发生的。这使我们能够在不影响主版本的情况下修改代码。通过使用分支,我们避免了需要在同一地点合并所有更改的需求;相反,我们可以随着时间的推移跟踪代码的修改。然后,版本控制系统可以将这些独立的分支重新集成到主分支中。如果我们不打算将其他更改合并到主分支中,我们可以将它们存储在单独的分支中,稍后进行合并。

制定良好的分支策略对于防止代码中的冲突和错误非常重要。幸运的是,健壮的版本控制系统使团队轻松与主分支同步并解决任何潜在冲突——即使主分支已经进行了更改。

现在我们已经掌握了 VCS 的基本工作原理,让我们探索两种在塑造协作开发过程中发挥关键作用的初级类型:分布式和集中式系统。

分布式版本控制系统

分布式版本控制系统DVCS)是一种版本控制系统,其中每个用户都在他们的本地计算机上拥有项目仓库的完整副本。这意味着即使他们离线,你也可以继续在项目上工作。

下图显示了 DVCS 的结构:

图 7.1 – DVCS 结构

图 7.1 – DVCS 结构

这是如何工作的:

  • 本地副本:在 DVCS 中,每个用户都有整个项目历史的本地副本,包括所有文件和随时间进行的更改。这允许你独立工作并做出更改,而不需要依赖于中央服务器。

  • 灵活的协作:由于每个用户都有自己的仓库副本,他们可以在不影响彼此工作的情况下工作在不同的功能或修复上。他们可以将更改提交到本地仓库,并在稍后与别人分享。

  • 增强的安全性:由于整个项目历史都存储在本地,DVCS 提供了冗余和安全。即使中央服务器宕机,你仍然可以继续在本地副本上工作,并在服务器恢复在线后同步更改。

  • 高效的分支和合并:如Git这样的 DVCS 系统提供了强大的分支和合并功能。你可以创建分支来处理新功能或实验性更改,而不会影响主项目。当你准备好时,可以将你的更改合并回主分支。

Git 是 DVCS 的一个例子。它允许用户在本地计算机上维护项目仓库的完整副本。这意味着你可以访问整个项目历史,即使你离线也可以工作。

使用 Git,你可以创建分支来处理新功能或修复,而不会影响主代码库。一旦你的更改完成,这些分支可以合并回主分支。

Git 还通过允许您通过共享远程仓库与他人共享您的更改来促进团队成员之间的协作。然后,其他团队成员可以从远程仓库拉取您的更改到他们的本地副本。

总体而言,Git 的分布式特性和强大的分支功能使其成为许多开发团队的优先选择。它为软件项目的管理和跟踪变更提供了灵活性、效率和无缝协作。

总结来说,DVCS 使我们能够在去中心化的环境中独立工作,有效协作,并维护项目历史和完整性。它为软件项目的管理和跟踪变更提供了灵活性、安全性和强大的功能。

集中式版本控制系统

集中式版本控制系统CVCS)是一种版本控制系统,其中有一个单一的中央仓库,存储所有文件及其相应的版本。

以下图显示了 CVCS 的结构:

图 7.2 – CVCS 结构

图 7.2 – CVCS 结构

这就是它的工作方式:

  • 单一仓库:在 CVCS 中,所有用户都访问并工作在同一个中央仓库上。这意味着项目的历史只有一个副本,存储在中央服务器上。

  • 有限的离线访问:由于仓库是集中的,用户通常需要连接到中央服务器才能访问文件并进行更改。这可能限制了离线工作或在连接有限的环境中的能力。

  • 协作工作流程:用户直接将更改提交到中央仓库,在那里它们对所有团队成员可见。这促进了协作并确保每个人都在使用代码的最新版本。

  • 潜在的瓶颈:在 CVCS 中,中央服务器可能成为瓶颈,尤其是在大型团队或使用量大的项目中。如果服务器宕机,开发者可能无法访问或提交更改,直到服务器恢复。

  • 有限的分支功能:与 Git 这样的分布式系统相比,CVCS 系统通常提供有限的分支功能。当在不同的功能或更改上工作时,用户可能需要紧密协调以避免冲突。

SVN(或Subversion)是集中式版本控制系统(CVCS)的一个例子。在 SVN 中,有一个单一的中央仓库,存储所有项目文件及其相应的版本。

与 Git 这样的分布式版本控制系统(DVCS)不同,SVN 不向用户提供整个项目历史的本地副本。相反,用户直接与中央仓库交互,以访问文件并进行更改。

SVN 通过允许开发者直接将更改提交到中央仓库来促进协作工作流程。这确保了每个人都在使用代码的最新版本,并有助于维护项目完整性。

虽然 SVN 缺乏一些分布式版本控制系统(DVCSs)的灵活性和离线功能,但它仍然是许多开发团队的首选,尤其是在需要严格控制代码库的中心化环境中。

总结来说,集中式版本控制系统(CVCS)依赖于单个中央仓库来存储和管理项目文件和版本。虽然它促进了协作并提供了一个集中的真相来源SoT),但它可能在离线访问、潜在瓶颈和有限的分支能力方面带来挑战。

接下来,让我们深入了解必要的 Git 命令。

在下面的表格中,您将找到必要的 Git 命令。请确保您的系统已安装 Git:

命令 描述
git init 在当前目录中初始化一个新的 Git 仓库
git clone [url] 从远程服务器克隆现有的 Git 仓库到本地机器
git add [file] 将文件或更改添加到暂存区,以便包含在下一个提交中
git commit -m "[message]" 使用描述性消息将更改提交到本地仓库
git push 将本地仓库内容上传到远程仓库
git pull 从远程仓库下载更改并将其合并到本地仓库
git status 显示工作目录和暂存区的状态
git log 显示仓库中的提交列表,包括作者、日期和提交信息等详细信息
git branch 列出本地仓库中的所有分支
git checkout [branch] 切换到指定的分支
git merge [branch] 将指定分支的更改合并到当前分支
git remote -v 列出与本地仓库关联的所有远程仓库

许多命令可能令人感到不知所措,尤其是考虑到我们书籍的背景。因此,我们将依赖图形用户界面(GUI)工具,如 GitHub Desktop 应用程序进行版本控制,其中大多数操作都可以通过用户界面(UI)完成。请随意依赖 GUI 来完成所有过程。

在下一节中,我们将探讨代码协作的最佳实践和高效解决冲突的方法。

使用 C# 进行协作和解决冲突

在协作环境中导航和维护代码质量是软件开发的重要方面。让我们在以下章节中探讨有效的协作、冲突解决和代码质量维护。

协作的最佳实践

高效的协作对于项目成功开发至关重要,采用最佳实践可以确保在整个过程中团队协作和代码管理顺畅。以下是一些在与其他版本控制系统(VCSs)一起工作时需要遵循的最佳实践:

  • 频繁和增量提交以实现流畅的工作流程:进行小而频繁的提交是提高工作流程的简单而有效的方法,尽管这对一些开发者来说是一个挑战。在其他项目管理工具的背景下,任务通常被分解成可管理的部分,同样的方法也应该应用于提交。每个提交应专门对应一个任务或票据,除非一行代码奇迹般地解决了多个问题。对于更广泛的功能,将其分解成更小的任务并为每个任务创建提交是有益的。选择较小提交的主要优势是,在出现问题时,更容易检测和撤销不希望的变化。

  • 优先获取最新更改:养成在可行时将仓库中的最新更改获取到工作副本中的习惯。不建议孤立工作,因为这会增加遇到合并冲突的风险。

  • 谨慎提交以实现流畅的工作流程:避免仓促提交。commit -a 命令或其等效命令仅在项目的初始提交时使用,通常情况下,项目仅包含README.md文件。提交应仅包含与提交到仓库的特定更改相关的文件。在处理 Unity 项目时,要格外小心,因为某些修改可能会意外地影响多个文件,例如场景、预制件或精灵图集,即使不是有意为之。不小心提交更改到另一个团队成员正在同时编辑的场景可能会导致他们在自己的提交过程中遇到问题,需要事先合并您的更改。

  • 编写清晰的提交信息:保持提交信息的清晰性,因为它们讲述了项目的发展历程。当提交信息明确指出“实现了第 3 关的新敌人行为”而不是选择更随意的表达,例如“第 3 关添加了一些酷炫的东西”时,在游戏中追踪新游戏机制的增加会更加方便。当使用 Jira 或 GitLab 等任务跟踪系统时,在提交信息中包含一个任务编号是有利的。许多系统可以配置为与智能提交集成,使您可以直接从提交信息中引用任务并更新其状态。例如,一个提交信息如“JRA-123 #close #comment 任务完成”将关闭 JRA-123 Jira 任务,并将注释“任务完成”附加到任务上。

在协作编码的领域,掌握这些最佳实践不仅能够促进团队协作的流畅性,还能在整个项目开发过程中简化代码管理。

掌握协作中的分支和合并

理解协作开发的分支和合并策略对于软件项目中的有效团队合作至关重要。以下是一些想法和指南,以帮助你实现这一点。

以下是一些构建分支和合并策略:

  • 功能分支:在单独的分支中工作于新功能或修复。这种做法保持了主分支的清洁性,同时使我们能够独立工作。

  • 发布分支:创建专门用于发布候选人的分支,以便在部署前稳定代码库。

  • 热修复分支:建立分支以解决生产中的关键问题或错误,而不会干扰持续的开发。

  • 长期分支:某些项目可能需要长期分支来支持持续的开发工作或特定的功能集。

探索分支、分叉和拉取请求为协作编码和版本控制系统提供了宝贵的见解:

  • 分支:为每个你工作的新任务或功能创建功能分支。这有助于隔离更改,并使审查和合并代码变得更加容易。

  • 分叉:在开源项目中,贡献者通常会分叉主存储库以独立工作。分叉允许在不影响原始代码库的情况下进行实验。

  • 拉取请求:拉取请求(或合并请求)是提出更改和启动代码审查的关键机制。它们提供了一种结构化的方式来讨论和批准修改,在合并到主分支之前。

  • 代码审查:强调在拉取请求过程中进行彻底代码审查的重要性。审查代码有助于维护代码质量,识别潜在问题,并在团队成员之间分享知识。

鼓励明确的分支和合并策略,以及有效使用分支、分叉和拉取请求,可以促进软件开发团队中的协作、代码质量和项目稳定性。

精通代码冲突管理

理解代码冲突的本质以及如何解决它们对于无缝协作和项目成功至关重要。让我们深入了解常见的冲突类型,并学习在 Unity 项目中解决它们的具体技巧。

探索代码冲突的起源和在 Unity 项目中导航冲突解决

理解代码冲突的起源对于维护和谐的开发环境并确保团队成员之间协作顺畅至关重要。让我们深入了解导致这些冲突的具体因素:

  • 合并冲突:当多个贡献者修改相同的文件或代码块时,合并过程中可能会出现冲突

  • 结构变更:重命名文件、移动目录或更改项目结构可能会引入冲突

  • 依赖困境:项目组件之间的不兼容依赖或不同的库版本可能导致冲突

  • 分支分歧:与主分支的显著偏差使得将更改合并回主代码库变得具有挑战性

接下来,我们将探讨两种解决冲突的方法:

  • 手动冲突解决:学习如何审查代码文件中的冲突更改,并决定保留、修改或丢弃哪些修改

  • 版本控制集成:探索 Git 和其他 VCS 如何与 Unity 集成,提供内置的合并工具和第三方插件以解决冲突

接下来,让我们进行实际的冲突解决。

实践冲突解决

在了解冲突的原因后,学习如何解决它们是至关重要的。我将分享一个来自我当前项目的冲突示例以及我如何成功解决它。虽然冲突并不重大,但用于解决代码冲突的方法是普遍适用的。让我们继续使用 GitHub Desktop 和 Visual Studio 解决代码冲突。

当你的本地更改与远程服务器上对同一文件的修改冲突时,将显示以下面板:

图 7.3 – GitHub Desktop 中的解决冲突面板

图 7.3 – GitHub Desktop 中的解决冲突面板

当此面板出现时,表示需要比较并决定来自不同来源的同一文件的冲突版本。在 GitHub Desktop 中,此面板代表冲突解决界面。通过点击下拉按钮,你可以选择你偏好的编辑程序,例如 Visual Studio 或 Visual Studio Code。对于此示例,当你选择 Visual Studio 作为默认程序时,编辑器将打开。你可以选择点击打开合并编辑器选项,如图所示:

图 7.4 – Visual Studio 中的冲突模式

图 7.4 – Visual Studio 中的冲突模式

在合并编辑器中,你会注意到有三个部分:传入或远程版本、当前或本地版本以及合并后的结果文件。在这里,你需要审查更改并决定是否合并它们或根据功能或任务需求保留一个版本。一旦你或负责的高级人员完成编辑,点击接受合并

图 7.5 – Visual Studio 中的合并编辑器

图 7.5 – Visual Studio 中的合并编辑器

关闭合并编辑器后,你会注意到侧边栏现在包括一个提交更改的选项。然后,你可以在侧面板中点击提交暂存来在合并后推送更改,如图所示:

图 7.6 – 编辑冲突后的文件

图 7.6 – 编辑冲突后的文件

记住

你需要集中注意力,尤其是在同一文件涉及两个不同任务时,确保两个任务的逻辑都能正常工作。

让我们使用命令行界面CLI)来高效地解决合并冲突。

探索代码冲突起源并使用 CLI 导航冲突解决

命令行界面(CLI)是解决 Git 冲突的基本且广泛使用的方法。虽然提供了图形用户界面(GUI),并且可以提供冲突解决的视觉辅助,但许多开发者,尤其是那些习惯于基于终端的工作流程的开发者,更喜欢将命令行作为他们的默认方法。CLI 提供了细粒度的控制、精确的代码更改导航和高效的合并能力,使其成为有效管理 Git 冲突的强大工具。

为了测试这一点,请确保你在项目中还有一个分支。然后,我们将修改两个分支中的相同文件以创建冲突并解决它。让我们开始吧:

  1. 导航到你的项目目录并在那里打开终端,或者使用终端中的 cd 命令导航到你的项目目录。

  2. 或者,确保你目前处于你的其他分支之一。以我的情况为例,我有一个名为 feature/branch-name 的分支。

  3. 使用 git checkout branch-name 命令切换到所需的分支,如下图中所示:

图 7.7 – 检出功能分支

图 7.7 – 检出功能分支

  1. 现在,让我们修改一个文件。以我的情况为例,我将在脚本中注释掉一行,如下图中所示:

图 7.8 – 在函数中注释掉一行

图 7.8 – 在函数中注释掉一行

  1. 我们需要使用 git add filename 命令添加并提交该文件,然后使用 git commit -m "commit message" 命令进行提交,正如以下图中所示:

图 7.9 – 提交更改

图 7.9 – 提交更改

  1. 推送你的更改:

图 7.10 – 推送更改

图 7.10 – 推送更改

  1. 现在,我们可以转到另一个分支;以我的情况为例,我将使用 git checkout 命令返回到主分支:

图 7.11 – 返回主分支

图 7.11 – 返回主分支

  1. 修改相同的文件以创建冲突,然后我们需要添加、提交和推送更改。

  2. 然后,再次回到功能分支。

  3. 接下来,从主分支执行 merge 命令,如下图中所示:

图 7.12 – 从主分支合并

图 7.12 – 从主分支合并

我们也可以使用 git status 命令来识别需要解决冲突的文件或文件:

图 7.13 – 检查冲突文件

图 7.13 – 检查冲突文件

  1. 在编辑器中打开文件以解决冲突,如下图中所示:

图 7.14 – 冲突代码

图 7.14 – 冲突代码

  1. 在进行必要的编辑后,添加并提交文件:

图 7.15 – 解决冲突后的提交

图 7.15 – 解决冲突后的提交

  1. 使用 git status 命令确保一切清晰:

图 7.16 – 检查状态

图 7.16 – 检查状态

  1. 现在,在解决冲突后推送分支:

图 7.17 – 推送功能分支

图 7.17 – 推送功能分支

通过使用命令行界面(CLI),我们可以通过导航到项目目录、切换到包含冲突的分支、修改冲突文件、添加和提交更改、推送分支,最后将更改与主分支合并,来有效地在 Git 仓库中解决冲突。

总结来说,掌握代码冲突管理涉及理解冲突的起源和解决技术。在本节中,我们探讨了常见的冲突类型,如合并冲突和结构变化,并深入探讨了使用 GitHub Desktop、Visual Studio 和 CLI 等工具的实际冲突解决方法。通过有效地处理冲突,我们确保在 Unity 项目中实现顺利的合作和项目成功。

在接下来的章节中,我们将探讨如何有效地处理现有项目,提供指导和实际示例。

理解现有代码库

在深入研究现有代码库时,有一些关键步骤可以帮助你熟悉其结构和功能:

  • 项目结构和组织:首先探索项目的目录结构和组织方式。了解文件和文件夹是如何根据功能或模块进行排列和分组的。

  • 审查文档:寻找任何可用的文档,包括 README 文件、维基页面或代码中的内联注释。文档可以为项目的目的、架构和设计决策提供宝贵的见解。

  • 识别关键组件和关系:识别代码库中的关键组件、模块及其关系。确定代码的不同部分是如何相互交互的,并理解整体架构。

  • 利用代码分析工具和集成开发环境(IDE)功能:利用 IDE 提供的代码分析工具和功能来探索代码依赖、继承层次和函数调用。例如,静态代码分析工具可以帮助识别潜在问题或改进区域。

  • 理解编码标准和规范:熟悉项目中使用的编码标准和规范。注意命名规范、代码格式和文档实践,以确保代码库的一致性。

  • 与团队成员沟通:与团队成员或项目负责人沟通,以深入了解代码库及其设计决策。讨论你任何疑问或不确定性,并利用他们的专业知识来加深你的理解。

开始处理现有项目需要时间和对复杂性的投入,但这个初步的探索为有成效的贡献奠定了基础,确保项目的耐用性和易于维护。随着你对代码库的熟悉,你将能够提出改进建议,解决难题,并与项目团队进行有效的协作。

对现有代码库的实用探索

我们将通过示例说明代码审查过程,无论是遵循前面的步骤之后,还是事先考虑这些步骤。

如果你在一个新项目中分配了一个任务,你可以独立地按照这些步骤进行,如果需要的话,向你的高级或领导寻求指导,或者检查游戏中是否已经实现了类似的方法。

我首选的方法是从终点开始,逆向工作到源头。例如,如果你在处理 UI 逻辑,首先检查按钮的 onclick 动作来确定它调用了哪个函数。然后,导航到那个脚本以审查该函数。

然后,你可以检查逻辑并检查对其他脚本的额外调用。继续这个过程,直到你达到逻辑的核心。有时,一个函数可能涉及对其他多个脚本的调用,因此需要逐个审查它们,以理解它们是如何交互的。这个过程增强了你对项目的理解。如果逻辑涉及管理者,你将掌握他们的责任。因此,当你未来处理与这些管理者相关的任务时,你将更有能力有效地理解和连接逻辑。

如果你分配了一个新任务,并意识到如处理玩家或游戏数据的管理者缺少必要的函数,你只需简单地向该管理者添加一个新函数。这种方法允许你无缝地扩展管理者的功能,以满足你的需求。

当与第三方合作时,检查示例场景或脚本以了解其功能是有益的。通过实验和修改这些示例,你可以深入了解它们的用法,并将它们适应到自己的功能中,只关注项目所需的必要逻辑。

现在,让我们看看一个示例,我们将遵循它来了解我们如何理解现有的代码库。

示例:

我目前正在审查SettingsView脚本中的声音切换功能,该功能处理我在另一个项目中参与的项目中的声音效果的开/关:

  1. 我首先在层次结构中导航到SettingsView脚本,并定位到切换按钮。

  2. 然后,我检查值变化时触发的动作,并识别相关的函数或函数。此外,验证切换是否在视图脚本中引用,以及函数是否通过代码附加,也是非常重要的。如图所示,该函数可以在检查器中找到:

图 7.18 – SoundToggle 组件

图 7.18 – SoundToggle 组件

  1. 接下来,我们应该导航到 SettingsView 脚本来检查 OnSoundClicked 的逻辑,如图所示:

图 7.19 – SettingsView 脚本中的 OnSoundClicked 函数

图 7.19 – SettingsView 脚本中的 OnSoundClicked 函数

  1. SetSFXVolume 函数中,如图所示,你可以观察到其相关的逻辑:

图 7.20 – AudioManager 脚本中的 SetSFXVolume 函数

图 7.20 – AudioManager 脚本中的 SetSFXVolume 函数

  1. SetSFXVolume 函数中,我们在音频混音器中调整音效音量,使我们能够控制所有链接到该混音器的音频源,静音或取消静音它们的声音。此外,第二行管理声音状态数据,便于其持久化和本地或云端存储。

  2. 现在,我们将检查负责在 GameData 脚本中设置声音状态的函数,该脚本管理游戏数据,如图所示:

图 7.21 – GameData 脚本中的 SetSoundState 函数

图 7.21 – GameData 脚本中的 SetSoundState 函数

总结来说,我们获得的优势是游戏内存在一个专门用于音频控制的脚本,并配备了可用于未来音频管理相关任务的函数。此外,还有一个名为 GameData 的脚本,负责管理游戏数据。这使得我们可以在以后的数据相关需求中引用它,例如检索保存的数据或存储新的数据状态。

这个例子很容易理解,但步骤很全面。请随意将这些步骤应用到你的项目中,或者当你处理新项目时。

总结

在本章中,我们学习了使用 C# 在 Unity 中处理现有代码。我们探讨了如何使用版本控制系统、合并代码以及在项目协作中解决冲突。通过理解这些概念,我们可以与其他开发者更好地合作并保持代码质量。本章还涵盖了如何理解项目结构、审查文档和与团队成员有效沟通。通过花时间理解现有项目,我们可以更有效地做出贡献并做出更好的决策。

第八章 中,我们将探讨使用 C# 将外部资源和功能添加到 Unity 游戏中。我们将学习如何使用预制资源来改善游戏视觉效果,并添加新的功能,如分析和货币化。准备好在下一章中学习新的游戏增强方法!

第四部分:在 Unity 中使用 C# 进行高级集成和外部资产

在本部分,您将深入探讨如何使用 C# 在 Unity 项目中集成第三方资产、预构建组件和 API,以增强游戏视觉效果和用户参与度。您将学习如何有效地利用外部资源,为沉浸式游戏体验做出贡献。探索 Unity 的性能分析工具,以识别性能瓶颈并应用优化技术,以提升游戏性能和内存管理。使用 C# 发现提高生产力的快捷方式和高级工作流程,以简化游戏开发流程,解决挑战,并在 Unity 开发中取得成功。

本部分包含以下章节:

  • 第八章在 Unity 中使用 C# 实现外部资产、API 和预构建组件

  • 第九章使用 Unity 的性能分析器、帧调试器和内存分析器优化游戏

  • 第十章Unity 中的技巧和窍门

第九章:在 Unity 中使用 C#实现外部资产、API 和预构建组件

欢迎来到第八章,我们将深入探讨使用 C#进行游戏开发的关键方面。我们将从探索预构建资产的集成开始,这是增强游戏视觉和性能的基本技能。然后,我们将深入研究渲染管道的集成,这对于优化游戏视觉和实现更好的性能至关重要。之后,我们将讨论后端服务在游戏开发中的重要性,重点关注身份验证逻辑作为其重要性的主要例子。最后,我们将探索分析 API 及其在理解玩家行为和优化游戏性能中的关键作用。在本章中,我将演示如何将这些关键组件集成到我们的游戏中,让您能够创建沉浸式和引人入胜的游戏体验。

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

  • 利用 C#预构建资产

  • 利用 C#集成后端服务

  • 利用 C#集成分析 API

技术要求

本章的所有代码文件都可以在以下位置找到:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp/tree/main/Assets/Chapter%2008

利用 C#预构建资产

在游戏开发的广阔世界中,时间与屏幕上的像素一样宝贵。每一刻都至关重要,每一行代码都塑造着玩家将要探索的世界。这就是第三方资产发挥作用的地方——它们是提高游戏设计变化中创造力的有效工具。

想象一下:你是一位拥有光明前景的新游戏开发者。你梦想着广阔的景观、细致的角色动画和能把玩家带到遥远土地的声音。然而,从想法到现实的旅程充满了挑战,时间难以捉摸。

第三方资产,在游戏开发中常常被低估,但发挥着至关重要的作用。这些现成的资源构成了游戏创作的基石,将虚拟世界变为现实。从宏伟的景观到微妙的细节,第三方资产简化了复杂的资产创建任务,使我们能够专注于优化游戏玩法和提升玩家体验。

但为什么它们如此重要呢? 首先,第三方资产提供了由专家创建的大量资源。无论你需要令人惊叹的环境、逼真的角色还是令人毛骨悚然的音效,第三方资产的庞大阵容应有尽有。这些资产激发创造力,激发想象力,帮助我们快速将想法变为现实。

此外,好处远不止便利性。通过使用预构建资产,我们可以专注于游戏的核心——游戏玩法本身。摆脱了创建资产的重担,他们可以打造沉浸式的世界、引人入胜的故事和难以忘怀的体验,这些都能抓住玩家的心并随着时间的推移而持久。

在本节中,我们将探讨 Unity 包管理器中可用的预构建资源的利用,例如URP,它提供了高级渲染选项。让我们开始我们的探索之旅。

通用渲染管线(URP)

在本节中,我们将探讨通用渲染管线(URP),这是一个强大的工具,有助于在我们的 Unity 项目中创建令人惊叹的视觉效果并优化性能。

URP 是由 Unity Technologies 提供的渲染解决方案。它旨在在视觉质量和性能之间取得平衡,使其适用于包括移动、游戏机和 PC 在内的广泛平台和设备。

URP 提供了一个灵活且高效的渲染管线,允许创建视觉上吸引人的游戏,同时确保在不同硬件配置上保持流畅的性能。无论你是创建风格化的独立游戏还是现实主义的 AAA 游戏,URP 都提供了将你的愿景变为现实所需的工具和功能。

在 Unity 的渲染管线之间进行选择——URP 与 HDRP

Unity 提供了两种不同的渲染管线,URP 和高清渲染管线(HDRP),它们分别针对游戏开发中的不同需求和需求量身定制。让我们探讨这两者之间的关键差异以及为什么你可能会选择其中一个而不是另一个来为你的游戏服务。

在下面的表格中,我概述了 Unity 渲染管线的比较,突出了关键点,以帮助您确定最适合您项目的管线。

方面 URP HDRP
图形保真度 URP 旨在优化渲染性能,同时保持视觉质量和效率之间的平衡。它支持实时照明、阴影和后期处理效果等功能。 HDRP 旨在提供高质量的视觉效果和图形保真度,尤其是在 PC 和游戏机等高端平台上。它提供基于物理的渲染和体积照明等高级渲染功能。
平台兼容性 URP 针对跨平台开发进行了优化,使其适用于各种设备和平台,包括移动设备、游戏机和 PC。 HDRP 针对高端平台,可能需要更强大的硬件才能实现最佳性能。由于其更高的要求,它可能不适合移动设备或低端 PC。
艺术风格和视觉方向 URP 具有多功能性,可以适应广泛的风格和视觉方向,包括风格化、卡通化或现实风格的艺术。它允许在不同类型和主题中实现视觉上吸引人的结果。 HDRP 非常适合追求照片级图形和沉浸式视觉体验的项目。它提供高级渲染功能和高保真效果,增强了现实感和沉浸感。
开发时间和资源 URP 通过简化的工作流程和更简单的设置,在视觉质量和开发效率之间提供了平衡。它适用于资源有限或时间受限的开发者。HDRP 提供了高级功能和图形能力,但要充分发挥其潜力,可能需要额外的时间和资源。它需要仔细优化和调整以达到期望的性能。

总结来说,URP 和 HDRP 之间的选择取决于各种因素,包括你的项目目标平台、期望的图形保真度、艺术风格和可用的开发资源。如果你优先考虑性能、跨平台兼容性以及视觉质量和效率之间的平衡,URP 可能是更好的选择。另一方面,如果你的项目需要高端视觉、逼真度和高级图形效果,HDRP 可能是更好的选择,前提是你有必要的资源和硬件来支持它。

现在我们已经了解了 Unity 中两个不同渲染管道之间的对比,让我们继续安装 URP。

将 URP 安装到我们的项目中

重要提示

在升级到自定义渲染管道之前,备份你的项目是至关重要的。

我们已经为我们的项目选择了 URP 包。然而,如果它还没有在你的项目中配置,你可以简单地访问包管理器并选择通用 RP选项,正如你在图 8**.1中看到的那样:

图 8.1 – 安装 URP 包

图 8.1 – 安装 URP 包

当你点击安装时,Unity 将安装该包及其依赖项。

注意

如果你正在从内置管道迁移到 URP 或高清 RP,有一些特定的说明需要遵守。你需要调整所有材质以利用这些包提供的新着色器。然而,鉴于我们的游戏从一开始就使用这些包,导入的文件,如模型材质,已经配置为使用这些包提供的着色器。

由于迁移到内置管道不是我们的主要目标,所以不会深入探讨升级到 URP。你可以通过参考官方文档来探索其他可能性:

在下一个子节中,我们将发现 Unity 的 URP 的强大之处,这是一个提供优化渲染、自定义着色器和增强光照的多平台视觉项目包。

探索 Unity 中的 URP(通用渲染管线)- 特性和功能

Unity 中的 URP 包为开发者提供了一系列功能和功能,以增强项目的渲染能力。以下是 URP 包及其使用的一些关键方面:

  • 优化渲染管线:URP 提供了一个优化的渲染管线,旨在在各种平台和设备上平衡性能和视觉质量。它包括诸如延迟渲染和前向渲染路径等功能,使我们能够选择最适合项目需求渲染技术。

  • 轻量级渲染:URP 设计得轻量级,使其适合针对移动设备、低端硬件和性能敏感型应用程序的项目。它优化渲染过程以实现流畅的性能,同时保持视觉保真度。

  • 着色器图集成:URP 无缝集成 Unity 的着色器图工具,使我们能够在不编写代码的情况下创建自定义着色器和视觉效果。着色器图通过基于节点的界面使我们能够设计复杂材料、照明效果和后处理效果。

  • 自定义渲染功能:URP 通过可脚本化渲染管线SRP)扩展提供了对自定义渲染功能的支持。我们可以通过实现自定义渲染通道、后处理效果和着色器变体来扩展和自定义渲染管线。

  • 增强的照明系统:URP 包括一个灵活的照明系统,支持实时照明、阴影和反射。它提供诸如对象级和像素级照明、动态阴影和用于逼真照明效果的光探针等功能。

  • 后处理效果:URP 内置了对后处理效果的支持,使我们能够增强场景的视觉质量。它提供了一系列后处理效果,如光晕、景深、色彩分级和环境遮挡。

  • 跨平台兼容性:URP 旨在实现跨平台兼容性,使我们能够为各种平台创建游戏和应用程序,包括移动设备、游戏机和 PC。它优化了不同硬件配置和平台规范下的渲染性能。

总结来说,Unity 中的 URP 包为我们提供了一个轻量级、灵活且优化的渲染解决方案,用于创建视觉上令人惊叹且性能高效的项目。从照明和着色到后处理效果和自定义渲染功能,URP 使我们能够将创意愿景变为现实,同时确保在不同平台和设备上实现最佳性能。

接下来,我们将探讨更多高级主题,例如渲染回调和自定义渲染功能。

掌握 Unity 中的视觉修改 - 使用 URP 和 C#的高级技术

在 URP 中使用 C#引入高级技术可以极大地改变您 Unity 项目的视觉质量和性能。本节中我们可以探索和讨论的两个关键特性是自定义渲染通道和渲染管线回调:

  • 自定义 渲染通道

    • 自定义渲染通道允许您将自定义渲染逻辑注入到渲染管线中,使您能够实现超出 URP 内置功能的专用效果或优化。

    • 使用自定义渲染通道,您可以在渲染过程的各个阶段进行精细控制,例如在透明或非透明渲染之前或之后,或者在特定的渲染队列之间。

    • 您可以使用自定义渲染通道实现轮廓渲染、屏幕空间效果、自定义后期处理或优化,例如为自定义着色器或计算渲染额外的缓冲区。

  • 渲染 管线回调

    • 渲染管线回调提供了一种机制,可以在渲染管线中的特定事件和阶段执行自定义 C#代码。

    • 使用渲染管线回调,您可以执行诸如修改材质、动态调整渲染设置或在渲染过程中的特定点注入自定义渲染逻辑等任务。

    • 渲染管线回调可用于实现基于游戏事件动态修改材质、纹理或几何形状的生成,或根据运行时条件应用自定义着色器效果等高级功能。

接下来,让我们探索一个示例,演示如何在项目中应用这些高级技术。

实施高级技术的说明

下面是一个逐步指南,通过示例说明如何利用这些高级技术创建和控制轮廓效果:

  1. 在创建渲染器功能之前,请确保您的项目已配置为在项目设置中的质量和图形设置中使用 URP 渲染器数据。在项目选项卡中右键单击,然后导航到创建 | 渲染 | URP 渲染器功能以生成一个新的功能脚本。您可以将其命名为OutlineEffect

图 8.2 – 创建 URP 渲染器功能脚本

图 8.2 – 创建 URP 渲染器功能脚本

  1. 接下来,我们打开OutlineEffect脚本,并根据以下代码块中的说明进行修改:

    public class OutlineEffect : ScriptableRendererFeature
    {
        class OutlineRenderPass : ScriptableRenderPass
        {
            public List<Material> outlineMaterials;
            public OutlineRenderPass(List<Material> materials)
            {
                this.outlineMaterials = materials;
                renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
            }
            // This method is called before executing the render pass..
            public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
            {
            }
            // Here you can implement the rendering logic.
            // Use <c>ScriptableRenderContext</c> to issue drawing commands or execute command buffers
            // https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
            // You don't have to call ScriptableRenderContext.submit, the render pipeline will call it at specific points in the pipeline.
            public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
            {
                CommandBuffer cmd = CommandBufferPool.Get("OutlineRenderPass");
                // Set the render target to the camera's depth buffer
                cmd.SetRenderTarget(renderingData.cameraData.renderer.cameraDepthTargetHandle);
                // Clear the depth buffer to ensure the outline is rendered correctly
                cmd.ClearRenderTarget(false, true, Color.clear);
                var settings = new DrawingSettings(new ShaderTagId("UniversalForward"), new SortingSettings(renderingData.cameraData.camera));
                var filterSettings = new FilteringSettings(RenderQueueRange.opaque);
                context.DrawRenderers(renderingData.cullResults, ref settings, ref filterSettings);
                // Draw objects with outline materials
                // Draw objects with outline materials
                foreach (Material material in outlineMaterials)
                {
                    var drawSettings = new DrawingSettings(new ShaderTagId("Outline"), new SortingSettings(renderingData.cameraData.camera))
                    {
                        overrideMaterial = material
                    };
                    var filterSettingsOutline = new FilteringSettings(RenderQueueRange.opaque);
                    context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref filterSettingsOutline);
                }
                context.ExecuteCommandBuffer(cmd);
                CommandBufferPool.Release(cmd);
            }
            // Cleanup any allocated resources that were created during the execution of this render pass.
            public override void OnCameraCleanup(CommandBuffer cmd)
            {
            }
        }
        OutlineRenderPass outlinePass;
        public List<Material> outlineMaterials;
        /// <inheritdoc/>
        public override void Create()
        {
            outlinePass = new OutlineRenderPass(outlineMaterials);
            outlinePass.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
        }
        // Here you can inject one or multiple render passes in the renderer.
        // This method is called when setting up the renderer once per-camera.
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            renderer.EnqueuePass(outlinePass);
        }
    }
    

    让我们分解之前的代码,解释每个部分的作用:

    • OutlineEffect类定义了一个自定义渲染器功能,通过使用带有轮廓材质列表的自定义渲染通道(OutlineRenderPass)来为场景中的对象添加轮廓效果。OutlineRenderPass类实现了在渲染过程中应用轮廓效果到对象的渲染逻辑。

    • 接下来,你可以将此功能集成到你的 URP 数据可脚本对象中。你可以根据游戏需求自定义设置。在我们的演示中,我保留了默认设置,如图图 8**.3所示:

图 8.3 – 添加 OutlineEffect 功能

图 8.3 – 添加 OutlineEffect 功能

你可以包括额外的功能以实现你期望的视觉效果。此外,你可以通过以下链接参考 Unity 的官方文档以获取更多详细信息:https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@16.0/manual/index.html

在以下图中,你将观察到应用此功能前后的差异:

图 8.4 – OutlineEffect 功能效果

图 8.4 – OutlineEffect 功能效果

探索利用渲染通道功能优化渲染和增强视觉效果的重要影响和好处。

利用渲染通道功能的目的是什么?

使用 URP(Universal Render Pipeline)结合渲染通道可以为实现轮廓效果提供多项优势:

  • 模块化和可扩展性:通过使用渲染通道,你可以模块化你的渲染管道,并将特定的渲染任务分离成独立的通道。这使得你的渲染管道更加灵活且易于维护。你可以根据需要添加或删除通道,而不会影响管道的其他部分。

  • 性能优化:URP 中的渲染通道允许你控制渲染任务执行的顺序。这使你能够通过高效地批处理和排序对象、减少过度绘制和最小化不必要的渲染调用来优化性能。对于轮廓效果,你可以确保只有必要的对象使用轮廓材质进行渲染,从而减少计算开销。

  • 与 URP 渲染管道的集成:URP 提供了一个优化性能的简化渲染管道,适用于各种平台。通过使用渲染通道将你的自定义渲染效果(如轮廓效果)集成到 URP 的管道中,你确保了与 URP 的渲染功能和优化的兼容性和一致性。

  • 跨平台一致性:URP 旨在为不同平台和设备(包括桌面、移动和游戏机)提供一致的渲染结果。通过利用 URP 的功能,你可以确保你的轮廓效果在各种目标平台上表现可预测且性能最优。

  • 着色器图集成:URP 无缝集成 Unity 的 Shader Graph 工具,允许你通过视觉方式创建自定义着色器而无需编写代码。你可以使用 Shader Graph 来设计轮廓着色器,并轻松将其集成到渲染通道中,使实现过程更加易于访问和直观。

总体而言,虽然可以在不利用 URP 的渲染通道功能的情况下直接应用轮廓效果,但将其与 URP 集成在性能优化、模块化、一致性和与 Unity 渲染管线兼容性方面提供了优势。它为在 Unity 项目中实现自定义渲染效果提供了一个更强大和灵活的解决方案。

游戏开发得益于预构建资源和 C#脚本,节省了时间并提高了创造力。Unity 的 URP 优化了视觉效果和性能。了解 URP 与 HDRP 的区别有助于选择管线。URP 的设置涉及包安装和配置。URP 的功能包括轻量级渲染、Shader Graph 集成以及自定义渲染通道等高级技术。URP 中的渲染通道提供了模块化和性能优化。

在下一节中,我们将探讨后端服务的集成,了解其在游戏开发中的重要性以及其必要性的原因。此外,我将通过一个包含可用服务之一的示例来说明它们的用法。

将后端服务与 C#集成

在本节中,我们将了解如何通过将后端 API 与 C#集成,为我们提供一种强大的工具,以丰富我们的项目功能。通过使用这些 API,我们可以无缝地将他们的 Unity 项目链接到外部服务,从而实现用户身份验证、数据存储和排行榜等功能。

让我们探索后端服务,并了解它们在游戏中的重要性。

后端服务

后端服务指的是支持软件应用程序(包括游戏)从服务器端视角运行的一组功能性和基础设施组件。在游戏开发背景下,后端服务包括旨在增强游戏体验、管理玩家数据和促进在线互动的各种功能和能力。以下是游戏开发中后端服务的一些关键方面:

  • 数据存储:为游戏相关数据提供存储解决方案,包括玩家资料、游戏进度、成就、库存以及其他持久化游戏状态信息。这些数据通常存储在数据库或云存储系统中,确保在不同平台和设备上具有可靠性、可扩展性和可访问性。

  • 用户身份验证:提供身份验证机制以验证玩家的身份并确保对游戏功能和内容的安全访问。身份验证过程通常涉及用户注册、登录和会话管理,采用加密和安全的协议来保护用户凭据并防止未经授权的访问。

  • 多人功能:使游戏实现多人功能成为可能,允许玩家实时连接、交互和竞争。这包括匹配、大厅管理、游戏会话编排和网络环境中玩家动作的同步等功能。

  • 实时通信:促进游戏客户端和服务器之间的实时通信,支持游戏内聊天、消息、通知和实时更新等功能。这些通信渠道使玩家能够相互交流,接收重要的游戏更新,并参与协作或竞技游戏体验。

  • 分析和洞察:提供分析工具和能力,以跟踪玩家行为、监控游戏性能,并深入了解玩家的参与度、留存率和货币化模式。分析数据帮助我们做出明智的决策,优化游戏机制,并针对玩家偏好定制体验。

  • 实时运营和内容管理:支持实时运营和内容管理,使我们能够无缝部署更新、补丁和新内容到游戏中。这包括内容分发网络CDNs)、版本控制、A/B 测试和实时事件管理等功能,使游戏体验动态且不断进化。

总结来说,后端服务是现代游戏开发的基础,提供必要的基础设施和功能,以支持在线多人游戏、管理玩家数据、分析玩家行为,并在不同平台和设备上提供引人入胜和沉浸式的游戏体验。

后端服务提供商及其功能的介绍

几个后端服务提供商提供针对游戏开发者需求的全面解决方案,每个都有一套自己的特性和功能。以下是可用的服务,这些是在撰写本书时的常用选项:

  • Firebase:由谷歌开发,Firebase 提供一系列后端服务,包括实时数据库、身份验证、云存储和托管。它提供了与 Unity 的无缝集成,使我们寻求强大且可扩展的后端解决方案的理想选择。

  • PlayFab:PlayFab 提供专为游戏开发者设计的全面后端平台。其功能包括玩家身份验证、数据存储、游戏内分析、虚拟货币管理和实时运营工具。PlayFab 的灵活 API 和 SDK 支持与 Unity 的轻松集成,使我们能够构建引人入胜的多人体验和实时服务功能。

  • 亚马逊网络服务 (AWS):AWS 提供了包括数据库、身份验证、内容交付和数据分析在内的广泛基于云的服务。通过提供 Amazon DynamoDB、Amazon Cognito 和 Amazon GameLift 等服务,AWS 为寻求以灵活性和可靠性构建、部署和管理后端基础设施的游戏开发者提供了可扩展的解决方案。

我们将在本章中使用 PlayFab,并在下一节中相应地将其实现到我们的项目中。让我们开始集成 PlayFab。

集成 PlayFab

在本指南中,我将向您介绍实现 PlayFab 并在您的项目中设置它的过程。然后,我们将创建一个示例身份验证逻辑来演示其用法。

首先,您需要下载 PlayFab Unity 包。我为此使用了 PlayFab SDK 2.188,您可以从以下链接获取:github.com/PlayFab/UnitySDK/releases。下载后,按照以下步骤操作:

  1. 图 8.5中,如您所见,我们将 PlayFab SDK 提取到我们的项目中。只需点击导入即可启动导入文件和编译的过程。

图 8.5 – 导入 PlayFab SDK

图 8.5 – 导入 PlayFab SDK

  1. 等待 Unity 编译完成,然后导航到资产 | PlayFabSDK | 共享 | 公共 | 资源。从那里,您可以选择可脚本对象PlayFabSharedSettings以配置它以适应您的项目。在图 8.6中,您可以观察到我们可以配置的 PlayFab 设置:

图 8.6 – PlayFab 共享设置

图 8.6 – PlayFab 共享设置

  1. 对于请求类型,您可以选择Unity 网络请求或选择适合您项目的选项。然而,对于我们的项目,我将选择Unity 网络请求

  2. 我们将包括标题 ID值,您可以从您的 PlayFab 游戏项目仪表板中获取。如果您还没有项目,您可以创建一个并使用其密钥在您的项目中。图 8.7显示了您可以在哪里找到用于 PlayFab 设置的 ID。

图 8.7 – PlayFab 标题 ID

图 8.7 – PlayFab 标题 ID

  1. 接下来,访问项目以检索密钥;为此,点击设置图标并选择标题设置

图 8.8 – PlayFab 标题设置

图 8.8 – PlayFab 标题设置

  1. 前一步将打开设置选项卡,从这里,我们可以导航到密钥选项卡以检索密钥,如图 8.9 所示:

图 8.9 – 标题密钥

图 8.9 – 标题密钥

  1. 点击显示以显示密钥。然后您可以复制它并将其粘贴到我们游戏中的 PlayFab 设置中的开发者密钥字段。

就这样!现在你已经将 PlayFab SDK 集成到你的项目中了。在接下来的步骤中,我们将开始使用它,并通过示例来学习。虽然这个示例只是一个样本,但它将涵盖使用这个包的完整流程。这个包有无数的使用可能性,所以请自由探索它们的文档以获取更多信息:learn.microsoft.com/en-us/gaming/playfab/

开发一个示例登录系统

游戏中用户登录有多种方法,例如使用电子邮件、访客账户,以及对于移动平台,利用 Android 的 Play 服务和 iOS 的 Game Center。PlayFab 提供了各种选项,以方便根据游戏平台进行用户登录。

对于这个系统,我们可以通过以下步骤建立一个有组织的结构:

  1. 我们将首先为登录方法创建一个接口。在下面的代码块中,你将找到一个登录方法以及登录状态的回调:

    using PlayFab.ClientModels;
    using PlayFab;
    public interface ILogin
    {
        void Login(System.Action<LoginResult> onSuccess, System.Action<PlayFabError> onFailure);
    }
    
  2. 接下来,我们可以为每种登录方法创建一个类。对于访客登录,我们将使用设备 ID 进行身份验证。在下面的代码块中,我们已经实现了ILogin接口,并使用DeviceLogin类实现了使用设备 ID 进行登录的逻辑。

    using PlayFab.ClientModels;
    using PlayFab;
    public class DeviceLogin : ILogin
    {
        private string deviceId;
        public DeviceLogin(string deviceId)
        {
            this.deviceId = deviceId;
        }
        public void Login(System.Action<LoginResult> onSuccess, System.Action<PlayFabError> onFailure)
        {
            var request = new LoginWithCustomIDRequest
            {
                CustomId = deviceId,
                CreateAccount = true // Create account if not exists
            };
            PlayFabClientAPI.LoginWithCustomID(request, onSuccess, onFailure);
        }
    }
    

    你可以遵循这些步骤并创建额外的登录逻辑,特别是如果你针对的是移动平台,因为将会有针对移动设备的特定登录方法。

  3. 然后,我们可以创建一个LoginManager类,它将负责调用适当的登录方法,如下面的代码块所示:

    using PlayFab.ClientModels;
    using PlayFab;
    using UnityEngine;
    public class LoginManager
    {
        private ILogin loginMethod;
        public void SetLoginMethod(ILogin method)
        {
            loginMethod = method;
        }
        public void Login(System.Action<LoginResult> onSuccess, System.Action<PlayFabError> onFailure)
        {
            if (loginMethod != null)
            {
                loginMethod.Login(onSuccess , onFailure);
            }
            else
            {
                Debug.LogError("No login method set!");
            }
        }
    }
    
  4. 对于下一步,我们将实现一个PlayfabManager脚本,用于管理登录方法并处理用户输入,从而触发适当的操作:

    using PlayFab.ClientModels;
    using PlayFab;
    using UnityEngine;
    public class PlayFabManager
    {
        private LoginManager loginManager;
        private string savedEmailKey = "SavedEmail";
        private string userEmail;
        private void Start()
        {
            loginManager = new LoginManager();
            // Check if email is saved
            if (PlayerPrefs.HasKey(savedEmailKey))
            {
                string savedEmail = PlayerPrefs.GetString(savedEmailKey);
                // Auto-login with saved email
                EmailLoginButtonClicked(savedEmail, "SavedPassword");
            }
        }
        // Example method for triggering email login
        public void EmailLoginButtonClicked(string email, string password)
        {
            userEmail = email;
            loginManager.SetLoginMethod(new EmailLogin(email, password));
            loginManager.Login(OnLoginSuccess, OnLoginFailure);
        }
        // Example method for triggering device ID login
        public void DeviceIDLoginButtonClicked(string deviceID)
        {
            loginManager.SetLoginMethod(new DeviceLogin(deviceID));
            loginManager.Login(OnLoginSuccess, OnLoginFailure);
        }
        private void OnLoginSuccess(LoginResult result)
        {
            Debug.Log("Login successful!");
            // You can handle success here, such as loading player data
            // Save email for future auto-login
            if (!string.IsNullOrEmpty(userEmail))
                PlayerPrefs.SetString(savedEmailKey, userEmail);
            // Load player data
            LoadPlayerData(result.PlayFabId);
        }
        private void OnLoginFailure(PlayFabError error)
        {
            Debug.LogError("Login failed: " + error.ErrorMessage);
        }
        private void LoadPlayerData(string playFabId)
        {
            var request = new GetUserDataRequest
            {
                PlayFabId = playFabId
            };
            PlayFabClientAPI.GetUserData(request, OnDataSuccess, OnDataFailure);
        }
        private void OnDataSuccess(GetUserDataResult result)
        {
            // Process player data here
            Debug.Log("Player data loaded successfully");
        }
        private void OnDataFailure(PlayFabError error)
        {
            Debug.LogError("Failed to load player data: " + error.ErrorMessage);
        }
    }
    

    PlayFabManager中,你会发现使用电子邮件和设备 ID 进行登录的方法,以及表示登录状态的回调。此外,我还包括了一个处理成功用户登录的示例,通过加载他们的数据来实现。这使得我们可以根据 PlayFab 中存储的数据执行进一步的逻辑。

这个示例就结束了,它可能看起来很简单,但它涵盖了使用 PlayFab 的完整过程。这个包提供了广泛的功能,包括排行榜管理、远程设置配置、分析和匹配。如前所述,探索它们的文档以获取更多信息和建议:learn.microsoft.com/en-us/gaming/playfab/

总之,我们深入了解了后端服务对我们游戏的重要性。我们成功地将 PlayFab 集成到我们的项目中,并通过这个过程学习了如何开发登录系统。

在下一节中,我们将深入了解分析 API,它们在游戏中的好处以及它们在我们游戏开发努力中的必要性。

将分析 API 与 C#集成

分析 API 是软件接口,使我们能够将分析功能集成到我们的应用程序中,包括游戏。这些 API 允许我们收集、分析和解释与游戏内用户交互、行为和性能指标相关的数据。以下是分析 API 的一些关键方面:

  • 数据收集:分析 API 简化了收集游戏内用户交互产生的各种类型数据的流程。这包括诸如玩家行为、会话时长、游戏内购买、进度里程碑和用户人口统计信息等数据。

  • 事件跟踪:我们可以使用分析 API 跟踪游戏中的特定事件或行为,例如关卡完成、物品获取、任务成就和社交互动。通过定义和跟踪自定义事件,我们可以深入了解玩家如何与游戏的不同方面互动。

  • 性能监控:分析 API 提供监控和分析游戏性能的工具,包括与帧率、加载时间、网络延迟和设备规格相关的指标。这些数据帮助我们识别性能瓶颈,优化游戏性能,并确保玩家获得流畅的游戏体验。

  • 用户行为分析:分析 API 使我们能够分析用户行为模式和趋势,帮助他们了解玩家如何导航游戏,他们最常参与哪些功能,以及哪些因素影响他们的留存和参与度。这些信息有助于游戏设计决策,并帮助我们调整游戏体验以更好地满足玩家期望。

  • 留存和货币化分析:分析 API 使我们能够跟踪玩家留存率随时间的变化,并分析导致玩家流失的因素。此外,我们还可以分析货币化指标,如每用户收入、转化率和平均每付费用户收入ARPPU),以优化货币化策略并最大化收入机会。

  • 实时报告和洞察:分析 API 提供实时报告和可视化工具,使我们能够获取可操作的洞察并做出数据驱动的决策。交互式仪表板、图表和报告使我们能够监控关键绩效指标KPIs),跟踪目标进展,并识别改进的机会。

总结来说,分析 API 使我们能够深入了解玩家的行为、性能和货币化,使他们能够优化游戏设计,提升玩家体验,并推动商业成功。通过将分析功能集成到他们的游戏中,我们可以做出明智的决策,并持续改进游戏的质量和性能。

集成 GameAnalytics

GameAnalytics 是一个流行的 Unity 游戏开发包,它提供了对玩家行为和游戏性能的分析和洞察。它允许我们跟踪各种指标,例如玩家进度、留存率、游戏内事件和货币化数据,以优化他们的游戏并提高玩家参与度。

这里是 GameAnalytics Unity 包的关键功能和能力:

  • 事件跟踪:GameAnalytics 使我们能够在他们的游戏中跟踪自定义事件,例如关卡完成、物品购买、解锁成就和教程进度。这些数据帮助我们了解玩家如何与他们的游戏互动,并确定改进的领域。

  • 用户分析:该包提供了对用户行为和人口统计数据的洞察,包括活跃用户、会话长度、留存率和用户细分。我们可以分析这些数据,以根据特定玩家的偏好和人口统计数据调整他们的游戏。

  • 货币化跟踪:GameAnalytics 允许我们跟踪应用内购买、广告收入和其他货币化指标。通过分析收入数据与玩家行为的结合,我们可以优化他们的货币化策略并最大化收入生成。

  • 实时仪表板:该包提供实时仪表板和报告工具,可视化游戏分析数据,使我们能够轻松监控游戏性能并做出数据驱动的决策。我们可以自定义仪表板,专注于特定的指标和关键绩效指标(KPI)。

  • 与 Unity 集成:GameAnalytics 提供了一个 Unity SDK,它可以无缝集成到 Unity 项目中,使得分析事件和指标的实施和跟踪变得简单。SDK 支持 Unity 编辑器和运行时环境,使我们能够在整个开发周期中测试和分析他们的游戏。

  • 跨平台支持:GameAnalytics 支持多个平台,包括 PC、移动、游戏机和网页,使我们能够在各种设备和平台上跟踪分析数据。这种跨平台支持使我们能够深入了解不同环境中的玩家行为。

总体而言,GameAnalytics 是一个有价值的工具,可以帮助我们深入了解玩家行为,优化游戏性能,并通过数据驱动的决策最大化收入。其用户友好的界面、强大的功能集和跨平台支持使其成为全球游戏开发者中的热门选择。

在后续步骤中,我们将集成 GameAnalytics 并配置我们的初始事件:

  1. 我们将使用 GameAnalytics Unity SDK 的 7.8.0 版本,您可以通过以下链接下载:download.gameanalytics.com/unity/7.8.0/GA_SDK_UNITY.unitypackage

  2. 下载包后,通过点击导入将其实现到您的项目中,如图 图 8**.10 所示。

图 8.10 – 导入 GameAnalytics 包

图 8.10 – 导入 GameAnalytics 包

  1. 在等待 Unity 编译文件后,导航到窗口 | 游戏分析 | 选择设置,如图图 8.11所示。

图 8.11 – 打开游戏分析设置

图 8.11 – 打开游戏分析设置

  1. 按照前面的步骤操作,将显示游戏分析设置。然后,你可以使用你的账户登录或注册并创建一个项目(如果你还没有这样做),如图图 8.12所示。

图 8.12 – 游戏分析设置

图 8.12 – 游戏分析设置

  1. 在成功登录并拥有 GameAnalytics 仪表板中的项目后,你将找到链接你的项目并使用适当的平台实现它的选项,无论是 Android、iOS 还是 Windows。对于我们的游戏,我选择了 Windows,因为我们正在制作 PC 游戏。在图 8.13中,你可以看到与项目相关的信息,例如游戏和组织,以及重要的元素,游戏密钥和密钥,这些在成功登录后会自动添加。

图 8.13 – 将我们的游戏与游戏分析链接

图 8.13 – 将我们的游戏与游戏分析链接

然后,你需要将游戏分析游戏对象添加到我们的场景中。记住,它是一个持久化的游戏对象,所以不需要在所有场景中实现它。只需添加一个游戏对象。

  1. 你应该导航到窗口 | 游戏分析 | 创建游戏分析对象,游戏对象将被添加到你的场景中,如图图 8.14所示。

图 8.14 – 将游戏分析对象添加到我们的场景

图 8.14 – 将游戏分析对象添加到我们的场景

现在,我们需要开始在代码中实现事件,这取决于你需要跟踪的内容。让我提供一个例子来澄清。

游戏分析使用示例

你现在可以初始化游戏分析并开始在代码中使用事件,如下面的代码块所示:

    private void Start()
    {
        GameAnalytics.Initialize();
    }

我们可以在GameManager脚本中添加这个示例,或者如果你有一个处理游戏服务初始化的脚本。

你可以像以下代码块中所示那样使用它。别忘了包含GameAnalyticsSDK命名空间:

// Call this method when the player completes a level
 public void LevelCompleted(int levelNum)
 {
     // Track the event using GameAnalytics
     GameAnalytics.NewDesignEvent("LevelComplete", levelNum);
 }

并且有各种类型的事件可供更好的数据收集。有关更详细的信息,你可以参考游戏分析文档:docs.gameanalytics.com/event-types

你需要首先构建包含游戏分析游戏对象的场景,然后开始发送事件。结果可能需要几分钟才能在网站上显示。

你可以访问游戏分析网站上的你的游戏,然后导航到实时部分来查看数据,如图图 8.15所示。

图 8.15 – 我们游戏的实时数据

图 8.15 – 我们游戏的实时数据

现在我们已经将分析 API,特别是 GameAnalytics,整合到我们的项目中,我们已经成功整合并投入使用。

注意

如果你已经整合了任何包,最初不要在它的可能性上花费太多时间。简单地进行整合,检查错误,创建一个示例,然后逐步探索其全部潜力。一开始不要无谓地使事情复杂化。

将第三方资产整合是一项对任何开发者都非常有价值的技能。许多成功的游戏都依赖于外部资源来节省时间并确保可能难以独立实现的顶级质量。

摘要

在本章中,我们深入探讨了使用 Unity 中的 C#整合预构建资源和 API,这是游戏开发的一个关键方面。我们首先探讨了利用预构建资源来增强游戏视觉效果和优化性能的方法,接着整合了渲染管线以实现更好的视觉效果。讨论随后转向了后端服务的重要性,以身份验证逻辑为例,强调了它们在游戏开发中的实用性。此外,我们还探讨了分析 API 及其在理解玩家行为和优化游戏性能中的作用,展示了如何有效地将它们整合到我们的游戏项目中。

展望第九章,我们将专注于使用 Unity 的剖析工具优化游戏性能。你将学习如何利用 Profiler、帧调试器和内存剖析器等工具来识别和解决性能瓶颈,优化渲染和管理内存使用。通过实际练习和实用见解,你将掌握优化技术,以确保你的游戏运行顺畅且高效。准备好在下一章提升你游戏的表现力吧!

第十章:使用 Unity 的 Profiler、帧调试器和内存分析器优化游戏

欢迎来到您 Unity 游戏开发之旅的第九章,我们将探讨如何使用 Unity 的 Profiler、帧调试器和内存分析器来优化游戏性能。在本章中,我们将学习如何识别和解决性能瓶颈,优化渲染,并高效管理内存。我们将介绍 Unity 的性能分析工具,深入探讨如物理、音频、人工智能和脚本优化的性能优化技术,并深入研究内存管理和优化,包括内存分析器使用和资产导入优化。掌握这些技能将确保流畅的游戏体验和沉浸式的玩家体验。

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

  • 介绍 Unity 性能分析工具

  • 性能优化技术

  • 内存管理和优化

技术要求

您需要安装以下内容才能跟随本章内容:

  • 主要集成开发环境 – Visual Studio 2022:教程和代码示例是使用 Visual Studio 2022 制作的。请确保已安装,以便您可以无缝地跟随。如果您更喜欢 Rider 或其他 IDE,请随意探索,尽管说明是为 Visual Studio 定制的。

  • Unity 版本 2022.3.13:下载并安装 Unity,选择版本 2022.3.13 以获得与提供内容最佳兼容性。

本章的代码文件可在以下位置找到:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp/tree/main/Assets/Chapter%2009

介绍 Unity 性能分析工具

欢迎使用 Unity 的性能分析工具!这些工具对于理解和提升游戏性能至关重要。它们为我们提供了深入了解游戏运行情况的有价值见解,并帮助我们优化游戏,以实现跨不同设备和平台的流畅体验。

那么,为什么我们需要利用性能分析工具呢?性能分析工具是优化过程中的宝贵资产,优化过程是将我们的游戏调整到尽可能高效运行的过程。这些工具在游戏开发的世界中充当我们的侦探伙伴,帮助我们调查和识别游戏可能减慢或使用过多内存的领域。通过使用这些工具,我们可以有针对性地改进游戏性能,确保玩家拥有无缝且愉快的游戏体验。

那么,在游戏开发中,优化意味着什么?优化是一个使我们的游戏尽可能高效运行的过程,包括寻找减少不必要的计算、最小化内存使用和提高渲染性能的方法。就像一个组织良好的城市确保交通流畅和资源管理高效一样,优化确保我们的游戏代码和图形协同工作,为玩家提供引人入胜的体验。这种优化至关重要,因为它直接影响玩家的体验。没有人想玩一个卡顿、停滞或意外崩溃的游戏。通过优化我们的游戏,我们可以确保它们在各种硬件配置上运行顺畅,为玩家提供跨平台的持续和愉快的游戏体验。

深入探索 Unity 的性能分析工具

Unity 的性能分析工具提供了一套全面的特性,帮助我们理解和改进游戏性能。Profiler 允许我们实时分析 CPU 和 GPU 使用情况,为我们提供宝贵的优化见解。另一方面,Frame Debugger 帮助我们可视化游戏图形的渲染过程。

在接下来的章节中,我们将逐一深入探讨这些性能分析工具,并学习如何有效地使用它们来优化我们的游戏。那么,让我们准备好深入 Unity 性能分析的世界吧!

Profiler

Unity 中的 Profiler 就像是你游戏的贴心侦探。它会检查游戏的表现——例如,计算机思考了多少(CPU),图形表现如何(GPU),以及使用了多少内存。它就像一个捕捉问题并使你的游戏运行得更好的工具。

要打开 Profiler,请转到窗口 | 分析 | Profiler。会出现一个新窗口,如图图 9.1所示:

图 9.1 – Profiler

图 9.1 – Profiler

现在我们已经打开了 Profiler,让我们深入了解它是如何工作的。

理解 Unity 的 Profiler 功能

Unity 中的 Profiler 通过在游戏运行时持续监控游戏性能的各个方面来工作。它收集有关 CPU 使用率、GPU 使用率、内存分配、渲染性能等方面的数据,使我们能够深入了解游戏如何利用系统资源。

当你在 Unity 中打开 Profiler 时,它会显示各种图表和图形,实时可视化这些数据。这些图表显示了诸如 CPU 使用率随时间变化、游戏不同组件的内存使用量以及渲染每一帧所需的时间等指标。

Profiler 通过在你的游戏代码中添加性能标记来收集这些数据。这些标记跟踪特定函数和操作执行所需的时间,使我们能够识别性能瓶颈和优化区域。

例如,如果分析器在游戏过程中显示 CPU 使用量激增,我们可以使用分析器的调用栈视图来定位消耗最多 CPU 时间的函数。然后它可以分析这些函数内的代码,以识别低效或需要优化的区域。

类似地,如果分析器检测到过度的内存使用,我们可以使用内存分配视图来识别代码中内存分配和释放的位置。这有助于识别内存泄漏或可能影响性能的低效内存使用模式。

总体而言,Unity 中的分析器为我们提供了关于游戏性能的宝贵见解,使我们能够识别和解决可能影响玩家体验的问题。通过有效地使用分析器,我们可以优化游戏,使其在各种平台和设备上运行顺畅且高效。

在 Unity 中,优化游戏的性能对于提供流畅和沉浸式的玩家体验至关重要。Unity 分析器是一个强大的工具,通过其多样化的模块,为游戏性能的各个方面提供见解,每个模块都专注于不同的分析区域。

Unity 分析器的主要模块如下:

  • CPU 分析器:此模块监控游戏运行时的 CPU 使用情况。它有助于识别与脚本、物理计算、渲染和其他 CPU 密集型任务相关的性能瓶颈。

  • GPU 分析器:GPU 分析器专注于监控游戏的 GPU 使用情况。它提供了有关渲染性能的信息,包括绘制调用、着色器和与图形相关的计算所花费的时间。

  • 内存分析器:此模块跟踪游戏中的内存使用情况,包括分配、释放和内存泄漏。它有助于识别内存使用效率低下或资源管理不当的区域。

  • 音频分析器:音频分析器监控游戏中的音频相关操作性能,如播放音频剪辑、混合音频通道和处理音频效果。它有助于优化音频性能并解决与音频播放相关的问题。

  • 物理分析器:此模块专注于分析游戏中的物理计算性能。它提供了关于物理模拟、碰撞、刚体动力学和其他与物理相关的计算所花费时间的见解。

  • UI 分析器:UI 分析器专门设计用于分析游戏中的用户界面(UI)元素性能。它有助于识别与 UI 相关的瓶颈,例如布局计算、渲染开销和事件处理。

  • 网络分析器:网络分析器监控游戏中的网络活动,包括数据传输、延迟和网络相关事件。它有助于优化网络性能并解决与多人联网或在线游戏相关的问题。

  • 渲染分析器:此模块专注于分析游戏中的渲染性能。它提供了关于渲染开销、绘制调用、批处理以及其他与图形相关的优化的见解。

这些模块共同为您提供了游戏性能的全面视图,使您能够有效地识别和解决性能问题。通过使用 Unity 分析器的各种模块,我们可以优化游戏以获得更好的性能、更流畅的游戏体验和增强的玩家体验。

在本章中,我们将重点关注 CPU 分析器,并学习如何使用它来识别与 CPU 相关的性能问题。

CPU 分析器模块

CPU 分析器模块是 Unity 中分析游戏性能的必备工具。它提供了游戏在运行时花费时间的详细分解,包括渲染、脚本和动画等领域。本节深入探讨了 CPU 分析器模块的各个方面,包括图表类别、模块详细信息面板、实时设置等。

图表类别

CPU 使用率分析器模块的图表将游戏主线程上花费的时间分为九个类别。这些类别是渲染脚本物理动画垃圾回收器垂直同步全局照明用户界面其他。通过了解这些类别的时间分布,我们可以确定改进的区域并相应地优化游戏性能。

通过理解每个部分及其作用或影响,我们可以轻松地确定脚本或动画中的问题区域。这使我们能够将工作重点放在解决这些具体问题上。您可以参考图 9.2中的图表及其定义的颜色:

图 9.2 – CPU 模块

图 9.2 – CPU 模块

在以下表格中,我列出了每个类别,以及可能在该类别中发生的常见活动,指出了所花费的时间或对性能的影响:

类别 实时执行的活动
渲染 为 GPU 处理数据并等待 GPU 操作。这包括渲染网格、处理着色器、管理纹理以及其他与图形相关的计算任务。
脚本 MonoBehaviour更新方法和协程执行。这涉及执行脚本代码、处理游戏逻辑以及管理游戏对象之间的交互。
物理模拟 执行物理模拟和相关过程,包括碰撞检测、刚体交互、关节处理以及其他与物理相关的计算。
动画 动画系统处理和性能考虑,包括处理关键帧、混合树、动画状态转换以及其他与动画相关的任务。
垃圾回收器 垃圾回收和内存分配活动,包括内存分配、释放未使用的内存、管理对象生命周期以及优化内存使用。
VSync 等待垂直同步活动。这包括将游戏帧率与显示刷新率同步,确保平滑且无撕裂的渲染。
全局照明 全局照明包括与场景中的照明相关的计算,例如光照贴图烘焙、实时 GI 计算、光照探针和反射探针。
UI UI 活动涉及渲染和交互元素,例如画布、文本元素、按钮、面板以及其他 UI 组件。
其他 这些是其他类别之外的附加 CPU 活动。这可能包括各种引擎任务、与编辑器相关的活动、音频处理、网络任务以及其他在运行时发生但不符合定义的类别的不规则计算。

理解每个类别内的活动使我们能够根据项目的优化需求针对特定区域。

模块详细信息面板

您可以选择以三种不同的视图显示所选帧,让您能够了解如何在这之间切换,如图 图 9**.3 所示:

图 9.3 – 视图下拉菜单

图 9.3 – 视图下拉菜单

让我们了解这些视图是如何工作的:

  • 时间线:此视图提供了在不同线程上的单个时间轴上时间分布的概述,有助于可视化并行执行

  • 层次结构:此视图按内部层次结构分组时间数据,提供了关于函数调用和内存分配的详细见解

  • 原始层次结构:这与层次结构视图类似,但提供了关于性能警告和线程分组的额外详细信息

现在我们已经了解了分析器中可用的视图,我们可以切换它们以更好地了解帧的工作方式。

在分析时使用实时设置

Unity CPU 分析器中的“实时”设置提供了我们在与游戏交互时的性能指标动态和即时视图,使我们能够进行高效的即时分析和优化。

让我们启用实时设置并看看它是如何工作的:

  • 在开始录制之前,在 CPU 分析器模块中启用实时设置。

  • 当我们与游戏交互并触发不同事件时,分析器立即在详细信息面板中显示关于当前帧的实时信息。

  • 我们可以立即看到每个动作如何影响 CPU 使用率、内存分配以及其他性能指标。

  • 这种实时反馈使我们能够快速识别性能瓶颈,即时进行调整,并立即看到优化的影响。

我们还可以启用显示完整脚本方法名称选项来显示完整的方法名称。这在处理复杂脚本或需要精确了解正在执行的功能时特别有用。您可以通过设置启用此选项,如图 图 9**.4 所示:

图 9.4 – 显示完整的脚本方法名称

图 9.4 – 显示完整的脚本方法名称

在深入实际部分之前,理解常见的 Profiler 标记是至关重要的。

常见标记

Unity 的代码被各种 Profiler 标记所增强,提供了对性能关键任务和优化区域的洞察。通过利用这些标记,我们可以识别瓶颈并简化性能关键操作,从而提高我们游戏的整体效率和响应速度。

Unity 的代码带有许多 Profiler 指示器,这些指示器提供了对您游戏性能的洞察。这些标记对于识别瓶颈和优化您的代码非常有价值。让我们深入了解 Profiler 标记的主要类别及其功能:

  • 主线程 基本标记

    主线程基本标记用于区分在您的游戏上花费的时间和在编辑器和 Profiler 任务上花费的时间。这些标记对于理解主线程上的帧时间至关重要。以下是一些示例:

    • PlayerLoop:包含来自您游戏主循环的样本。当在玩家活动时针对编辑器,PlayerLoop 样本在 EditorLoop 之下。

    • EditorLoop(仅编辑器标记):包含在编辑器中分析玩家时的编辑器主循环的样本。EditorLoop 样本表示在玩家和编辑器同时运行时渲染和执行编辑器所花费的时间。

    • Profiler.CollectEditorStats(仅编辑器标记):包括与收集活动 Profiler 模块统计信息相关的样本。这些样本提供了关于玩家在收集模块统计信息时产生的开销的洞察。

  • 仅编辑器标记

    某些标记仅在 Unity 编辑器中进行分析时才会出现,提供了对编辑器特定活动(如安全检查和预制件相关任务)的洞察。以下是一个示例:

    • GetComponentNullErrorWrapper:一个仅适用于 Unity 编辑器的标记,有助于识别空组件的使用
  • MonoBehaviour 更新方法和协程执行。以下是一个示例:

    • BehaviourUpdate:包含所有 MonoBehaviour.Update 方法的样本
  • 渲染和 VSync 标记

    这些标记揭示了与处理 GPU 数据和等待 GPU 操作完成相关的 CPU 活动。以下是一个示例:

    • WaitForTargetFPS:表示等待由 Application.targetFrameRate 指定的目标帧率的所花费的时间
  • 后端 脚本标记

    这些标记突出了脚本后端活动,有助于解决与垃圾收集和内存分配相关的问题。以下是一个示例:

    • GC.Alloc:表示在托管堆中的分配,受自动垃圾收集的影响
  • 多线程标记

    这些标记专注于线程同步和作业系统,提供了有关并行处理和同步点的信息。以下是一个示例:

    • 空闲:包含表示 Worker 线程保持不活跃时间的样本
  • 物理标记符

    物理标记符提供了对物理模拟及其相关过程(如碰撞检测和关节处理)执行的洞察。以下是一个示例:

    • Physics.FetchResults:包含收集物理引擎中物理模拟结果的样本
  • 动画标记

    这些标记符与动画系统相关,提供了关于动画处理阶段和性能考虑的详细信息。以下是一个示例:

    • Director.PrepareFrame:安排并等待Director.PrepareFrameJob作业,评估活动 Animator 组件的状态机
  • 性能警告

    CPU 剖析器识别常见的性能问题,并向开发者显示警告,帮助他们有效地优化代码。以下是一个示例:

    • Animation.DestroyAnimationClip:指示与销毁AnimationClips相关的调用存在性能问题,触发资源密集型操作

通过对这些剖析标记符的深入了解,我们可以定位性能瓶颈,并优化我们的 Unity 游戏以增强效率和响应性。

你可以在官方 Unity 文档中了解更多关于常见标记符的信息:docs.unity3d.com/Manual/profiler-markers.html

通过熟悉常见的标记符,我们发现它们在我们的优化之旅中证明是无价之宝。它们使我们能够高效地专注于特定区域,确保我们的努力既有效又精确。

理解剖析过程

让我们讨论剖析过程的一般技巧,包括如何识别瓶颈并更好地理解整个过程。

剖析在以下三个特定时间使用时最为有效:

  • 在实施重大更改之前,通过剖析建立基线

  • 在开发过程中跟踪更改,并确保它们不会对性能产生负面影响或超出资源预算

  • 开发完成后,再次进行剖析以确认所做的更改已实现预期的性能改进

在确定你的游戏中存在问题时再进行剖析。此外,避免过度剖析;确定你游戏所需的帧率。每一帧都应该遵循与你的目标每秒帧数FPS)相匹配的时间预算。例如,目标为 30 FPS 的游戏每帧应消耗少于 33.33 毫秒(1,000 毫秒除以 30 FPS)。同样,目标为 60 FPS 允许每帧 16.66 毫秒。

注意

要实现最精确的剖析结果,需要在目标设备上直接运行和剖析构建。表单顶部

识别瓶颈

你应该确定你的游戏是 CPU 绑定还是 GPU 绑定,这样你就可以正确地集中优化努力。例如,请注意,虽然 VSync 在所有平台上都是可选的,但在移动设备上通常启用,可能会对 CPU 时间等待做出贡献。

VSync,即垂直同步,是一种图形技术,它将游戏的帧率与显示器或显示设备的刷新率同步。这种同步防止了屏幕撕裂等问题,即不同帧的部分同时出现在屏幕上,导致视觉上的冲击。VSync 确保在渲染下一帧之前,每个帧都完整显示,为玩家创造更平滑、更视觉上愉悦的体验。

一个项目的性能由需要最多时间处理的芯片或线程决定。这是优化努力应该集中的地方。例如,考虑一个目标帧时间预算为 16.66 ms 且启用了 VSync 的游戏:

  • 如果 CPU 帧时间(不包括 VSync)为 10 ms,而 GPU 时间为 12 ms,那么没有问题,因为两者都在预算范围内。

  • 如果 CPU 帧时间为 20 ms,而 GPU 时间为 12 ms,则需要优化 CPU 性能,因为 GPU 不会从优化中受益。考虑将一些 CPU 任务转移到 GPU 上。

  • 如果 CPU 帧时间为 8 ms,而 GPU 时间为 20 ms,则应专注于优化 GPU 工作负载,因为它受 GPU 绑定。

  • 如果 CPU 和 GPU 的时间都为 20 ms,那么你将受到两者的限制,需要将它们优化到 16.66 ms 以下才能达到 60 FPS 的帧率。

在接下来的小节中,我们将了解更多关于 CPU 和 GPU 绑定问题的内容。

CPU 绑定问题

当 CPU 时间超过分配的时间预算时,被认为是 CPU 绑定问题。让我们通过一个例子来说明如何使用 Profiler 来识别和解决此类问题。利用 Profiler 中的时间轴层次结构视图可以帮助我们更清楚地了解具体问题。请参阅图 9**.5以获取关于峰值帧的详细信息:

图 9.5 – 峰值帧

图 9.5 – 峰值帧

gfx.waitForCommandsFromMainThread标记表示主线程上可能存在的瓶颈,这会影响整体性能。这发生在渲染线程等待来自主线程的命令时,表明正在 CPU 上处理的任务或命令需要更长的时间。因此,游戏出现 CPU 绑定问题,导致渲染延迟。解决这些瓶颈和优化 CPU 绑定问题可以显著提高游戏性能和响应速度。

通过分析哪个线程最活跃来识别 CPU 瓶颈。性能分析有助于准确定位瓶颈,以便进行针对性的优化。猜测可能导致无效的优化甚至降低性能。

识别性能问题的主要线程通常包括以下内容:

  • 主线程:此线程处理游戏逻辑和脚本执行,包括与物理、动画、UI 和渲染相关的任务。它占用了相当一部分的处理时间。

  • 渲染线程:此线程负责在渲染过程中处理场景元素,例如相机剔除、深度排序和绘制调用批处理。它将 Unity 的场景表示转换为特定图形 API 调用以进行 GPU 渲染。

  • 工作线程:这些线程利用 C#作业系统将特定任务卸载到单独的工作线程上,从而减少主线程的工作负载。Unity 的各种系统,如物理、动画和渲染,也利用作业系统来提高性能。

您需要识别代码中任何出现峰值的地方,确定导致高 CPU 使用率或处理时间延长的原因,并调查它是否对应于 Profiler 中的常见标记。理解这些标记的含义有助于您有效地解决问题。根据您的发现优化代码,在应用修复后再次监控 Profiler,并继续此迭代过程,直到达到目标帧率。

GPU 受限问题

如果您的游戏在 Profiler 标记如Gfx WaitForPresentOnGfxThread(表示渲染线程的空闲时间)上出现长时间活动,同时显示如Gfx PresentFrame<GraphicsAPIName> WaitForLastPresent等标记,这表明是一个 GPU 受限的场景。在这种情况下,GPU 受限的特征是 GPU 利用率高,以及帧渲染和展示可能出现的延迟。

如果您的游戏看起来大量使用 GPU,您可以使用帧调试器快速检查发送到 GPU 的绘制调用批次。我将在下一节中更详细地讨论此工具。然而,重要的是要注意,尽管帧调试器可以提供对场景构建的见解,但它不提供具体的 GPU 时间细节。因此,您可以在 Profiler 和帧调试器之间切换,以修复任何与 GPU 相关的问题。

让我们探讨可能导致我们项目中 GPU 性能问题的因素。以下是一些常见问题:

  • 具有大量粒子或复杂行为的复杂粒子系统可能会影响 GPU 性能

  • 实时反射或折射,尤其是在有许多反射表面的场景中,可能会对 GPU 造成大量负载

  • 着色器排列或针对不同材质或效果的着色器变体可能会增加 GPU 的工作负载,尤其是如果它们没有被高效管理的话

  • 动态天气或环境效果,如雨、雾或动态天空,如果未进行优化,可能会增加 GPU 开销

  • 动态遮挡剔除和可见性计算可能会影响 GPU 性能,尤其是在具有复杂几何形状或许多移动对象的场景中

  • 高屏幕分辨率,尤其是 4K 显示器或移动设备上的视网膜显示器,可能会对 GPU 造成沉重的负载

在使用 Profiler 时,以下是一些有用的提示:

  • CPU 使用率Profiler 模块中关闭VSync其他类别。VSync标记表示 CPU 主线程的不活跃期,隐藏这些标记可以增强你的性能分析分析的清晰度。

  • 在你的项目构建中禁用VSync,以清晰地了解主线程、渲染线程和 GPU 之间的交互。禁用VSync的性能分析构建可以简化 Profiler 数据的解释。

  • 注意在播放模式或编辑模式中何时进行性能分析。使用播放模式进行游戏性能分析,使用编辑模式监控 Unity 编辑器进程。对编辑器进行性能分析有助于识别性能瓶颈并提高生产力。

  • 当你需要快速迭代解决性能问题时,选择在编辑器中进行性能分析。在识别问题后,使用播放模式性能分析来高效迭代更改并验证解决方案。

利用帧调试器的强大功能,优化 Unity 中的图形性能和识别渲染瓶颈变得流畅且高效。我们将在下一节中深入了解这一点。

帧调试器

帧调试器是一个强大的工具,用于分析和调试游戏中帧的渲染过程。它允许你检查渲染过程中涉及的每个步骤,如绘制调用、批量处理、纹理和材质。这个工具对于优化图形性能和识别渲染瓶颈至关重要。

你可以从窗口 | 分析 | 调试器菜单打开帧调试器工具:

图 9.6 – 帧调试器

图 9.6 – 帧调试器

现在我们已经学会了如何打开帧调试器,让我们探索它是如何工作的以及它做了什么。

理解帧调试器的工作原理

帧调试器通过拦截和分析发送到图形 API(例如 DirectX 或 OpenGL)的渲染命令来工作。它捕获有关每个绘制调用的信息,包括涉及的着色器、纹理、材质和网格。然后,这些捕获的数据以可视化界面呈现,使开发者能够检查和理解帧的渲染管道。

帧调试器实时运行,这意味着你可以在播放模式下暂停游戏,分析当前帧的渲染,并即时进行优化。

探索帧调试器的关键功能

帧调试器的主要功能如下:

  • 捕获并显示在渲染过程中进行的每个绘制调用

  • 显示如何将对象批量处理以优化渲染性能

  • 提供有关在渲染过程中使用的着色器、纹理、材质和网格的详细信息

  • 识别渲染目标和离屏渲染纹理

让我们探索帧调试器。这是帧调试器窗口的外观:

图 9.7 – 帧调试器窗口已启用

图 9.7 – 帧调试器窗口已启用

启用帧调试器后,游戏将暂停,你将能够查看该帧的所有图形相关细节。这包括从初始黑屏到当前场景的每个绘制调用。在 Unity 2022 中,这是我们使用的版本,帧调试器具有一个 输出/网格 部分带有两个标签:一个显示图形/场景的完整输出或当前状态,另一个显示绘制的网格,例如本例中的棕榈树示例。你可以在 图 9.8 中看到这个网格:

图 9.8 – 网格预览

图 9.8 – 网格预览

每个绘制调用的详细信息将在一个包含重要信息(如 渲染目标顶点索引使用的着色器)的单独部分中展示:

图 9.9 – 详细信息部分

图 9.9 – 详细信息部分

此外,帧调试器还包括用于使用的 纹理向量浮点数和其他部分的区域,如图 图 9.10 所示:

图 9.10 – 帧调试器中的其他部分

图 9.10 – 帧调试器中的其他部分

一旦你确定了帧的内容,优化过程就取决于采用针对每个游戏独特方面的特定策略和技术。解决问题需要彻底研究以确定优化区域,这个过程通常涉及迭代步骤以达到最佳性能。减少绘制调用的最重要方法之一是使用批处理。让我们更详细地看看。

绘制调用批处理

绘制调用批处理是一种通过合并多边形来优化绘制调用的技术,允许 Unity 在更少的绘制调用中渲染它们。Unity 提供了两种默认的绘制调用批处理方法:

  • 静态批处理

    在此过程中,Unity 将静态游戏对象合并并一起渲染。

    Unity 中的静态批处理是指通过在构建时间或运行时合并网格来优化绘制调用的过程。当使用静态批处理时,确保某些标准得到满足对于游戏对象有资格进行静态批处理至关重要:

    • 游戏对象必须处于活动状态

    • 它应该有一个启用的 网格过滤器 组件,并引用一个顶点数大于 0 的网格

    • 游戏对象还应有一个启用的 网格渲染器 组件,并使用一个没有禁用批处理的着色器的材质

    • 要一起批处理的多边形必须共享相同的顶点属性

    当使用静态批处理时,Unity 允许整个批处理的多边形作为一个整体进行变换,例如移动、旋转或缩放它们。然而,不能对批处理内的单个多边形应用变换。

    值得注意的是,为了有效地使用运行时静态批处理,必须启用对网格的读写访问。总的来说,静态批处理是优化绘制调用并提高 Unity 项目中性能的有用技术。

  • 动态批处理:

    动态批处理是一个过程,其中 Unity 通过在 CPU 上变换小网格的顶点并将相似顶点分组,最终在一个绘制调用中渲染它们。

    要在 Unity 中启用网格的动态批处理,请按照以下步骤操作:

    1. 导航到编辑 | 项目设置 | 玩家

    2. 其他设置部分,激活动态批处理选项。

    如果它们满足指定的标准,Unity 将自动将移动网格分组到一个单独的绘制调用中。

    Unity 中的动态批处理不适用于在Transform组件中具有镜像变换的 GameObject。例如,如果一个 GameObject 的缩放为 1,而另一个为-1,Unity 无法将它们一起批处理。

通常,Unity 会结合使用相同材质的 GameObject 的绘制调用,因此通过在多个 GameObject 之间共享材质来最大化批处理效率至关重要。如果你有两个几乎相同的材质资产,除了它们的纹理外,考虑将纹理合并到同一个图集中,从而创建一个更大的纹理。这允许你使用一个材质资产而不是两个。从 C#脚本访问共享材质属性时,请确保使用Renderer.sharedMaterial而不是Renderer.material。使用Renderer.material会创建材质的副本,从而阻止 Unity 为该渲染器批处理绘制调用。

以下是一些你可以利用的额外方法来减少绘制调用批次的数量:

  • 使用遮挡剔除来消除隐藏在前景元素后面的对象并最小化过度绘制。请注意,这可能会增加 CPU 处理,因此请使用 Profiler 评估将工作负载从 GPU 转移到 CPU 的影响。

  • 使用 GPU 实例化来减少批次,特别是对于共享相同网格和材质的多个对象。限制场景中的模型数量可以提高性能,并且通过谨慎的实现,你可以创建一个复杂而不会重复的场景。

  • 利用 SRP 批处理器通过分组绑定绘制GPU 命令来减少绘制调用之间的 GPU 设置。为了最大化 SRP 批处理的优势,使用多个材质,但将它们限制在几个兼容的着色器变体上,例如通用渲染管线URP)和高清渲染管线HDRP)中的LitUnlit着色器,最小化关键字组合之间的差异。

利用这些技术可以显著提高渲染性能并简化 Unity 游戏开发过程。

现在,让我们探讨任何游戏中各种类别的优化技术,以提高性能。

性能优化技术

在本节中,我们将深入研究 Unity 中性能优化技术的关键方面。性能优化在确保游戏运行顺畅、高效利用系统资源并向玩家提供无缝体验方面发挥着关键作用。通过实施优化技术、分析性能数据并采用高效的脚本实践,开发者可以显著提高他们游戏的表现力和整体质量。让我们详细探讨这些技能,以了解它们如何有助于在 Unity 中创建高性能游戏。

下面的子节涵盖了优化技术的关键领域。

物理和碰撞

为了提高 Unity 中物理和碰撞的性能和效率,战略性的优化技术起着至关重要的作用。在这里,我们将探讨两种这样的技术,并详细说明它们各自的问题、解决方案、示例和结果:

  • 碰撞层掩码:

    • 问题:存在不必要的碰撞检查,这些对象之间没有交互,导致计算资源浪费。

    • 解决方案:使用碰撞层掩码指定哪些层应该相互交互,避免不必要的碰撞检查。

    • 工作原理:根据对象的交互需求分配不同的层。配置物理设置以仅启用需要交互的特定层之间的碰撞。

    • 示例:让我们考虑一个 2D 平台游戏,其中玩家角色与敌人、收藏品和环境障碍物交互。通过将这些对象分配到不同的层(例如,玩家、敌人、收藏品和障碍物),您可以配置物理设置以仅允许特定层之间的碰撞。以下是一个示例:

      • 玩家层与敌人和障碍物层交互,但不与收藏品层交互

      • 敌人层与玩家和障碍物层交互,但不与收藏品层交互

      • 收藏品层不与玩家、敌人或障碍物层交互:

图 9.11 – 物理设置

图 9.11 – 物理设置

在您的脚本中,当使用射线投射或碰撞触发器执行碰撞检查时,您可以应用层掩码以过滤掉不必要的碰撞。例如,当您检查敌人碰撞时,您可以指定仅包括敌人层的层掩码,忽略与收藏品或障碍物的碰撞。

  • 结果:通过消除不必要的物理计算,减少了碰撞检查的数量并提高了性能。

在 Unity 中,通过碰撞层掩码优化物理和碰撞涉及战略性地分配层、配置物理设置以及应用层掩码以简化碰撞检查并提高性能。

  • 简化 碰撞检测

    • 问题:对于不需要真实物理交互的对象进行碰撞检测的完整物理计算可能会消耗大量资源。

    • 解决方案:使用触发器作为非必要对象的简化碰撞检测。

    • 如何工作:Unity 中的触发器是碰撞组件,它们可以检测当另一个碰撞体进入或退出它们的体积时,而无需与它们发生物理碰撞。它们非常适合需要检测交互而不需要模拟物理力的场景。

    • 示例:在一个游戏中,收集品硬币散布在关卡周围,而不是为硬币使用基于刚体的碰撞,你可以将触发碰撞体附加到硬币上。当玩家的角色与硬币的触发碰撞体重叠时,你可以处理收集逻辑,而无需进行完整的物理计算。

    • 结果:使用触发器可以减少仅需要碰撞检测而不需要物理响应的对象的物理计算相关的计算开销。这有助于提高性能,尤其是在有大量非必要对象的情况下。

    这种技术在优化场景中非常有用,其中对象不需要详细的物理交互,但仍然需要基本的碰撞检测功能。通过使用简化的碰撞检测方法,你可以节省计算资源,并提高 Unity 项目的整体性能。

音频

在 Unity 中优化音频对于保持流畅和沉浸式的游戏体验至关重要。让我们探索一种高级技术,以减少内存使用并提高游戏中的音频性能:

图 9.12 – 导入音频设置

图 9.12 – 导入音频设置

  • 示例:在游戏中压缩背景音乐和音效。

  • 结果:减少内存占用,加快加载时间,以及更流畅的游戏体验。

通过采用高级音频优化技术,如压缩和流式传输,你可以显著提高游戏性能,同时保持高质量的音频输出。

UI

我在第五章使用 C#为 Unity 游戏设计优化的用户界面中详细介绍了这个主题。你可以查看它以获取更多详细信息。优化 UI 是性能的关键部分,尤其是在移动游戏中,因为它直接影响用户体验和设备资源利用。

网络和多人游戏

优化 Unity 游戏中的网络和多玩家功能对于确保跨各种设备和玩家交互的流畅游戏体验至关重要。在这里,我们将探讨增强网络性能和实施 Unity 游戏中的有效多玩家机制的关键技术和策略:

  • 延迟补偿技术

    • 问题:延迟可能导致多人游戏中的延迟,导致同步问题和游戏不一致性。

    • 解决方案:实施延迟补偿技术以减轻网络延迟对游戏的影响。这取决于你实施的网络解决方案。你可以参考他们的文档以获取特定网络解决方案的信息,例如 Photon。

    • 示例:使用客户端预测、插值和延迟补偿等技术,根据输入和网络数据预测和平滑网络对象的移动。

    • 结果:在多人游戏中提高了响应性和同步性,减少了网络延迟对玩家体验的影响,并增强了游戏流畅性。

  • 网络 对象池

    • 问题:过度实例化和销毁网络对象可能导致网络拥塞和性能问题。

    • 解决方案:实施网络对象池来重复使用现有的网络对象,而不是频繁地创建和销毁它们。

    • 示例:在一个多人游戏中,而不是每次开火时都实例化和销毁子弹,使用对象池来回收子弹。当子弹不再需要时,它被返回到池中,以后可以再次使用。

    • 结果:由于对象实例化和销毁次数减少,网络开销降低,性能提高,从而带来更平滑的游戏体验。

这些技术在优化 Unity 游戏中的网络方面至关重要,因为它们有效地最小化了网络开销,提高了数据传输效率,并为玩家提供了更令人满意的多人游戏体验。然而,这些优化的有效性取决于游戏中实施的特定网络解决方案。

人工智能和路径查找

有效的 AI 和路径查找技术在创建沉浸式和引人入胜的游戏体验方面至关重要。我们将探讨两个关键解决方案:A*(A 星)路径查找和分层路径查找,以及行为树和状态机,以优化 AI 导航和行为:

  • 使用 行为树

    • 问题:效率低下的路径查找算法可能导致高计算开销和缓慢的性能,尤其是在具有动态障碍物的复杂游戏环境中。

    • 示例:在 Unity 中使用 NavMesh 系统实现 A*算法。

    • How it works: A* 是一种流行的路径查找算法,它能够高效地在图或网格上找到两点之间的最短路径。在 Unity 中,NavMesh 系统利用 A* 算法进行 AI 导航,使代理能够在避免障碍物的同时导航动态环境。

    • Result: 改善 AI 导航性能,降低计算成本,并在复杂游戏场景中使 AI 代理的运动更加平滑。

  • Using state machines for AI behavior:

    • Problem: AI 行为缺乏真实性和多样性可能导致可预测和单调的游戏体验。

    • Example: 实现状态机以用于 AI 角色行为。

    • How it works: 状态机将 AI 行为建模为一组状态、转换和动作。每个状态代表特定的行为或条件,转换定义了 AI 代理根据环境刺激或内部变量在状态之间切换的方式。

    这里是一个 AI 行为状态机的简化结构:

    State interface/class:

    • Enter: 进入状态时调用的方法

    • Update: 在状态中每个更新周期调用的方法

    • Exit: 离开状态时调用的方法

    Concrete states:

    • Idle State: 这表示 AI 正在空闲,具有针对空闲行为的特定 EnterUpdateExit 方法。

    • Attack State: 表示 AI 正在攻击,具有针对攻击行为的特定 EnterUpdateExit 方法

    • 根据需要添加其他状态,每个状态都有自己的行为方法

    State machine manager:

    • Current State: 跟踪 AI 的当前状态

    • Change State: 通过更新 Current State 变量将 AI 从一个状态转换到另一个状态的方法

    • Update: 在每个更新周期中要调用的方法,它反过来调用当前状态的 Update 方法

    Usage:

    • 使用初始状态(例如,空闲状态)初始化 AI

    • 在每个更新周期中,调用状态机管理器的 Update 方法来执行当前状态的行为

    • 当条件发生变化(例如,AI 检测到敌人)时,使用 Change State 方法切换到适当的状态(例如,攻击状态

    该结构概述了状态机中组件及其关系。您可以通过创建状态类/接口、实现处理状态转换的管理器以及将它们集成到您的 AI 系统中来用任何编程语言实现此结构。

    • Result: 模块化和组织化的 AI 行为设计,更容易调试和维护 AI 逻辑,以及更好地适应不断变化的游戏条件。

构建大小

高效的构建大小对于向玩家交付优化和完善的 Unity 游戏至关重要。让我们探讨一种称为构建大小缩减的技术,通过资产压缩来提高构建效率:

  • Build size reduction:

    • 问题:大型构建大小会导致下载时间更长,并增加玩家所需的存储空间。

    • 解决方案:实施资产压缩技术,如纹理压缩、音频压缩和代码剥离,以减少构建的整体大小。对于纹理压缩,建议使用 2 的幂次方尺寸,例如 64x64。这种方法对压缩有益,从而减少内存使用并减小最终构建大小。

    • 示例:使用适用于 Android 构建的 ETC2 和适用于 iOS 构建的 ASTC 纹理压缩格式,可以显著减小纹理资产的大小,同时不牺牲质量。纹理的大小在以下图中显示:

图 9.13 – 使用压缩格式前

图 9.13 – 使用压缩格式前

图 9.14 – 使用压缩格式后

图 9.14 – 使用压缩格式后

  • 结果:减小构建大小,加快下载时间,并在存储空间有限的设备上提高性能。

渲染

优化渲染对于提供视觉上令人惊叹的游戏同时保持最佳性能至关重要。让我们探讨两种强大的技术:用于高效网格渲染的细节级别LOD)系统和遮挡剔除以最小化不必要的渲染,从而提高性能和视觉质量:

  • LOD 系统

    • 问题:高多边形模型和复杂场景可能导致性能问题,尤其是在低端设备上。

    • 解决方案:实施一个 LOD 系统,其中对象具有多个版本,具有不同的细节级别。当对象远离摄像机时,系统切换到低细节版本,从而减少渲染工作量。

    • 示例:使用 Unity 的 LOD 组 组件为网格创建 LOD 级别,确保根据摄像机距离平滑过渡到 LOD 级别。确保你有必要的网格,可以通过从艺术家那里请求,利用资产商店中的资产,或者如果你有技能,可以手动创建。从原始网格创建低多边形网格对于优化性能很重要:

图 9.15 – LOD 组组件

图 9.15 – LOD 组组件

  • 结果:通过减少渲染的多边形数量来提高性能,同时不牺牲视觉质量。

  • 遮挡剔除

    • 问题:渲染屏幕外对象会消耗资源并影响性能,即使它们对玩家不可见。

    • 解决方案:使用遮挡剔除来防止被其他对象遮挡或不在玩家视锥体内的对象被渲染。

    • 示例:在 Unity 中配置遮挡剔除体积,以定义应应用遮挡剔除的区域,通过跳过遮挡对象来优化渲染:

图 9.16 – 起跑线前的摄像机

图 9.16 – 起跑线前的摄像机

图 9.17 – 起跑线后的摄像机

图 9.17 – 起跑线后的摄像机

  • 结果:减少了渲染工作量,提高了帧率,并增强了整体性能,尤其是在复杂场景中。

脚本

有效的脚本编写实践对于优化游戏性能和确保流畅的游戏体验至关重要。让我们探讨两种强大的技术:用于高效对象管理的对象池和用于增强协程性能的协程优化,从而提高整体游戏性能和响应速度。

对象池

让我们考虑一个对象池的解决方案:

  • 问题:在游戏过程中频繁地实例化和销毁对象会导致由于内存分配和垃圾回收而产生的性能开销。

  • 解决方案:实现对象池,其中一组预分配的对象被重复使用,而不是您需要反复实例化和销毁它们。

  • ObjectPoolManager及其功能。

    在下面的代码块中,ObjectPoolManager类负责处理与池化对象相关的所有操作。为了使其可以从其他脚本中访问,我们将它实现为一个单例:

     public class ObjectPoolManager : MonoBehaviour
       {
           // Static instance of the ObjectPoolManager
           private static ObjectPoolManager instance;
           // Property to access the ObjectPoolManager instance
           public static ObjectPoolManager Instance
           {
               get
               {
                   if (instance == null)
                   {
                       instance = FindObjectOfType<ObjectPoolManager>();
                       // If not found, create a new GameObject and add the ObjectPoolManager script to it
                       if (instance == null)
                       {
                           GameObject obj = new GameObject("ObjectPoolManager");
                           instance = obj.AddComponent<ObjectPoolManager>();
                       }
                   }
                   return instance;
               }
           }
           private void Awake()
           {
               if (instance != null && instance != this)
               {
                   Destroy(gameObject);
               }
               else
               {
                   instance = this;
                   DontDestroyOnLoad(gameObject);
               }
           }
    

    此脚本使用静态Instance属性来实现ObjectPoolManager的单例模式。它还包括一个Awake方法,以确保场景中只有一个ObjectPoolManager实例,并在需要时在场景变化之间持续存在。

    在下面的代码块中,我将继续通过GetPooledObjectReturnToPool函数实现与对象池相关的逻辑:

            // Define a dictionary to store object pools
            private Dictionary<string, Queue<GameObject>> objectPools = new Dictionary<string, Queue<GameObject>>();
            // Create or retrieve an object from the pool based on the name of it
            public GameObject GetPooledObject(string objectName)
            {
                if (objectPools.ContainsKey(objectName))
                {
                    if (objectPools[objectName].Count > 0)
                    {
                        GameObject obj = objectPools[objectName].Dequeue();
                        obj.SetActive(true);
                        return obj;
                    }
                }
                Debug.LogWarning("No available object in the pool with name: " + objectName);
                return null;
            }
            // Return an object to the pool
            public void ReturnToPool(string objectName, GameObject obj)
            {
                obj.SetActive(false);
                objectPools[objectName].Enqueue(obj);
            }
    

    下面是每个部分的解释:

    • private Dictionary<string, Queue> objectPools = new Dictionary<string, Queue>();:这一行声明了一个名为objectPools的私有字典,它根据名称存储对象池。每个名称对应一个 GameObject 队列。

    • public GameObject GetPooledObject(string objectName):此方法根据名称从对象池中检索一个对象。它检查是否存在具有给定名称的对象池以及池中是否有可用的对象。如果可用,它将对象出队、激活并返回它。如果没有可用对象,它记录一个警告并返回 null。

    • public void ReturnToPool(string objectName, GameObject obj):此方法根据名称将对象返回到对象池。它禁用对象并将其重新入队到相应的对象池队列中。

    最后,我将创建一个函数,从其他脚本中实例化池化对象,如下面的代码块所示:

      // Create an object pool for a specific prefab so I can dynamically add object to the pool in runtime
      public void CreateObjectPool(GameObject prefab, int poolSize, string objectName)
      {
          if (!objectPools.ContainsKey(objectName))
          {
              objectPools[objectName] = new Queue<GameObject>();
              for (int i = 0; i < poolSize; i++)
              {
                  GameObject obj = Instantiate(prefab);
                  obj.SetActive(false);
                  objectPools[objectName].Enqueue(obj);
              }
          }
          else
          {
              Debug.LogWarning("Object pool with name " + objectName + " already exists.");
          }
      }
    

    下面是CreateObjectPool方法的解释:

    • public void CreateObjectPool(GameObject prefab, int poolSize, string objectName):此方法为特定预制体创建一个具有给定池大小和对象名称的对象池。它会检查是否已存在具有相同名称的对象池。如果没有,它将在字典中创建一个新的队列,并根据预制体实例化对象以填充池。

    下面是一个如何使用此管理器的示例:

    public class ExampleUsage : MonoBehaviour
     {
         public GameObject prefabToPool;
         public int poolSize = 10;
         public string objectName = "MyTag";
         void Start()
         {
             // Create an object pool with the specified prefab, pool size, and tag
             ObjectPoolManager.Instance.CreateObjectPool(prefabToPool, poolSize, objectName);
             // Get an object from the pool
             GameObject obj = ObjectPoolManager.Instance.GetPooledObject(objectName);
             if (obj != null)
             {
                 // Use the object
                 obj.transform.position = Vector3.zero;
             }
             // Return the object to the pool
             ObjectPoolManager.Instance.ReturnToPool(objectName, obj);
         }
     }
    
  • 结果:减少了内存开销,提高了性能,并改善了游戏体验的流畅性,尤其是在频繁创建和销毁对象的场景中。

协程优化

现在,让我们考虑一个协程优化的解决方案:

  • 问题:过度使用协程而不进行优化可能导致性能问题,尤其是在处理长时间运行或频繁调用的协程时。

  • 解决方案:通过使用WaitForSeconds代替WaitForSecondsRealtime、最小化WaitForSeconds调用,以及在可能的情况下避免嵌套协程等技术来优化协程。此外,考虑定义或缓存WaitForSeconds实例,以避免每次协程执行时都创建新实例,这可以提高内存效率。以下代码块展示了如何定义WaitForSeconds的示例:

    // Define WaitForSeconds as a variable
        private WaitForSeconds waitShort = new WaitForSeconds(2f);
    
  • 示例:重构协程密集型脚本,减少协程实例的数量,优化 yield 指令,并使用如InvokeRepeating等替代方案进行重复性任务。

  • 结果:通过减少协程开销,使游戏体验更加流畅,以及提高帧率,尤其是在运行多个协程的复杂场景中,性能得到提升。

我们现在已经了解了一些常见问题和它们相应的解决方案。在下一节中,我们将继续学习如何优化内存。

内存管理和优化

Unity 中的内存分析涉及使用内存分析器模块和包等工具来分析和优化内存使用,使我们能够识别改进区域并提升整体性能。在本节中,你将了解更多关于内存分析器包的内容。

你可以通过两种方法在 Unity 应用程序中分析内存使用情况。首先,内存分析器模块提供了对内存使用的关键洞察,突出了应用程序消耗内存的区域。其次,通过将内存分析器包集成到项目中,你可以在 Unity 编辑器中获得一个增强的内存分析器窗口。这个高级工具允许进行更详细的分析,包括存储和比较快照以识别内存泄漏,以及检查内存布局以检测碎片化问题。

内存分析器

Unity 中的内存分析器是一个用于分析和优化 Unity 项目中内存使用的工具。它帮助我们了解我们的游戏如何使用内存,并识别可以优化的内存区域。

您需要将此包安装到您的项目中。转到 包管理器 窗口并选择 内存分析器,如图 图 9.18* 所示:

图 9.18 – 安装内存分析器包

图 9.18 – 安装内存分析器包

等待 Unity 完成内存分析器包的安装,然后从 窗口 | 分析 | 内存分析器 菜单打开它。如果您第一次在项目中使用它,将打开一个空窗口,如图 图 9.19* 所示:

图 9.19 – 内存分析器面板

图 9.19 – 内存分析器面板

现在我们已经学会了如何打开内存分析器,让我们来探索它是如何工作的以及它做了什么。

理解内存分析器的工作原理

内存分析器通过在 Unity 项目运行时实时监控和记录内存分配和使用情况来工作。它跟踪各种指标,如堆大小、按类型分配的内存、实例计数和内存泄漏。它提供了内存使用的详细分解,使开发者能够定位内存消耗高的区域和潜在的内存泄漏。

探索内存分析器的关键功能

内存分析器的主要功能如下:

  • 跟踪内存分配:内存分析器跟踪您的游戏所做的内存分配,包括堆内存、对象实例和资源使用

  • 识别内存泄漏:它通过突出显示未正确处置或从内存中释放的对象来帮助识别内存泄漏

  • 按类型分析内存使用情况:您可以看到项目中不同类型对象、脚本、纹理和其他资产的内存使用分解

  • 提供实例计数:内存分析器显示了当前内存中每种对象类型的实例数量,帮助您了解内存消耗模式

  • 深入了解资源使用情况:它提供了关于纹理、音频剪辑和其他资源如何影响内存使用的见解

现在,让我们学习如何使用内存分析器。

使用内存分析器

在我们深入内存分析器之前,考虑并遵守目标设备在多平台开发中的内存限制至关重要。根据硬件能力,在为每个设备指定的内存预算内设计场景和关卡,以确保基于硬件能力的最佳性能。设定明确的限制和指南有助于保持平台间的兼容性。

内存分析器包提供了全面的内存分析功能。利用它来存储和比较快照,以识别内存泄漏和优化应用程序的内存布局。与内存分析器模块不同,此包扩展了其功能,包括托管内存分析、快照保存、比较以及通过可视化分解详细探索内存内容:

图 9.20 – 内存分析器的摘要选项卡

图 9.20 – 内存分析器中的摘要选项卡

摘要选项卡提供了所选快照(s)中内存状态的概述。

一旦您点击摘要中的任何区域,有关该区域的更多详细信息将出现在右侧面板中。

我们还应考虑的另一个选项卡是Unity 对象,它展示了正在使用内存的 Unity 对象,以及它们在本地和托管内存中的相应分配和总计。您可以使用这些数据来查找重复的内存条目或确定具有重大内存使用的对象。您还可以使用搜索栏根据指定的文本过滤表中的条目。这可以在图 9.21中看到:

图 9.21 – 内存分析器中的 Unity 对象选项卡

图 9.21 – 内存分析器中的 Unity 对象选项卡

最后,所有内存选项卡仅适用于单快照模式,提供了快照中所有跟踪内存的详细分解。它可视化内存使用情况,展示了由 Unity 或平台管理的较大部分。此选项卡对于区分与 Unity 无关的内存消耗和揭示在Unity 对象选项卡中不明显潜在内存问题至关重要:

图 9.22 – 内存分析器中的所有内存选项卡

图 9.22 – 内存分析器中的所有内存选项卡

您可以通过以下步骤识别优化候选对象:

  1. 通过参考打开快照的说明来打开快照。

  2. 访问Unity 对象选项卡。

  3. 确保表格按降序排序,这是内存分析器窗口中的默认设置。如果排序顺序已更改,请选择总大小列标题以将此过程重置为降序。这种安排将内存使用量最高的对象置于表格顶部。

您可以通过两种方式之一搜索结果:

  • 展开组以查看每个组内的单个对象。

  • 考虑启用扁平化层次结构属性,以仅显示表格中的单个对象。

如果您不确定哪些对象可能使用过多内存,请禁用扁平化层次结构属性,并检查组以识别最大的对象。如果大多数资产都理解但怀疑有少数异常对象消耗了过多内存,则启用此属性。

此外,启用仅显示潜在重复项属性以识别内存分析器标记为潜在重复的对象。利用引用组件和选择详情组件对这些对象进行深入了解。这些信息有助于区分预期的重复项,例如场景中 Prefab 的多个实例,与问题重复项,例如意外创建的对象或 Unity 未正确处理的实例。

在内存分析方面,以下是一些考虑因素:

  • 根据质量级别、图形层级和 AssetBundle 变体等设置使用不同的内存使用情况,尤其是在更强大的设备上。

  • 质量级别图形设置可能会影响用于阴影图的 RenderTextures 的大小。

  • 分辨率缩放会影响屏幕缓冲区、RenderTextures 和后期处理效果。

  • 文本质量设置影响所有纹理的大小。

  • 最大检测限(LOD)可能会影响模型和其他元素。

  • 如 HD 和 SD 版本之类的 AssetBundle 变体可以根据设备的规格产生不同的资产大小。

  • 目标设备的屏幕分辨率会影响用于后期处理效果的 RenderTextures 的尺寸。

  • 支持的图形 API 可以根据 API 特定的变体影响着色器的大小。

  • 游戏过程中动态生成的内容,如程序化关卡,可能会显著影响内存使用。监控动态生成资产的内存占用,以确保有效的内存管理。

  • 在具有不同规格、屏幕分辨率和硬件配置的目标设备上进行全面测试,以识别和解决每个平台特有的内存问题。

通过考虑这些方面并进行全面的内存分析,您可以在不同平台上优化内存使用,确保 Unity 项目中的性能和资源利用达到最佳。

在 Unity 中,通过优化资产导入设置来提高内存优化是一种有效的方法。

导入模型

导入模型是 3D 游戏开发的关键方面,优化这一过程可以显著提高内存使用率和整体性能。在本节中,我们将探讨如图图 9.23所示的几个关键设置,这些设置可以通过调整来实现有效的优化:

图 9.23 – 模型的设置

图 9.23 – 模型的设置

网格压缩是一个设置,它决定了应用于导入的网格数据的压缩级别。它影响网格资产的大小,并可能影响内存使用和渲染性能:

  • 选项:Unity 为网格压缩提供了三个选项:

    • 关闭:不应用压缩,导致文件大小更大,但可能具有更好的渲染性能。

    • :应用基本的压缩算法以减小文件大小,同时仍保持视觉质量。这在许多情况下都适用,因为它对渲染性能没有显著影响。

    • 中/高:使用更高级的压缩技术进一步减小文件大小。然而,更高的压缩级别可能会由于额外的解压缩开销而导致渲染性能略有下降。

  • 最佳实践:根据您游戏的具体需求,明智地使用网格压缩。对于复杂模型或具有复杂细节的模型,考虑使用压缩以平衡文件大小减少与渲染性能。测试不同的压缩级别以找到您项目的最佳平衡。

读写启用设置确定网格数据是否可以在运行时访问和修改。启用此设置允许脚本在游戏过程中读取和修改网格属性:

  • 对性能和内存的影响:启用网格的读写权限会增加内存使用,因为网格数据需要以允许运行时修改的格式存储。然而,它也可以为动态网格操作提供灵活性,例如变形或程序化网格生成。

  • 最佳实践:只为需要运行时修改的网格启用读写权限。对于在游戏过程中不发生变化的静态网格,保持此设置禁用以减少内存开销。

优化网格设置确定 Unity 是否对导入的网格数据应用额外的优化以提高渲染性能:

  • 对性能和内存的影响:启用优化网格允许 Unity 执行优化,如顶点焊接,这在不显著影响视觉质量的情况下减少了网格中的顶点数量。这可以通过减少 GPU 上的工作量来提高渲染性能。

  • 最佳实践:为可以受益于顶点减少而不影响视觉保真度的网格启用优化网格。这对于具有冗余或重叠顶点的模型尤其有用,因为它可以显着减少内存使用并提高渲染性能。

Rig选项卡中将静态游戏对象的动画类型选项设置为可以对性能和内存使用产生积极影响。当您选择时,Unity 会跳过与动画绑定相关的任何处理,并在运行时不分配与动画相关的计算资源。这可以在图 9.24中看到:

图 9.24 – 模型设置中的 Rig 选项卡

图 9.24 – 模型设置中的 Rig 选项卡

让我们看看将动画类型设置为如何影响性能和内存。

性能影响

  • 通过将动画类型设置为,Unity 避免了处理游戏对象的动画数据和计算。这减少了 CPU 和 GPU 的工作量,尤其是在渲染和动画播放阶段。

  • 由于没有动画更新要执行,Unity 可以通过跳过与骨骼变换、混合形状或动画状态更新相关的计算来优化渲染管线。

  • 这种优化可以导致帧率更平滑,整体性能得到提升,尤其是对于不需要任何动画功能的静态对象。

内存影响

  • 动画类型设置为也有节省内存的好处。Unity 不会为应用此设置的 GameObject 分配内存以存储动画片段、绑定数据或与动画相关的组件(例如,Animator 或 Animation Controller)。

  • 这种内存使用量的减少可能非常显著,尤其是在包含多个静态对象或大型模型且不需要动画功能的场景中。

  • 通过最小化动画相关资源的内存分配,您可以释放更多内存用于其他游戏资产,并减少 Unity 项目的整体内存占用。

最佳实践

  • 对于静态游戏对象或模型,如果不需要动画功能,将动画类型设置为。这对于环境道具、静态景观元素或建筑模型尤其有效。

  • 对于需要动画的动态对象,例如角色或交互元素,根据它们的动画需求选择合适的动画类型(例如,通用人形旧版等)。

  • 定期审查和优化每个游戏对象的动画设置,以确保资源的高效使用并提高性能。

总结来说,将静态游戏对象的动画类型设置为可以是一种有益的优化策略,因为它可以减少 Unity 项目中的 CPU/GPU 工作负载和内存使用。

最后,当在 Unity 中考虑模型设置动画选项卡中的动画压缩选项时,选择最佳压缩关键帧减少确实会对性能和内存使用产生影响。这可以在图 9.25中看到:

图 9.25 – 模型设置中的动画选项卡

图 9.25 – 模型设置中的动画选项卡

让我们看看每个选项如何影响您的项目。

最佳压缩

  • 性能影响最佳压缩旨在在保持视觉质量的同时减少动画片段的大小。这可以在运行时提高性能,尤其是在处理能力或内存带宽有限的设备上。

  • 内存影响:通过有效地压缩动画数据,最佳压缩减少了动画片段的内存占用。这对于拥有许多动画或大型动画文件的项目有益,可以降低内存使用并改善资源管理。

关键帧减少

  • 性能影响关键帧减少专注于在保持平滑运动的同时最小化动画片段中的关键帧数量。这可以通过减少动画播放期间在关键帧之间插值计算的开销来提高性能。

  • 记忆影响:虽然关键帧减少可以通过减少动画播放所需的数据来帮助节省内存,但在内存优化方面可能不如最佳压缩高效。然而,它仍然可以有助于降低动画片段的整体内存使用。

最佳实践

  • 最佳压缩:对于需要在文件大小减少和视觉质量之间取得平衡的动画,请使用最佳压缩。它适用于广泛的动画,并可以提供显著的内存和性能优势。

  • 关键帧减少:对于减少关键帧数量不会显著影响视觉保真度的动画,可以考虑使用关键帧减少。此选项对于重复或简单的动画特别有用,有助于简化内存使用并提高播放性能。

选择正确的选项

  • 评估您项目的动画需求,并选择最适合您需求的压缩选项。最佳压缩通常推荐用于大多数场景,因为它在减少文件大小和内存使用的同时保持了视觉质量。

  • 定期测试不同压缩设置下的动画,以找到适合您特定项目需求的性能、内存使用和视觉保真度之间的最佳平衡。

总之,动画压缩选项中的最佳压缩关键帧减少都会影响 Unity 中的性能和内存。选择与您的项目动画复杂度、视觉质量标准和目标平台能力相匹配的压缩设置以实现最佳结果。

为了自动化此过程,我们可以创建一个名为 PostProcessor 的资产脚本,这是一个编辑器脚本,允许我们为导入资产设置默认设置。以下是一个示例代码块,演示了一个 CustomMeshPostProcessor 类,在导入网格时实现所需的默认设置。只需创建此脚本并将其放置在项目中的 Editor 文件夹中。一旦实施,脚本将自动在导入新模型时应用这些设置。您可以自由地对其进行自定义并根据需要添加更多设置以适应您的游戏:

using UnityEditor;
using UnityEngine;
public class CustomMeshPostprocessor : AssetPostprocessor
{
    void OnPreprocessModel()
    {
        ModelImporter importer = assetImporter as ModelImporter;
        importer.isReadable = false; // Set Read/Write option to disabled
        importer.meshCompression = ModelImporterMeshCompression.Medium; // Set Mesh Compression to Medium
    }
}

总体而言,在 Unity 中对模型导入设置进行优化对于实现游戏中的高效内存使用和整体性能至关重要。通过仔细配置这些设置并遵循最佳实践,您可以在保持最佳资源利用率的同时显著提升用户体验。

导入纹理

导入纹理的话题对优化游戏和内存性能影响极大,其显著效果还扩展到 UI 性能。图 9.26 中显示了可用的设置:

图 9.26 – 纹理设置

图 9.26 – 纹理设置

当将纹理导入 Unity 时,有两个关键设置会影响性能和内存使用,即读/写生成 MipMaps。让我们深入了解每个设置及其影响。

读/写

  • 性能影响:启用读/写允许脚本在运行时访问和修改纹理数据。虽然这种灵活性对于某些功能(如动态纹理更新或程序生成)可能有益,但它也带来了性能成本。每个标记为读/写的纹理都会消耗额外的内存,并且在运行时可能需要更多的处理能力。

  • 内存影响:启用读/写的纹理与未启用此选项的纹理相比,占用的内存更多。这是因为 Unity 为纹理数据和可以修改的附加副本分配空间。因此,为多个纹理启用读/写可能导致内存使用增加,尤其是在资源受限的平台。

生成 MipMaps

  • 性能影响:生成 MipMaps 创建了一系列预计算的纹理级别(MipMaps),这提高了渲染质量和性能。然而,这个过程在纹理导入或运行时生成时需要额外的计算资源,影响加载时间和初始性能。

  • 内存影响:包含 MipMaps 会增加纹理的内存占用,因为每个 MipMap 级别都会增加纹理的总大小。虽然 MipMaps 通过在不同距离提供优化的纹理采样来提高渲染性能,但它们也消耗更多的内存,尤其是对于具有众多 MipMap 级别的较大纹理。

最佳实践

  • 读/写:仅对需要运行时修改或动态更新的纹理启用读/写。对于用作精灵、背景或 UI 元素的静态纹理,禁用读/写以节省内存并提高性能。

  • 生成 MipMaps:对于将受益于改进的渲染质量和性能的纹理,例如用于 3D 模型或远距离地形的纹理,使用生成 MipMaps。在决定是否包含 MipMaps 时,考虑提高视觉保真度和增加内存使用之间的权衡。

选择正确的设置

  • 评估你项目中每种纹理的具体需求。根据纹理是否需要运行时修改以及是否需要 MipMaps 进行优化渲染,明智地启用读/写生成 MipMaps

  • 定期监控不同设置下纹理的内存使用和性能影响,以优化资源利用并保持高效的运行时行为。

总结来说,当你在 Unity 中导入纹理时,管理读取/写入生成 Mip 贴图设置对于平衡性能、内存使用和视觉质量至关重要。根据每个纹理的预期使用情况选择适当的设置有助于优化资源分配并提高整体应用程序性能。

另一个需要考虑的方面是压缩设置,这些设置根据目标平台而异,每个平台都有其独特的配置。虽然具体的平台设置非常广泛且依赖于平台,但有一些通用的优化压缩的建议。

最大尺寸:

  • 调整纹理的最大尺寸决定了导入时的纹理尺寸。较高的分辨率提供更好的视觉质量,但消耗更多的内存。考虑设备的性能和纹理的预期用途,在质量和性能之间取得平衡。

  • 最佳实践:根据目标平台和纹理在游戏中的作用设置最大尺寸。对于背景元素或远距离对象使用较低的分辨率以节省内存。

调整尺寸的算法

  • 调整尺寸的算法决定了当纹理的尺寸超过最大尺寸时如何缩放纹理。不同的算法可能影响图像质量和内存使用。

  • 最佳实践:选择适合纹理类型的算法。对于细节纹理使用更锐利的算法,对于渐变或图案使用更平滑的算法以保持质量。

格式:

  • 纹理格式决定了图像数据如何存储,影响压缩、内存使用和视觉保真度。常见的格式包括 PNG、JPG 和 TGA,每种格式都有其压缩级别和质量权衡。

  • 最佳实践:根据纹理的内容和使用情况选择格式。使用 PNG 以获得无损质量,使用 JPG 以压缩照片纹理,使用 TGA 以获得具有透明度的高质量图像。

压缩:

  • 压缩方法可以减小纹理大小和内存占用。Unity 提供了正常质量高质量低质量等压缩选项,它们以不同的方式影响图像质量和内存使用。

  • 最佳实践:根据性能要求和视觉标准选择压缩设置。对于关键纹理使用高质量压缩,对于背景或非关键元素使用低质量压缩。

Crunch 压缩:

  • Crunch 压缩是一种进一步减少纹理文件大小的同时保持可接受质量的方法。它对于优化内存使用,尤其是在资源受限的平台上有益。

  • 最佳实践:对于文件大小减少至关重要的纹理,如 UI 元素或常用纹理,启用 Crunch 压缩。平衡压缩级别以在保持视觉质量的同时最小化内存影响。

记住,这些设置的影响可能因平台而异,因此测试和迭代是关键。定期监控内存使用情况和性能指标,以优化纹理设置,以在每个目标平台上获得最佳结果。

通过仔细配置导入设置,你可以减少内存使用并提高 Unity 项目的整体性能。

在 Unity 中,利用精灵图集是内存优化的一项关键技术。

精灵图集

Unity 中的精灵图集是优化内存和性能的必要工具,尤其是对于 UI 元素。它们允许你将多个精灵组合成单个图像,减少绘制调用和纹理内存使用。让我们学习如何有效地使用精灵图集,同时考虑其最佳实践和它们对内存和性能的影响。

创建精灵图集

注意

确保你通过包管理器在你的项目中安装了2D Sprite包。

在你的项目标签页中,右键单击并选择创建 | 2D | 精灵图集,如图 9.27所示:

图 9.27 – 精灵图集

图 9.27 – 精灵图集

注意

确保你在编辑 | 项目设置 | 编辑器中启用了精灵打包器选项,这样你就可以开始使用这个包了。

启用精灵打包器,如图 9.28所示:

图 9.28 – 在项目设置中启用精灵打包器

图 9.28 – 在项目设置中启用精灵打包器

创建精灵图集后,导航到它以开始添加纹理。点击+号选择单个纹理或包含纹理的文件夹。然后,点击打包预览将它们组合成图集文件,如图 9.29所示:

图 9.29 – 精灵图集设置

图 9.29 – 精灵图集设置

这里有一些你应该考虑的最佳实践:

  • 将常用精灵合并到单个精灵图集中,以最小化绘制调用并提高性能

  • 使用精灵打包器窗口中的打包选项来优化精灵在图集中的打包方式,以最小化浪费的空间

  • 避免在图集中包含过大或不必要的精灵,以保持图集大小可控

精灵图集对内存和性能有影响。让我们更深入地了解一下:

  • 内存:精灵图集通过减少加载到内存中的单个纹理数量来帮助节省内存。然而,请注意精灵图集的总大小,因为它仍然根据其尺寸和内容占用内存。

  • 性能:精灵图集通过减少渲染 UI 元素所需的绘制调用次数来提高性能。这对于具有许多精灵的复杂 UI 尤其有益。

精灵图集主要影响 UI 元素在内存和性能方面的表现。它们旨在优化 2D 图形的渲染,因此它们对游戏的其他方面,如 3D 模型或音频,的影响最小。然而,对于以 UI 为主的游戏或应用程序,正确利用精灵图集可以显著提升性能和内存管理。

摘要

在本章中,我们开始了使用 Unity 强大的性能分析工具来优化游戏性能的旅程。在掌握我们已经获得的知识的基础上,我们深入了解了 Unity 性能分析工具的介绍,加强了识别性能瓶颈和应用优化技术以显著提升游戏性能的能力。本章进一步加深了我们对于内存管理和优化的理解,指导我们如何有效地管理内存使用并优化游戏中的内存性能。通过实际练习和洞察,我们巩固了对 Profiler、帧调试器和内存分析器的掌握,确保我们的游戏运行顺畅且高效。

展望第十章,我们将发现一大堆使用 C#增强 Unity 开发技能的技巧和窍门。下一章将专注于提高生产力的快捷方式、高级技术和工作流程,以及解决 Unity 开发中常见挑战的方法。我们将学习如何利用快捷方式提高开发效率,应用高级技术来提升我们的游戏开发流程,并解决我们可能遇到的常见挑战。本章将为我们提供宝贵的见解,我们可以将这些见解应用于改进我们的工作流程、克服挑战,并在使用 C#进行 Unity 游戏开发时解锁新的可能性。

第十一章:Unity 中的技巧与窍门

欢迎来到我们使用 C# 进行游戏开发之旅的最后一章!在本章中,我们将深入探讨旨在提高生产力和提升您游戏开发技能的高级技术和工作流程。我们将从探索必要的 Unity 编辑器快捷键开始,接着是节省时间的代码编辑器快捷键,专门针对 C# 脚本编写。接下来,我们将深入研究优化您的预制件工作流程和有效地掌握预制件的使用。在高级技术方面,我们将揭示 Scriptable Objects 在数据驱动开发中的力量,并深入探讨创建自定义编辑器以增强工作流程和用户体验。最后,我们将解决故障排除和常见挑战,掌握调试工具并解决特定平台挑战,如移动优化和跨平台开发。准备好提升您的游戏开发技能,随着我们导航这些提高生产力的策略和高级技术!

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

  • C# 的提高生产力快捷键

  • C# 的高级技术和工作流程

  • 故障排除和常见挑战

技术要求

本章所需的所有代码文件都可以在以下位置找到:github.com/PacktPublishing/Mastering-Unity-Game-Development-with-C-Sharp/tree/main/Assets/Chapter%2010

C# 的提高生产力快捷键

在本节中,我们将深入探讨各种基本快捷键和技巧,这些技巧可以显著提高您的 Unity 开发工作流程。我们将从探索必要的 Unity 编辑器快捷键开始,这些快捷键对于快速有效地导航 Unity 编辑器至关重要。接下来,我们将关注代码编辑器快捷键,您将学习针对您首选代码编辑器中 C# 脚本编写的节省时间的键盘快捷键。随后,我们将讨论预制件工作流程优化,提供有效使用预制件和高效预制件管理的快捷键。每个主题都将帮助简化您的开发过程,使使用 Unity 更高效和更有生产力。

快捷键是极具生产力的工具,帮助我们节省宝贵的时间,这在我们的工作流程中至关重要。

Unity 编辑器快捷键

在本子节中,我们将解锁 Unity 编辑器快捷键的强大功能,以实现高效的导航和管理,节省我们宝贵的时间,并提高 Unity 开发的生产力。

层次结构导航快捷键

首先,让我们开始探索 Unity 的层次结构导航快捷键,以实现复杂场景中 GameObject 的有序和高效编辑。让我们深入了解这些有用的快捷键:

  • Ctrl/Cmd + 左箭头:在 层次结构 视图中折叠选定的 GameObject 的层次结构,如图下所示:

图 10.1 – 折叠选定的 GameObject

图 10.1 – 折叠选定的 GameObject

  • Ctrl/Cmd + 右箭头: 在层次结构视图中展开选定的 GameObject 的层次结构,如图下所示:

图 10.2 – 展开选定的 GameObject

图 10.2 – 展开选定的 GameObject

接下来,我们将探讨另一类快捷键,这次聚焦于 GameObject。

GameObject 创建和管理快捷键

在这里,我们将探讨 Unity 的 GameObject 创建和管理快捷键,用于在场景中高效创建、添加组件、设置父对象和维持层次结构。让我们深入了解这些提高生产力的快捷键:

  • Ctrl/Cmd + Shift + N: 创建一个空 GameObject:

图 10.3 – 创建一个空 GameObject

图 10.3 – 创建一个空 GameObject

  • Ctrl/Cmd + Shift + A: 向选定的 GameObject 添加组件:

图 10.4 – 向选定的 GameObject 添加组件

图 10.4 – 向选定的 GameObject 添加组件

  • Ctrl/Cmd + Shift + V: 将 GameObject 作为另一个 GameObject 的子对象粘贴:

图 10.5 – 将 GameObject 作为另一个 GameObject 的子对象粘贴

图 10.5 – 将 GameObject 作为另一个 GameObject 的子对象粘贴

  • Ctrl/Cmd + Shift + G: 为选定的 GameObject 创建一个空父对象:

图 10.6 – 为 GameObject 创建一个空父对象

图 10.6 – 为 GameObject 创建一个空父对象

上述快捷键只是提高生产力的简单示例。您可以通过导航到快捷键编辑 | 快捷键)来学习更多快捷键。

导航到快捷键后,您将看到如图图 10**.7所示的面板。注意左侧列表中的创建配置文件、分类快捷键和修改现有快捷键的选项。您还可以为空槽分配快捷键:

图 10.7 – 快捷键面板

图 10.7 – 快捷键面板

如果您需要为工作创建特定的配置文件,只需单击创建新配置文件..选项。这将弹出一个面板,您可以在其中为新配置文件分配名称:

图 10.8 – 创建配置文件面板

图 10.8 – 创建配置文件面板

在创建新配置文件后,您可以修改其中的默认快捷键。

利用这些 Unity 编辑器快捷键可以减少开发时间,提高工作效率,从而更有效地利用时间在关键任务上。

现在,让我们探索节省时间的 Visual Studio 快捷键,这些快捷键可以提高您的生产力。

Visual Studio 快捷键

在本小节中,我们将探讨提升效率的 Visual Studio 快捷键,这些快捷键分为导航、重构和代码生成类别,以简化您的编码体验并提高生产力。

导航快捷键

我们将从发现一系列强大的 Visual Studio 导航快捷键开始,这些快捷键旨在简化您的编码体验并提高生产力。让我们深入这些节省时间的快捷键:

  • Ctrl + T: 打开一个搜索窗口,以便您可以快速导航到任何文件、类型或成员:

图 10.9 – 搜索面板

图 10.9 – 搜索面板

  • Ctrl + Shift + V: 允许您查看剪贴板历史记录,以便您可以粘贴之前复制的项目:

图 10.10 – 编辑器剪贴板

图 10.10 – 编辑器剪贴板

接下来,让我们探索另一类快捷键,这次聚焦于重构。

重构快捷键

让我们了解 Visual Studio 的重构快捷键如何帮助您轻松提高代码可读性、可维护性和整体代码质量。让我们深入这些高效的重构工具!

  • Ctrl + R, R: 在整个代码库中重命名符号:

图 10.11 – 重命名符号

图 10.11 – 重命名符号

  • Ctrl + R, M: 将方法提取为重构代码到单独的方法:

图 10.12 – 提取方法

图 10.12 – 提取方法

接下来,让我们探索另一类快捷键,这次聚焦于代码生成。

代码生成快捷键

在这里,我们将探索 Visual Studio 的代码生成快捷键,这些强大的工具旨在简化编码任务,自动化重复性操作,并增强代码的一致性和可读性。让我们深入这些节省时间的快捷键:

  • Ctrl + K, S: 将选定的代码包裹在一个代码片段(例如 try-catchif-else)中:

图 10.13 – 包围选定的代码

图 10.13 – 包围选定的代码

  • Ctrl + Space: 显示 IntelliSense 以帮助自动完成代码或显示代码完成的建议:

图 10.14 – 自动完成代码

图 10.14 – 自动完成代码

您可以通过导航到 工具 | 选项 | 环境 | 键盘 来访问和修改 Visual Studio 的快捷键。

注意

这些快捷键专门为 Visual Studio 设计。要了解更多关于您编辑器的快捷键,您可以导航到其 快捷键 面板。

总结来说,掌握 Visual Studio 的强大快捷键,用于导航、重构和代码生成,对于优化您的编码工作流程和提升开发项目中的效率至关重要。通过探索和利用这些节省时间的工具,您可以提高代码可读性,简化编码任务,并最终在编程工作中提高生产力。

优化预制工作流程对于高效的 Unity 开发至关重要,它允许您简化资产管理、增强模块化,并在整个项目中提高生产力。

预制工作流程优化

预制件工作流程优化包括各种技术和策略,以有效地管理预制件,提高可重用性,并在您的 Unity 项目中保持一致性。以下是一些您可以实施的技术,以改进您的流程:

  • 预制件变体:使用预制件变体来创建具有覆盖属性或组件的基预制件的变体。这允许您在自定义特定预制件实例的同时保持一致性。

    要创建预制件变体,首先选择您想要从中派生的基预制件。然后,在项目窗口中右键单击它,并选择创建 | 预制件变体。这将创建基预制件的新变体。您可以在保持与基预制件连接的同时自定义变体的属性、组件和层次结构。这允许您对变体进行特定更改,而不会影响基预制件的其他实例。

  • 嵌套预制件:利用 Unity 的嵌套预制件功能创建具有嵌套层次结构的模块化和可重用组件。这有助于更好地组织并简化复杂预制件结构的更新。

    要创建嵌套预制件,只需在 Unity 层次结构中将一个预制件拖放到另一个预制件上。这将使拖动的预制件成为另一个预制件的子项,创建嵌套关系。嵌套预制件允许您在父预制件内封装可重用组件或对象,使其更容易管理和更新复杂的预制件结构。对嵌套预制件所做的更改将自动反映在父预制件的所有实例中。

  • ScriptableObject资产,用于存储配置数据、参数或其他资产的引用。然后,您可以通过将它们作为属性或参数分配到附加到预制件实例的脚本中,将这些ScriptableObject资产应用于预制件。这允许数据驱动的预制件定制和灵活性。

  • 预制件 PrefabUtility 事件:利用PrefabUtility事件,例如Prefab InstanceUpdatedCallbackPrefabUtility事件,例如PrefabInstanceUpdatedCallbackPrefabInstanceRemovedCallback,在编辑器中修改或删除预制件时执行自定义操作或验证。通过在您的编辑器脚本中订阅这些事件,您可以根据预制件的修改触发自定义逻辑或工作流程,从而实现自动验证检查或工作流程优化。

让我们探索一个实际示例,展示这些技术中的一种实现。

在预制件实例间更新组件属性

让我们考虑一个场景,您在 Unity 项目中拥有大量预制件,并且需要更新场景中特定预制件的所有实例的特定组件或属性。手动更新每个实例可能既耗时又容易出错。然而,通过使用 C#脚本和 Unity 的PrefabUtility API,您可以有效地自动化此过程。

问题:您有一个游戏中散布在场景中的数百个敌人预制体。由于游戏玩法的变化,您需要更新所有敌人预制体中EnemyMovement组件的移动速度属性。

解决方案:您可以创建一个 C#脚本,遍历场景中所有敌人预制体的实例,并按程序方式更新EnemyMovement组件的移动速度属性,如下所示:

using UnityEngine;
using UnityEditor;
public class EnemyPrefabUpdater : MonoBehaviour
{
    public float newMovementSpeed = 10f; // New movement speed value
    [MenuItem("Tools/Update Enemy Prefabs")]
    static void UpdateEnemyPrefabs()
    {
        GameObject[] enemyPrefabs = Resources.LoadAll<GameObject>("Prefabs/Enemies"); // Load all enemy prefabs from Resources folder
        foreach (GameObject prefab in enemyPrefabs)
        {
            // Instantiate prefab to apply changes
            GameObject instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
            // Update movement speed property of EnemyMovement component
            EnemyMovement enemyMovement = instance.GetComponent<EnemyMovement>();
            if (enemyMovement != null)
            {
                enemyMovement.movementSpeed = newMovementSpeed;
            }
            // Save changes to prefab
            PrefabUtility.ApplyPrefabInstance(instance, InteractionMode.UserAction);
            // Destroy temporary instance
            DestroyImmediate(instance);
        }
        Debug.Log("Enemy prefabs updated successfully.");
    }
}

让我们更详细地看看UpdateEnemyPrefabs方法。

此方法被标记为静态,并带有[MenuItem]属性,使其成为可以从 Unity 编辑器的工具菜单访问的自定义菜单项:

  • static void UpdateEnemyPrefabs():这个静态方法遍历 Unity 项目Resources目录中Prefabs/Enemies文件夹内的所有敌人预制体

  • GameObject[] enemyPrefabs = Resources.LoadAll("Prefabs/Enemies");:这一行使用Resources.LoadAll()方法从Prefabs/Enemies文件夹加载所有 GameObject 预制体

  • foreach (GameObject prefab in enemyPrefabs) { ... }:这个foreach循环遍历从Resources文件夹加载的每个敌人预制体

按照以下步骤有效地使用此组件并实现所需的解决方案:

  1. EnemyPrefabUpdater脚本附加到场景中的任何 GameObject 上。

  2. newMovementSpeed变量设置为移动速度属性的期望值。

  3. 在编辑器中,转到EnemyMovement组件,并将更改保存回预制体。

总体而言,此脚本提供了一个方便的方法,通过单个菜单命令在 Unity 编辑器中更新多个敌人预制体的属性,从而提高工作流程效率和生产力。

通过实施这些预制体工作流程优化技术,您可以简化资产管理,加速开发迭代,并确保 Unity 项目中更组织化和可扩展的项目结构。

让我们深入了解 Unity 游戏开发的世界,探索涉及使用 C#的高级技术和工作流程。

C#的高级技术和工作流程

在本节中,我们将深入了解 Unity 中高级 C#技术和工作流程的细节,提供有关这些策略如何增强您的游戏开发过程的见解。我们将涵盖几个关键主题,包括 ScriptableObjects 和自定义编辑器。

ScriptableObjects

ScriptableObjects 是 Unity 中的动态资源,允许您将数据与 GameObject 实例分开存储和管理。它们非常适合实现数据驱动系统并促进游戏不同部分之间的通信。让我们看看您可以利用 ScriptableObjects 的不同方式:

  • 数据驱动开发

    使用 ScriptableObjects 存储数据,例如游戏设置、角色统计数据、物品属性等。这允许在不修改代码的情况下轻松修改和迭代。你可以在第六章使用 C#进行有效的游戏数据处理和管理中了解更多关于数据驱动开发的内容。

  • 事件系统

    事件驱动架构是一种强大的方式,可以以解耦的方式在不同游戏元素之间促进通信。通过使用 ScriptableObjects 作为事件,我们将创建一个灵活且健壮的系统来处理交互和触发游戏中的动作。

以下是基于 ScriptableObjects 的事件系统的优势:

  • 解耦通信:基于 ScriptableObjects 的事件系统使得游戏中的不同组件之间可以实现解耦通信。这意味着组件可以相互交互,而无需直接引用,从而使得代码更加清晰和模块化。

  • 灵活性和可扩展性:ScriptableObjects 提供了一种灵活且可扩展的方式来定义自定义事件和数据结构。这允许你创建针对特定交互或游戏机制的事件类型,使你能够轻松设计复杂的系统。

  • 集中式事件管理:通过在 ScriptableObjects 中集中管理事件,你可以为处理游戏事件保持一个清晰和有序的结构。这使得在项目发展过程中更容易调试、修改和扩展你的事件系统。

  • 松耦合:使用 ScriptableObjects 进行事件通信可以促进游戏元素之间的松耦合。这意味着对代码库某一部分的更改不太可能对其他部分产生意外的后果,从而使得代码更加健壮和易于维护。

让我们通过考虑游戏中的一个真实场景以及我们如何使用 ScriptableObjects 进行事件驱动架构来学习。

想象一个塔防游戏,其中塔需要响应敌人生成、玩家升级和环境效果。我们将使用 ScriptableObjects 实现一个事件系统来处理这些交互:

  1. 首先,让我们使用 ScriptableObjects 为敌人生成、玩家升级和环境效果定义自定义事件:

    using UnityEngine;
    using UnityEngine.Events;
    // Scriptable Object representing an enemy spawn event
    [CreateAssetMenu(fileName = "EnemySpawnEvent", menuName = "Events/Enemy Spawn")]
    public class EnemySpawnEvent : ScriptableObject
    {
        public UnityAction<Vector3> onEnemySpawn;
        public void RaiseEvent(Vector3 spawnPosition)
        {
            onEnemySpawn?.Invoke(spawnPosition);
        }
    }
    // Scriptable Object representing a player upgrade event
    [CreateAssetMenu(fileName = "PlayerUpgradeEvent", menuName = "Events/Player Upgrade")]
    public class PlayerUpgradeEvent : ScriptableObject
    {
        public UnityAction<int> onPlayerUpgrade;
        public void RaiseEvent(int upgradeLevel)
        {
            onPlayerUpgrade?.Invoke(upgradeLevel);
        }
    }
    // Scriptable Object representing an environmental change event
    [CreateAssetMenu(fileName = "EnvironmentChangeEvent", menuName = "Events/Environment Change")]
    public class EnvironmentChangeEvent : ScriptableObject
    {
        public UnityAction<Color> onEnvironmentChange;
        public void RaiseEvent(Color newColor)
        {
            onEnvironmentChange?.Invoke(newColor);
        }
    }
    
  2. 接下来,我们将在Tower类中订阅这些事件,并实现当这些事件被触发时的逻辑:

    using UnityEngine;
    public class Tower : MonoBehaviour
    {
        public EnemySpawnEvent enemySpawnEvent;
        public PlayerUpgradeEvent playerUpgradeEvent;
        public EnvironmentChangeEvent environmentChangeEvent;
        private void OnEnable()
        {
            enemySpawnEvent.onEnemySpawn += ReactToEnemySpawn;
            playerUpgradeEvent.onPlayerUpgrade += ReactToPlayerUpgrade;
            environmentChangeEvent.onEnvironmentChange += ReactToEnvironmentChange;
        }
        private void OnDisable()
        {
            enemySpawnEvent.onEnemySpawn -= ReactToEnemySpawn;
            playerUpgradeEvent.onPlayerUpgrade -= ReactToPlayerUpgrade;
            environmentChangeEvent.onEnvironmentChange -= ReactToEnvironmentChange;
        }
        private void ReactToEnemySpawn(Vector3 spawnPosition)
        {
            // Logic to react to enemy spawn
            Debug.Log("Tower reacting to enemy spawn at position: " + spawnPosition);
        }
        private void ReactToPlayerUpgrade(int upgradeLevel)
        {
            // Logic to react to player upgrade
            Debug.Log("Tower reacting to player upgrade, level: " + upgradeLevel);
        }
        private void ReactToEnvironmentChange(Color newColor)
        {
            // Logic to react to environment change
            Debug.Log("Tower reacting to environment change, new color: " + newColor);
        }
    }
    
  3. 接下来,我们将从相关组件中触发事件。EnemySpawnerPlayerManagerEnvironmentManager类负责调用相应的事件:

    using UnityEngine;
    public class EnemySpawner : MonoBehaviour
    {
        public EnemySpawnEvent enemySpawnEvent;
        public void SpawnEnemy(Vector3 spawnPosition)
        {
            // Logic to spawn enemy
            // ...
            // Raise enemy spawn event
            enemySpawnEvent.RaiseEvent(spawnPosition);
        }
    }
    public class PlayerManager : MonoBehaviour
    {
        public PlayerUpgradeEvent playerUpgradeEvent;
        public void UpgradePlayer(int upgradeLevel)
        {
            // Logic to upgrade player
            // ...
            // Raise player upgrade event
            playerUpgradeEvent.RaiseEvent(upgradeLevel);
        }
    }
    public class EnvironmentManager : MonoBehaviour
    {
        public EnvironmentChangeEvent environmentChangeEvent;
        public void ChangeEnvironmentColor(Color newColor)
        {
            // Logic to change environment color
            // ...
            // Raise environment change event
            environmentChangeEvent.RaiseEvent(newColor);
        }
    }
    

在这个例子中,我们使用 ScriptableObjects 创建了三个自定义事件:EnemySpawnEventPlayerUpgradeEventEnvironmentChangeEvent。每个事件封装了一个特定的游戏事件,并提供了一种机制来使用相关数据触发事件。

Tower类订阅了这些事件,并实现了事件处理程序以响应敌人生成、玩家升级和环境变化。其他组件,如EnemySpawnerPlayerManagerEnvironmentManager,在游戏中的相关动作发生时引发这些事件。

此实现展示了使用 ScriptableObjects 进行游戏元素解耦通信的强大功能,使 Unity 中的灵活和模块化事件驱动架构成为可能。

解锁 Unity 编辑器的全部潜力超出了默认的检查器视图。通过自定义编辑器,我们可以根据其精确规格定制界面,提高生产力和用户体验。

自定义编辑器

自定义编辑器对于扩展 Unity 编辑器功能,超出默认的检查器视图至关重要。它们允许您创建针对特定需求的专用界面,提供更高效的流程和增强用户体验。以下是一些自定义编辑器开发的高级技术:

  • 定制属性绘制

  • 使用场景辅助线

我们将在接下来的小节中逐一介绍这些高级技术。

定制属性绘制

我们可以自定义在[Header][Space][Range][TextArea]和其他中的属性显示方式。您还可以创建自定义属性抽屉,以完全控制特定数据类型或类的 UI。

在自定义检查器和编辑器中,调整属性显示方式的能力至关重要。Unity 提供了一系列属性,允许我们在检查器窗口内创建视觉上吸引人且组织有序的 UI。以下我将提及一些这些属性及其用法:

  • [ContextMenu("菜单项名称")]: 此属性用于在 Unity 编辑器中右键单击组件或资产时创建自定义上下文菜单项。图 10.15图 10.16展示了在编辑器中使用此属性的方法:

图 10.15 – ContextMenu 属性

图 10.15 – ContextMenu 属性

图 10.16 – ContextMenu 使用

图 10.16 – ContextMenu 使用

  • [AddComponentMenu("菜单名/子菜单名/组件名")]: 此属性将组件添加到添加组件菜单中的特定菜单层次结构。图 10.17图 10.18展示了在编辑器中使用此属性的方法:

图 10.17 – AddMenuComponent 属性

图 10.17 – AddMenuComponent 属性

图 10.18 – AddMenuComponent 使用

图 10.18 – AddMenuComponent 使用

  • [ColorUsage(bool showAlpha, bool hdr)]: 此属性为检查器视图中的颜色渐变字段提供颜色选择器选项,允许您指定是否显示 alpha 通道和使用高动态范围HDR)颜色。图 10.19图 10.20展示了在编辑器中使用此属性的方法:

图 10.19 – ColorUsage 属性

图 10.19 – ColorUsage 属性

图 10.20 – ColorUsage 使用

图 10.20 – ColorUsage 使用

虽然我不会详尽地介绍每个属性,但我将提及其他一些属性以供您了解,并鼓励您根据需要探索和使用它们在代码中:

  • [RequireComponent(typeof(ComponentType))]: 当脚本附加时自动将指定的组件添加到 GameObject 中,确保所需组件始终存在。

  • [Range(min, max)]: 限制floatint属性的范围,在检查器视图中显示为滑块,以便轻松选择值。

  • [HideInInspector]: 完全隐藏属性从检查器视图中,确保用户不可见或编辑。

  • [TextArea(minLines, maxLines)]: 在检查器视图中渲染多行文本字段,用于字符串属性,具有可调整的最小和最大行数,以便更好地进行文本编辑。

  • [Tooltip("Tooltip Text")]: 在检查器视图中的属性上添加工具提示,当鼠标光标悬停在属性上时提供有用的信息。

  • [ReadOnly]: 在检查器视图中将属性渲染为只读,防止用户修改其值,但仍然显示它以供参考。

  • [Space(height)]: 在检查器视图中的属性之间插入指定高度(以像素为单位)的垂直空间,有助于视觉分离和可读性。

  • [Header("Section Name")]: 在检查器视图中创建带有标签的标题,用于分组相关的属性,增强组织和可读性。

  • [SerializeField]: 强制 Unity 序列化私有字段,使其在检查器视图中可见并可编辑,尽管其访问级别。

  • [ExecuteInEditMode]: 在编辑模式下执行脚本的代码,允许您在编辑器中执行操作或更新,而无需进入播放模式。

  • [Multiline(int lines)]: 指定字符串属性应在检查器视图中显示为多行文本区域,具有指定的行数用于文本输入。

  • [System.Flags]: 将枚举转换为检查器视图中的位掩码字段,允许您使用复选框同时选择多个枚举值。这对于一次性定义多个选项非常有用。

  • [Delayed]: 延迟更新属性值,直到用户完成在检查器视图中的编辑。这对于性能优化很有用。

  • [DisallowMultipleComponent]: 限制 GameObject 只能有一个组件实例,防止重复。

  • [SelectionBase]: 在选择子对象时突出显示层次结构视图中的 GameObject,使得在复杂场景中更容易识别。

  • [ExecuteAlways]: 强制脚本在编辑模式下执行其方法,允许在开发过程中获得即时反馈。

  • [SerializeReference]: 序列化引用类型属性,支持序列化对象中的多态性和继承。

  • [FormerlySerializedAs("OldName")]: 重命名序列化字段,而不会丢失其序列化数据。

  • [HelpURL("URL")]: 链接到与属性或组件相关的在线文档或资源。

  • [CanEditMultipleObjects]: 允许你在检查器视图中同时编辑具有相同组件的多个对象。

  • [RuntimeInitializeOnLoadMethod]: 标记一个在游戏开始或编辑器加载时执行的方法。

  • [ExecuteInEditMode, HelpURL("URL")]: 将ExecuteInEditMode与帮助 URL 结合,以便轻松访问文档。

通过掌握属性自定义,我们可以设计直观且高效的检查器界面,从而增强编辑体验并简化 Unity 中的工作流程。

场景 gizmos

Unity 中的场景 gizmos 是强大的工具,可以直接在场景视图中直观地表示游戏元素、调试信息和其他关键数据。通过添加自定义场景 gizmos,我们可以增强我们的工作流程,改进可视化,并简化调试过程。以下是一些重要的考虑因素:

  • 可视化表示:Gizmos 允许我们在场景视图中直观地表示游戏元素,如向量、光线、球体、立方体、线条等。这种可视化表示有助于您在运行时和在编辑器中理解对象的定位、朝向和行为。

  • 调试辅助工具:Gizmos 是强大的调试工具,帮助我们识别问题、可视化数据,并更有效地调试复杂系统。例如,我们可以使用 gizmos 来显示路径、边界、碰撞区域、触发区域和其他有助于调试和测试的临界信息。

  • 增强的工作流程:通过添加自定义的 gizmos,我们可以通过改进可视化、简化调试任务以及为游戏机制和交互提供有价值的见解来增强我们的工作流程。这种简化的工作流程可以在开发和测试阶段节省时间和精力。

  • 交互式反馈:Gizmos 可以在游戏和编辑器模式期间提供交互式反馈,允许我们动态调整参数、可视化更改,并在场景视图中直接微调游戏元素。这种交互式反馈循环促进了快速迭代和原型设计,从而带来更好的设计决策和优化的游戏体验。

让我们展示 Unity 中 gizmos 的一个应用实例。

在下面的代码块中,DisplayForwardDirection类展示了通过绘制光线来直观表示 GameObject 前向方向的方法:

using UnityEngine;
public class DisplayForwardDirection : MonoBehaviour
{
    [SerializeField]
    private Color gizmoColor = Color.blue; // Color for the arrow gizmo
    [SerializeField]
    private float gizmoSize = 1f; // Size of the arrow gizmo
    #if UNITY_EDITOR
    private void OnDrawGizmos()
    {
        // Set the gizmo color
        Gizmos.color = gizmoColor;
        // Calculate the forward direction in world space
        Vector3 forwardDirection = transform.TransformDirection(Vector3.forward) * gizmoSize;
        // Draw the arrow gizmo
        Gizmos.DrawRay(transform.position, forwardDirection);
    }
    #endif
}

现在,将DisplayForwardDirection脚本附加到您想要的 GameObject 上。之后,您可以自定义光线的颜色或大小。请参考图 20。21*来查看附加组件的说明:

图 10.21 – DisplayForwardDirection 脚本

图 10.21 – DisplayForwardDirection 脚本

图 20.22 显示了从显示的圆柱体延伸出的向前方向的射线:

图 10.22 – 延伸向前的射线

图 10.22 – 延伸向前的射线

总结来说,利用 Unity 中的自定义场景图示显著提高了开发工作流程,改善了调试能力,优化了游戏性能,并促进了团队成员之间更好的沟通和协作。有效地集成场景图示可以导致更高效的开发周期和更高质量的游戏。

通过将这些高级技术和工作流程集成到您的 Unity 项目中,您不仅可以扩展您的技能,还可以解锁创建引人入胜和沉浸式游戏体验的新可能性。

在游戏开发中导航复杂性通常涉及遇到故障排除和常见挑战,需要熟练的问题解决技能。我们将在下一节中考虑这一点。

故障排除和常见挑战

在本节中,我们将深入研究基本的调试技术和策略,以帮助您有效地识别和解决错误。此外,我们还将探讨特定平台挑战,重点关注移动优化、跨平台开发和针对不同游戏平台的复杂性。

让我们从调试开始,这是游戏开发中的一个基本技能,它允许开发者有效地识别和修复代码中的错误。

调试技术

在本小节中,我们将深入了解 Unity 中的调试工具和技术,让您掌握高效故障排除和解决代码问题的技能。

让我们先来探索不同的调试信息类型。有效地使用调试信息是一项宝贵的技能,它可以在故障排除和改进我们的代码方面给我们带来很大的好处。

以下代码块包含了在 Unity 中可以使用和自定义的四种主要类型的调试信息:

  Debug.Log("Info Message", gameObject); // Info level log
    Debug.LogWarning("Warning Message", gameObject); // Warning level log
    Debug.LogError("Error Message", gameObject); // Error level log
    Debug.LogException(new System.Exception("Custom Exception"), gameObject); // Exception level log

您可以将调试信息格式化,以便在文本中包含额外的信息。以下代码块显示了这一点:

Debug.LogFormat("[Scoreboard] Player score: {0:N0}", playerScore); // Custom log message with formatting for numbers
    Debug.LogFormat("[{0}] Game started at {1}", gameObject.name, System.DateTime.Now.ToString("HH:mm:ss")); // Custom log message with time stamp

您还可以使用颜色来格式化调试信息,如下面的代码块所示:

    Debug.Log("<color=green>[GameManager]</color> Game initialized successfully."); // Custom log message with color formatting

接下来,我们将了解如何在 Unity 中使用代码编辑器调试器来导航断点,这将使您能够有效地调试和解决项目中的错误。

代码编辑器调试器

让我们一步步地了解如何使用代码编辑器调试器,这样您就可以将以下技术应用到需要调试的代码中:

  1. 在您的代码面板的左侧放置断点,如图 图 10.23 所示:

图 10.23 – 添加断点

图 10.23 – 添加断点

  1. 然后,点击附加到 Unity,如图 图 10.24 所示。构建过程将开始,等待与 Unity 同步:

图 10.24 – 附加到 Unity 按钮

图 10.24 – 连接到 Unity 按钮

如果你第一次使用调试器,你会看到 图 10.25 中显示的消息。你可以点击 仅为此项目启用调试,或者点击 为所有项目启用调试 以在所有项目中启用它:

图 10.25 – 连接到 C#调试器

图 10.25 – 连接到 C#调试器

  1. 在编辑器中播放游戏,等待代码到达断点。游戏将暂停,编辑器将在断点位置打开。将鼠标悬停在行上以查看变量信息和条件,如图 图 10.26 所示:

图 10.26 – 鼠标悬停时显示的数据

图 10.26 – 鼠标悬停时显示的数据

  1. 审查数据后,你可以通过按下 继续 按钮来恢复你的会话,如图 图 10.27 所示。如果没有设置断点,游戏将按正常方式继续;然而,当遇到断点时,任何现有的断点都将再次执行:

图 10.27 – 继续按钮

图 10.27 – 继续按钮

你可以按照以下步骤使用游戏中的断点开始调试。这种方法对于显示有关自定义类和条件的综合信息以及显示断点行上的所有数据非常有用。此外,你可以在某些点使用 Step IntoStep Over 来全面了解特定代码块的功能。

异常处理和错误日志记录

异常处理涉及管理在运行时发生的意外错误或异常,防止崩溃,并确保应用程序的稳定性。Unity 提供了各种机制来优雅地处理异常,例如 try-catch 块、自定义错误日志记录和处理特定异常类型。

以下是对异常处理和错误日志记录的好处:

  • 防止崩溃:通过实现强大的异常处理,你可以在错误导致应用程序崩溃之前捕获和管理错误,从而提供更平滑、更可靠的用户体验

  • 优雅的错误处理:适当的异常处理允许你优雅地响应错误,向用户提供有意义的错误消息,并为调试目的记录详细的信息

  • 调试和故障排除:错误日志记录在调试和解决代码库中的问题时起着至关重要的作用,因为它提供了错误记录以及相关的上下文信息

让我们通过三个示例来演示这一点:

  • 示例 1 – 基本异常处理

    在下面的代码块中,我实现了一个基本的 try-catch 块来处理数字数组中的错误。然后我可以调试错误,了解其本质和解决方案:

    try
    {
        int[] numbers = { 1, 2, 3 };
        Debug.Log(numbers[5]); // Trying to access an out-of-bounds index
    }
    catch (Exception ex)
    {
        Debug.LogError("An error occurred: " + ex.Message); // Log the error message
    }
    
  • 示例 2 – 自定义 错误日志

    在下面的代码块中,我实现了一个自定义的 try-catch 日志记录方法:

    public class GameManager : MonoBehaviour
    {
        void Start()
        {
            try
            {
                // Code that may throw an exception
            }
            catch (Exception ex)
            {
                LogErrorToFile("GameManager", ex.Message); // Custom method to log errors to a file
            }
        }
        void LogErrorToFile(string context, string errorMessage)
        {
            // Code to log errors to a file or external logging system
            Debug.LogErrorFormat("[{0}] Error: {1}", context, errorMessage);
        }
    }
    

    在 catch 块中,调用了名为LogErrorToFile的自定义方法。此方法负责将错误消息记录到文件或外部日志系统中。在这个例子中,它使用 Unity 的Debug.LogErrorFormat方法以指定格式在控制台中记录错误消息。

  • 示例 3 – 处理 特定异常

    在下面的代码块中,我实现了一个特定的异常:

    void Start()
    {
        try
        {
            int result = DivideNumbers(10, 0); // Dividing by zero will throw a DivideByZeroException
            Debug.Log("Result: " + result);
        }
        catch (DivideByZeroException ex)
        {
            Debug.LogError("Division by zero error: " + ex.Message);
        }
    }
    int DivideNumbers(int a, int b)
    {
        if (b == 0)
        {
            throw new DivideByZeroException("Cannot divide by zero.");
        }
        return a / b;
    }
    

通过实施强大的异常处理和错误记录实践,如这些,您可以增强 Unity 应用程序的稳定性和可靠性,确保用户获得更平滑的游戏体验,并简化开发者的调试过程。

平台特定的挑战是游戏开发中的一个关键方面,需要专业知识以及战略性的考虑,以优化不同设备和平台上的性能。

平台特定的挑战

作为经验丰富的 Unity 开发者,我们了解平台特定优化带来的复杂挑战,尤其是在移动环境和跨平台开发场景中。

以下是一些关于移动优化的技巧和窍门:

  • 利用细节级别LOD)技术来管理复杂场景并提高移动设备上的性能

  • 实现动态批处理和静态批处理以减少绘制调用次数并优化移动平台上的渲染

  • 优化着色器复杂度并使用适合移动设备的着色器,以确保在各种移动硬件上实现流畅的性能

  • 实现性能优化,如对象池和纹理压缩

  • 利用 Unity 的 Profiler 和性能工具进行深入分析和优化

  • 利用 GPU 实例化和动态批处理,在移动设备上实现高效的渲染

  • 实现异步加载和资产流式传输,以无缝管理资源密集型场景

  • 利用 Unity 的 Addressable Asset 系统进行优化的资产管理以及动态内容加载

  • 通过使用如固定像素大小根据屏幕大小缩放等画布缩放模式来优化移动设备上的 UI 元素,以确保在不同屏幕分辨率上正确显示 UI 元素

  • 通过仅渲染相机视图内的对象来实现遮挡剔除,以优化渲染并提高复杂场景中的性能

  • 最小化动态光源的使用,并在可能的情况下使用烘焙光照,以减少 GPU 开销并提高移动平台上的性能

  • 通过使用音频池、流式音频片段和减少同时音频源的数量,实现高效的音频管理,以在移动设备上节省资源

  • 使用纹理图集和精灵打包将多个纹理合并到一个纹理图集中,以减少绘制调用次数并优化内存使用

  • 在目标移动设备上定期进行性能分析和测试,以识别瓶颈、优化关键区域,并确保在各种设备上实现流畅高效的游戏体验

以下是一些你应该考虑的跨平台开发策略:

  • 使用特定平台的编译指令来定制不同平台的行为

  • 设计模块化架构和功能标志,以适应平台变化而不损害代码库的完整性

  • 利用 Unity Cloud Build 和自动化测试框架以实现简化的部署和兼容性测试

  • 使用特定平台的编译指令(如 #if UNITY_IOS#if UNITY_ANDROID 等)来定制不同平台上的代码行为

  • 实施响应式 UI 设计原则,以创建在不同屏幕分辨率和宽高比上无缝工作的自适应用户界面

  • 实施开发在多个平台上无缝运行的游戏的高级方法,例如 PC、游戏机和移动设备,重点关注代码架构和特定平台的优化

  • 利用 Unity 的可寻址资产系统在不同平台上高效地管理和加载资产,允许动态更新内容,无需重新编译整个项目

  • 使用远程配置服务根据特定平台的要求或用户偏好动态调整游戏设置、功能和内容

  • 利用基于云的分析和监控工具收集不同平台上的实时性能数据和用户反馈,实现数据驱动的优化和更新

  • 实施本地化和国际化功能,以支持各种平台上的多种语言和文化偏好,增强全球可访问性和用户参与度

  • 实施特定平台的输入映射和控制定制选项,允许玩家根据他们偏好的设备和平台调整控制方案

  • 利用 Unity 的构建报告工具和性能分析功能来监控不同平台上的构建大小、内存使用、帧率和其他性能指标,从而实现数据驱动的优化

  • 实施特定平台的测试和 QA 流程,包括测试计划、设备兼容性测试和特定平台的错误跟踪,以识别和解决特定平台的问题,并确保所有平台上的高质量发布

通过掌握这些特定平台的挑战并实施高级优化技术,我们可以提供高质量的游戏,这些游戏在各种设备和平台上表现最佳,展示了我们作为 Unity 开发者的专业知识。

总结来说,掌握这些故障排除技术并掌握特定平台的考虑因素,将使你具备克服常见挑战的技能,并在不同平台上为玩家提供无缝和优化的游戏体验。

概述

在本章的结尾部分,我们探讨了使用 C# 的高级游戏开发技术和提高生产力的策略。我们首先介绍了 Unity 编辑器的基本快捷键和代码编辑器中的节省时间的快捷键。我们还深入探讨了预制件工作流程的优化、用于数据驱动开发的 ScriptableObjects 以及创建自定义编辑器以提升用户体验。此外,我们还掌握了调试工具,应对了特定平台挑战,如移动优化,并为有效的跨平台开发提供了见解。

随着本书的结束,我想向读者表达我衷心的感谢,感谢您与我们一同踏上这段学习之旅。请记住,本书的精髓不在于记忆特定的技术,而在于理解其背后的概念和原则。愿这些知识能够赋予您释放创造力并在游戏开发事业中创新的能量。继续拓展边界,探索新的可能性,为世界各地的玩家创造引人入胜的体验。感谢您成为这段旅程的一部分,并祝愿您在游戏开发事业中继续取得成功和满足。

第十二章:索引

由于本电子书版本没有固定的分页,以下页码仅作为参考,基于本书的印刷版。

符号

2D 相机 (CinemachineVirtualCamera) 72

A

Action Maps 61

自适应难度调整 121

AES 加密方法 177

人工智能逻辑 88

编码 89

探索 99-120

实现,使用 C# 88,89

亚马逊网络服务 (AWS) 223

分析 API 231

GameAnalytics 232

集成,使用 C# 231

关键方面 231

数组 158

音频压缩技术 260,261

音频分析器 244

每位付费用户的平均收入 (ARPPU) 231

B

后端服务

亚马逊网络服务 (AWS) 223

分析和洞察 223

数据存储 222

Firebase 223

集成,使用 C# 222

现场运营和内容管理 223

多玩家功能 223

PlayFab 223

PlayFab,集成 224-226

提供商 223

实时通信 223

样本登录系统,开发 227-230

用户身份验证 222

BaseView 类 149,150

UITween 组件 151

利用 151-153

行为模式 38

Flyweight 设计模式 41-44

观察者设计模式 38,44-48

单例设计模式 38-41

状态 38

策略 38

行为树

使用 263

二进制

反序列化 163, 164

序列化 163

瓶颈识别,配置过程 250

CPU 相关的问题 251, 252

GPU 相关的问题 252, 253

工作线程 252

主线程 252

渲染线程 252

构建大小缩减 264

C

C#

高级技术和工作流程 304

使用,以提升生产力快捷方式 294

驼峰命名法 48

集中式版本控制系统 (CVCS) 190

结构 190

工作 190, 191

挑战逻辑

实现 122-128

挑战 120

C# 实现 121

难度级别,平衡以吸引广泛受众 121

与任务/任务相关 120

Cinemachine 58, 69

利益 70

动态电影体验 78-82

游戏玩法动态,增强 75-78

使用,在游戏中 70-74

清洁代码 24

写作原则 25

写作 24

C# 命名规范 48

代码冲突管理

代码冲突起源,探索 194

冲突解决,在 Unity 项目中导航 194, 195

冲突解决,使用 CLI 导航 198-201

掌握 194

实践性冲突解决 195-197

代码编辑器调试器 317-319

代码格式化 48

评论 49

缩进 48

间距 48

编码规范

额外的最佳实践 49

最佳实践 48

类 48

代码格式化 48

错误处理和异常管理 49

有意义且描述性的名称 48

方法和类长度 49

方法 48

命名空间 48

变量 48

协作

最佳实践 192

分支,精通 193

策略,合并 193

碰撞层掩码 259, 260

内容分发网络 (CDNs) 223

协程优化 273, 274

CPU 性能分析模块 244, 245

图表类别 245, 246

详细信息面板 246

实时设置 247

创建型模式 37

工厂方法 37

对象池 37

单例 37

自定义数据结构 159

自定义编辑器 309

属性绘制,自定义 310-314

自定义保存系统 170

优势 181

功能 170-173

SaveManager 脚本 173-180

D

数据驱动游戏玩法,181

挑战系统 183-185

数据,为统计数据创建 181-183

数据组织和序列化 158

数据结构 158

数组 158

自定义数据结构 159

词典 159

列表 158

调试技术和策略 316, 317

代码编辑器调试器 317-319

错误日志记录 319, 320

异常处理 319-322

平台特定挑战 322, 323

依赖注入 (DI) 37, 40

利益 40

依赖倒置原则 (DIP) 34-36

景深 (DOF) 71

设计模式 37

行为型模式 38

创建型模式 37

结构型模式 37

词典 159

分布式版本控制系统 (DVCS) 188

结构 189

工作 189

绘制调用批处理方法

静态批处理 257

DRY 原则 53

动态批处理 257

E

事件系统,带有可脚本化的对象

利益 305

现有代码库 202

实践探索 202-205

可扩展标记语言 (XML) 158

F

视场 (FOV) 调整 70

Firebase 223

Flyweight 设计模式 41-43

缺点 44

优点 44

帧调试器 253

绘制调用批处理 257

动态批处理 257, 258

关键功能 254-256

工作 254

Freelook Camera (CinemachineFreeLook) 71

G

GameAnalytics 232

能力 232

功能 232

集成 232-237

使用示例 237, 238

游戏设计文档 (GDD) 4

元素 5-7

游戏机制 86

平衡 86

核心原则 86

反馈 86

玩家代理 87

关系,用代码表示 87

游戏性能

通过适当的数据结构选择增强 159, 160

Git 命令 191

GPU 分析器 244

图形用户界面 (GUI) 工具 192

H

HacknPlan 管理工具 19

支持敏捷方法论 19

示例 20, 21

游戏设计文档 20

与版本控制系统 (VCS) 集成 19

�看板板 19

路线图规划 20

任务管理 19

团队协作 19

时间跟踪 19

治愈方法 92

持续治愈方法 92

健康管理 88

高清渲染管线 (HDRP) 210

高动态范围 (HDR) 311

I

IDamage 接口 93

写作 89-92

IHealth 接口 93

集成,到玩家 90

写作 89

导入设置

动画类型设置为无 282, 283

关键帧减少 284

网格压缩 281

最佳压缩 283

优化网格设置 281

读写启用设置 281

初始化向量 (IVs) 178

接口分离原则 (ISP) 33, 34

J

JavaScript 对象表示法 (JSON) 158, 161

反序列化 161

序列化 161

K

关键性能指标 (KPIs) 231

KISS 原则 53

L

延迟补偿技术 262

细节级别 (LOD) 系统 267

Liskov 替换原则 (LSP) 31, 32

与开放-封闭原则 (OCP) 32 相比

列表 158

M

内存优化 274, 278, 280

模型导入 280-285

纹理导入 285-289

内存分析器 244, 274

安装 275, 276

关键功能 276

使用 276-279

工作状态 276

内存分析 274

考虑因素 279, 280

模型-视图-控制器 (MVC) 140

结构 141

模型-视图-视图模型 (MVVM) 140, 142

实现 153, 154

正确路径选择,针对 Unity UI 143, 144

结构 142, 143

UI 开发增强建议 144, 145

N

网络对象池 262

网络分析器 244

新输入系统 58-61

高级技术 68, 69

实现 61-68

交互 68

处理器 69

NewtonSoft 172

O

面向对象编程 (OOP) 25

对象池 269, 270

ObjectPoolManager

利用 270-273

观察者设计模式 44-47

构造 47

优点 47

漏洞消除 268, 269

开放-封闭原则 (OCP) 28-30

与 Liskov 替换原则 (LSP) 32 对比

P

PascalCase 48

性能优化技术 258

人工智能和路径查找 263

音频 260, 261

构建大小 264

网络和多人功能 262

物理和碰撞 259, 260

渲染 267-269

脚本 269, 270

UI 261

物理分析器 244

音高 4, 5

玩家行为设计 88

编码 89

实现 C# 88, 89

玩家体验 7

关键组件 7, 8

PlayerPrefs 166

替代方案 169

局限性 169

使用技巧 167-169

PlayFab 223

集成 224-226

预构建资产

利用 C# 210

预制件工作流程优化

组件属性,跨预制件实例更新 302-304

嵌套预制体 302

预制体 PrefabUtility 事件 302

预制体变体 301

带预制体的 ScriptableObjects 302

使用 C# 的生产力提升快捷键 294

预制体工作流程优化 301, 302

Unity 编辑器快捷键 294

Visual Studio 快捷键 298

分析器 242

音频分析器 244

CPU 分析器 244

功能 243

GPU 分析器 244

内存分析器 244

网络分析器 244

物理分析器 244

渲染分析器 244

UI 分析器 244

分析器标记

动画标记 249

后端脚本标记 249

仅编辑器标记 248

主线程基础标记 248

多线程标记 249

性能警告 249

物理标记 249

渲染和 VSync 标记 248

脚本更新标记 248

分析过程 250

瓶颈,识别 250

帧调试器 253

项目组织

项目结构 9

项目结构,高效开发

HacknPlan 管理工具 19-21

精通 9

命名规范 16

Unity 项目,组织 10

版本控制系统,使用 9

工作流程优化 16

R

重构技术 49

重复代码 52, 53

长且复杂的方法 50, 51

渲染分析器 244

奖励系统 121

C# 实现 121

探索 121

实现 128-132

S

保存和加载系统 166

SaveManager 脚本 173

场景小工具 314-316

ScoreManager 类 131

ScriptableObjects 164, 305-309

序列化 164-166

脚本可渲染管线 (SRP) 扩展 213

序列化 160, 161

SetMaxHealth 方法 92

射击机制 88

射击系统

实现 94-99

简化的碰撞检测 260

单一职责原则 (SRP) 25-28

单例设计模式 38

缺点 40

优点 40

结构 38, 39

SOLID 原则 25

依赖倒置原则 (DIP) 34-36

接口分离原则 (ISP) 33, 34

李斯克替换原则 (LSP) 31, 32

开放-封闭原则 (OCP) 28-30

单一职责原则 (SRP) 25-28

真实来源 (SoT) 191

Sprite Atlases 289

创建 289-292

StartHealingOverTime 方法 92

状态机,用于 AI 行为

具体状态 263

状态接口/类 263

状态机管理器 264

使用 263

静态批处理 257

结构模式 37

装饰者 37

Flyweight 37

Subversion (SVN) 191

T

TakeDamage 方法 92

纹理设置

压缩 288

Crunch 压缩 288

GenerateMipMaps 287

最大尺寸 288

读写操作 286

调整大小算法 288

U

UI 最佳实践

Canvases,分割 135-137

Canvas,隐藏 139

全屏 UI,有效处理 139

Graphic Raycaster,避免 137

Raycast Target,关闭 137,138

UI 元素动画,实现 139

UI 对象池,管理 138

UI 设计,游戏 134

UIManager 类 146-148

利用 148,149

UI 性能分析器 244

UI 系统界面

BaseView 类 149

创建 145

UIManager 类 146-148

UITween 组件 151

Unity 编辑器快捷键 294

GameObject 创建和管理快捷键 295-298

层级导航快捷键 294,295

Unity 插件 58

最佳实践 82, 83

Cinemachine 69

集成 59

新的输入系统 59-61

可选升级 58

Unity 性能分析工具 242

常见标记 248

CPU 性能分析模块 245

探索 242

性能分析器 242

Unity 项目

文件夹结构 11-16

组织 10

通用渲染管线(URP) 210

高级技术,实现 214,215-220

功能 213

功能 213

安装,到项目中 212,213

渲染流程功能 221

与 HDRP 对比 210,211

用户界面(UI) 192

V

版本控制系统,游戏项目 9

最佳实践 10

工作 10

版本控制系统(VCSs) 187,188

集中式 VCS 190,191

分布式 VCS 188,189

虚拟摄像机(CinemachineVirtualCamera) 71

Visual Studio 快捷键 298

代码生成快捷键 300,301

导航快捷键 298,299

重构快捷键 299,300

VSync(垂直同步) 250

W

工作流程优化,游戏项目 16

资产,分离 19

预设,使用 17

设置,应用预设 17,18

X

XML 162

反序列化 162,163

序列化 162

packtpub.com

订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及领先的行业工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。

第十三章:为什么订阅?

  • 使用来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,增加编码时间

  • 使用专为您量身定制的技能计划提高学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在packtpub.com升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。

www.packtpub.com,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

您可能还会喜欢以下书籍

如果您喜欢这本书,您可能会对 Packt 的以下其他书籍感兴趣:

通过 Unity 学习设计模式

哈里森·费罗尼

ISBN: 978-1-80512-028-5

  • 使用单例模式实现一个持久化的游戏管理器

  • 使用对象池来生成弹丸,以优化性能和内存使用

  • 使用工厂方法模式构建一个灵活的合成系统

  • 使用命令模式为玩家移动设计撤销/重做系统

  • 实现一个状态机来控制双人对战系统

  • 使用特殊能力修改现有的角色对象

通过 Unity 开发 C# 学习游戏

哈里森·费罗尼

ISBN: 978-1-83763-687-7

  • 通过将编程基础知识分解为其基本部分来理解编程原理

  • 对面向对象编程及其在 C#中的应用进行全面的解释,并附有示例代码

  • 按照简单的步骤和示例在 Unity 中创建和实现 C#脚本

  • 使用接口、抽象类和类扩展将代码划分为可插拔的构建块

  • 理解游戏设计文档的基础,然后进行关卡几何形状的布局,添加光照和简单的对象动画

  • 使用 C#创建基本的游戏机制,例如玩家控制器和射击弹丸

  • 熟悉栈、队列、异常处理和其他核心的 C#概念

  • 学习如何处理文本、XML 和 JSON 数据以保存和加载游戏数据

Packt 正在寻找像你这样的作者

如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发人员和科技专业人士合作,就像您一样,帮助他们将见解与全球科技社区分享。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或提交您自己的想法。

分享您的想法

现在您已经完成了《精通 Unity 游戏开发与 C#》,我们很乐意听听您的想法!如果您从亚马逊购买了本书,请点击此处直接进入本书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。

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

免费下载本书

感谢您购买本书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走?

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

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的无 DRM PDF 版本。

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

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

按照以下简单步骤获取收益:

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

二维码

packt.link/free-ebook/9781835466360

  1. 提交您的购买证明

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

目录

  1. 精通 Unity 游戏开发与 C#

  2. 贡献者

  3. 关于作者

  4. 关于审稿人

  5. 前言

    1. 本书面向的对象

    2. 本书涵盖的内容

    3. 如何充分利用本书

    4. 下载示例代码文件

    5. 使用的约定

    6. 联系我们

    7. 分享您的想法

    8. 免费下载本书的 PDF 版本

  6. 第一部分:游戏设计与项目结构

  7. 第一章:游戏设计与项目管理简介

    1. 技术要求

    2. GDD 简介

    3. 什么是 GDD?

      1. GDD 与提案:比较视角

      2. 提案:激发你对游戏概念的激情

      3. 理解 GDD 元素

    4. 关于玩家的体验如何?

    5. 让我们谈谈项目组织

      1. 掌握项目结构以实现高效开发
    6. 总结

  8. 第二章:为 Unity 游戏开发编写整洁和模块化的 C#代码

    1. 技术要求

    2. 编写整洁代码的介绍

    3. 编写整洁代码的原则

      1. 单一职责原则(SRP)

      2. 开放封闭原则(OCP)

      3. Liskov 替换原则(LSP)

      4. LSP 和 OCP 之间的区别是什么?

      5. 接口隔离原则(ISP)

      6. 依赖倒置原则(DIP)

    4. 理解游戏开发中的设计模式

      1. 创建型模式

      2. 结构型模式

      3. 行为模式

    5. 编码约定和最佳实践

      1. 重构技术
    6. 问题

    7. 总结

  9. 第二部分:Unity 高级 C#游戏开发技术

  10. 第三章:使用 Unity 插件扩展功能

    1. 技术要求

    2. 理解 Unity 插件

    3. 集成 Unity 插件

      1. 新输入系统

      2. Cinemachine

    4. 使用 Unity 插件的最佳实践

    5. 总结

  11. 第四章:在 Unity 中使用 C#实现引人入胜的游戏机制

    1. 技术要求

    2. 介绍游戏机制

      1. 游戏机制的基本原则

      2. 代码与游戏机制之间的关系

    3. 使用 C#实现玩家行为和 AI 逻辑

      1. 编写 IHealth 和 IDamage 接口

      2. 实现射击系统

      3. 深入 AI 逻辑

    4. 使用 C#实现挑战和奖励系统

      1. 挑战与任务/任务之间的区别

      2. 平衡难度级别以吸引更广泛的受众

      3. 探索奖励系统

      4. 挑战和奖励的 C#实现

    5. 总结

  12. 第五章:使用 C#为 Unity 游戏设计优化用户界面

    1. 技术要求

    2. 介绍游戏中的 UI 设计

    3. UI 的最佳实践和优化技术

      1. 拆分画布

      2. 避免过多的 Graphic Raycasters 并关闭 Raycast Target

      3. 高效管理 UI 对象池

      4. 正确隐藏画布的方法

      5. 为 UI 元素高效实现动画

      6. 有效处理全屏 UI

    4. 介绍架构模式(MVC 和 MVVM)

      1. 理解 MVC – 三角合作的角色

      2. 理解 MVVM – 视图和模型的混合

      3. 为 Unity UI 选择正确的路径

      4. 提升 UI 开发的实用建议

    5. 使用 C#创建 UI 系统

      1. UIManager 类

      2. BaseView 类

      3. 实现 MVVM

    6. 总结

  13. 第三部分:使用 C#在 Unity 中管理数据和代码协作

  14. 第六章:使用 C#在 Unity 中有效处理和管理游戏数据

    1. 技术要求

    2. 使用 C#进行数据组织和序列化

      1. 理解数据结构

      2. 通过适当的数据结构选择提高游戏性能

      3. Unity 中的序列化

    3. 使用 C#创建保存和加载系统

      1. PlayerPrefs

      2. 自定义保存系统

    4. 使用 C#进行数据驱动游戏

      1. 为统计数据创建数据

      2. 挑战系统

    5. 总结

  15. 第七章:使用 C#在 Unity 中为现有代码库做贡献

    1. 技术要求

    2. 介绍版本控制系统

      1. 理解版本控制系统
    3. 使用 C#协作和解决冲突

      1. 协作的最佳实践

      2. 掌握协作中的分支和合并

      3. 掌握代码冲突管理

    4. 理解现有代码库

      1. 对现有代码库的实际探索
    5. 摘要

  16. 第四部分:在 Unity 中使用 C#的高级集成和外部资源

  17. 第八章:在 Unity 中使用 C#实现外部资源、API 和预构建组件

    1. 技术要求

    2. 利用 C#预构建资源

      1. 通用渲染管线(URP)
    3. 使用 C#集成后端服务

      1. 后端服务
    4. 使用 C#集成分析 API

      1. 集成 GameAnalytics

      2. GameAnalytics 使用示例

    5. 摘要

  18. 第九章:使用 Unity 的 Profiler、帧调试器和内存分析器优化游戏

    1. 技术要求

    2. 介绍 Unity 性能分析工具

      1. 深入探索 Unity 的性能分析工具

      2. 理解性能分析过程

    3. 性能优化技术

      1. 物理和碰撞

      2. 音频

      3. 用户界面

      4. 网络和多人游戏

      5. 人工智能和路径查找

      6. 构建大小

      7. 渲染

      8. 脚本编程

    4. 内存管理和优化

      1. 内存分析器
    5. 摘要

  19. 第十章:Unity 中的技巧和窍门

    1. 技术要求

    2. 使用 C#提高生产力的快捷键

      1. Unity 编辑器快捷键

      2. Visual Studio 快捷键

      3. 预制件工作流程优化

    3. 使用 C#的高级技术和工作流程

      1. ScriptableObjects

      2. 自定义编辑器

    4. 故障排除和常见挑战

      1. 调试技术

      2. 特定平台挑战

    5. 摘要

  20. 索引

    1. 为什么订阅?
  21. 您可能喜欢的其他书籍

    1. Packt 正在寻找像您这样的作者

    2. 分享您的想法

    3. 免费下载此书的 PDF 副本

标记

  1. 封面

  2. 目录

  3. 索引

posted @ 2025-10-26 08:53  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报