Godot-游戏引擎项目-全-

Godot 游戏引擎项目(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是 Godot 游戏引擎及其新版本 3.0 的入门指南。Godot 3.0 拥有大量新功能和能力,使其成为比更昂贵的商业游戏引擎更强的替代品。对于初学者,它提供了一种友好的学习游戏开发技术的方法。对于更有经验的开发者,Godot 是一个强大、可定制的工具,可以将愿景变为现实。

本书将采用基于项目的方法。它由五个项目组成,将帮助开发者深入了解如何使用 Godot 引擎构建游戏。

本书面向的对象

这本书适合任何想要学习如何使用现代游戏引擎制作游戏的人。无论是新用户还是经验丰富的开发者,都会发现这是一本有用的资源。建议具备一些编程经验。

本书涵盖的内容

这本书是基于项目的 Godot 游戏引擎入门指南。五个游戏项目中的每一个都是在前一个项目中学习到的概念的基础上构建的。

第一章,简介,介绍了游戏引擎的概念,特别是 Godot,包括如何下载 Godot 并在您的计算机上安装它。

第二章,金币冲刺,处理一个小游戏,演示了如何创建场景以及与 Godot 的节点架构一起工作。

第三章,逃离迷宫,包含一个基于俯视迷宫游戏的项目,将展示如何使用 Godot 强大的继承特性和用于瓦片地图和精灵动画的节点。

第四章,太空岩石,演示了如何使用物理体创建类似《小行星》风格的太空游戏。

第五章,丛林跳跃,涉及一款类似《超级马里奥兄弟》的侧滚动平台游戏。你将了解运动学体、动画状态和视差背景。

第六章,3D 迷你高尔夫,将前面的概念扩展到三维空间。你将使用网格、光照和相机控制。

第七章,附加主题,涵盖了在掌握前几章材料后可以探索的更多主题。

为了充分利用本书

为了最好地理解本书中的示例代码,你应该具备编程的一般知识,最好是现代动态类型语言,如 Python 或 JavaScript。如果你完全是个编程新手,你可能希望在深入本书中的游戏项目之前,先回顾一下初学者 Python 教程。

Godot 可以在运行 Windows、macOS 或 Linux 操作系统的任何相对现代的 PC 上运行。您的显卡必须支持 OpenGL ES 3.0。

下载示例代码文件

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

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

  1. www.packtpub.com登录或注册。

  2. 选择支持选项卡。

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

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上github.com/PacktPublishing/Godot-Game-Engine-Projects/issues。如果代码有更新,它将在现有的 GitHub 仓库中更新。

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

下载彩色图像

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

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块应如下设置:

extends Area2D

export (int) var speed
var velocity = Vector2()
var screensize = Vector2(480, 720)

任何命令行输入或输出都应如下编写:

adb install dodge.apk

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“编辑器窗口的主要部分是 Viewport。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们欢迎读者的反馈。

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

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

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

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

评价

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

想了解更多关于 Packt 的信息,请访问 packtpub.com.

第一章:引言

不论是你的理想职业还是一种休闲爱好,游戏开发都是一项有趣且有益的活动。现在开始游戏开发从未有过更好的时机。现代编程语言和工具使得构建高质量游戏并将其分发到世界变得比以往任何时候都更容易。如果你正在阅读这本书,那么你已经踏上了实现你梦想中的游戏的道路。

本书是关于 Godot 游戏引擎及其新 3.0 版本的入门介绍,该版本于 2018 年初发布。Godot 3.0 拥有大量新特性和功能,使其成为昂贵商业游戏引擎的强大替代品。对于初学者来说,它提供了一种友好的方式来学习基础的游戏开发技术。对于更有经验的开发者来说,Godot 是一个强大、可定制且 开放 的工具,可以帮助你将你的想法变为现实。

本书采用基于项目的教学方法,将向你介绍引擎的基本原理。它由五个游戏组成,旨在帮助你获得对游戏开发概念及其在 Godot 中的应用的扎实理解。在这个过程中,你将学习 Godot 的工作原理,并吸收你可以应用到你的项目中的重要技术。

一般建议

本节包含了一些基于作者作为教师和讲师经验的读者一般建议。在阅读本书的过程中,请记住这些提示,尤其是如果你是编程新手的话。

尽量按照书中的项目顺序进行。后面的章节可能会基于前面章节中介绍的主题,在那里它们会得到更详细的解释。当你遇到你不记得的内容时,回到前面的章节去复习那个主题。没有人会给你计时,快速完成本书也没有奖品。

这里有很多内容需要吸收。如果你一开始没有理解,不要感到气馁。目标不是一夜之间成为游戏开发专家——这是不可能的。重复是学习复杂主题的关键;你越是用 Godot 的功能工作,它们就会开始显得越熟悉和容易。当你完成第七章,附加主题后,试着回顾第二章,金币冲刺。你会惊讶于与第一次阅读相比你理解了多少。

如果你正在使用本书的 PDF 版本,请抵制复制粘贴代码的诱惑。亲自输入代码将更多地调动你的大脑。这类似于在讲座中做笔记如何帮助你比单纯听讲学得更好,即使你从未阅读过笔记。如果你是打字速度慢的人,这也有助于你提高打字速度。总之:你是一名程序员,所以习惯于输入代码!

新游戏开发者犯的最大错误之一是承担超出自己能力范围的项目。在开始时,保持项目范围尽可能小非常重要。如果你完成两三个小型游戏,你将比拥有一个庞大且不完整的项目(这个项目已经超出了你的管理能力)更有成功的机会(并且学到更多)。

你会注意到这本书中的五个游戏非常严格地遵循这种策略。它们在范围上都较小,这不仅出于实际原因——以便合理地适应书本大小的课程——也是为了保持专注于教授基础知识。当你构建它们时,你可能会立即想到额外的功能和游戏元素。如果太空船有升级会怎样? 如果角色可以做墙壁跳跃会怎样?

理念很棒,但如果你还没有完成基本项目,就把它们记下来,留待以后。不要让自己被一个又一个“酷炫的想法”所分散。开发者称之为“功能蔓延”,这是一个导致许多游戏未完成的陷阱。不要成为它的受害者。

最后,别忘了时不时地休息一下。你不应该在几场阅读中就试图一口气读完整本书。在每个新概念之后,尤其是在每个章节之后,给自己一些时间来吸收新信息,然后再深入研究下一个。你会发现,你不仅能够记住更多信息,而且可能会更加享受这个过程。

什么是游戏引擎?

游戏开发复杂,涉及广泛的知识和技能。为了构建现代游戏,在制作实际游戏之前,你需要大量的底层技术。想象一下,在你开始编程之前,你必须自己构建电脑并编写自己的操作系统。如果你真的需要从头开始构建你需要的所有东西,游戏开发就会像那样。

此外,每个游戏都有一些共同的需求。例如,无论游戏是什么,它都需要在屏幕上绘制东西。如果已经编写了执行此操作的代码,那么在每次游戏中重新创建它而不是重用它更有意义。这就是游戏框架和引擎发挥作用的地方。

游戏框架是一组带有辅助代码的库,它帮助构建游戏的基础部分。它并不一定提供所有组件,你可能仍然需要编写大量代码来整合所有内容。正因为如此,使用游戏框架构建游戏可能比使用完整游戏引擎构建的游戏花费更多时间。

游戏引擎是一组工具和技术集合,旨在通过消除每个新游戏项目都需要“重新发明轮子”的需要来简化游戏制作过程。它提供了一组常用功能框架,通常需要大量时间来开发。

下面是游戏引擎将提供的一些主要功能列表:

  • 渲染(2D 和 3D):渲染是将你的游戏显示在玩家屏幕上的过程。一个好的渲染管线必须考虑到现代 GPU 支持、高分辨率显示器以及光照、透视和视口等效果,同时保持非常高的帧率。

  • 物理引擎:虽然这是一个非常常见的需求,但构建一个强大且精确的物理引擎是一项艰巨的任务。大多数游戏都需要某种形式的碰撞检测和响应系统,许多游戏还需要物理模拟,但很少有开发者愿意承担编写物理引擎的任务,尤其是如果他们曾经尝试过的话。

  • 平台支持:在当今的市场中,大多数开发者都希望能够在多个平台上发布他们的游戏,例如游戏机、移动设备、PC 和/或网页。游戏引擎提供了一个统一的导出过程,可以在多个平台上发布游戏,而无需重写游戏代码或支持多个版本。

  • 通用开发环境:通过使用相同的统一界面来制作多个游戏,你不必每次开始新项目时都重新学习新的工作流程。

此外,还将提供辅助工具,以支持诸如网络、简化图像和声音处理、动画、调试、关卡创建等功能。通常,游戏引擎会包括从其他工具导入内容的能力,例如用于创建动画或 3D 模型的工具。

使用游戏引擎可以让开发者专注于构建他们的游戏,而不是创建使游戏运行所需的所有底层框架。对于小型或独立开发者来说,这意味着在开发一年后发布游戏与三年后发布,甚至根本无法发布之间的区别。

目前市场上有很多流行的游戏引擎,例如 Unity、Unreal Engine 和 GameMaker Studio,仅举几个例子。需要了解的一个重要事实是,大多数流行的游戏引擎都是商业产品。它们可能或可能不需要任何财务投资就可以开始,但如果你的游戏盈利,它们将需要某种形式的许可和/或版税支付。无论你选择哪个引擎,你都需要仔细阅读用户协议,并确保你理解你可以和不可以使用该引擎,以及你可能需要承担的任何潜在费用。

另一方面,也有一些非商业的、开源的游戏引擎,例如 Godot 游戏引擎,这正是本书的主题。

什么是 Godot?

Godot 是一个功能齐全的现代游戏引擎,提供了上一节中描述的所有功能以及更多。它也是完全免费和开源的,在非常宽松的 MIT 许可下发布。这意味着没有任何费用、隐藏成本或需要支付版税。你用 Godot 制作的一切 100%属于你,而许多要求持续合同关系的商业游戏引擎则不是这样。对于许多开发者来说,这一点非常有吸引力。

如果你不太熟悉开源、社区驱动的开发概念,这可能会让你觉得有些奇怪。然而,与 Linux 内核、Firefox 浏览器以及许多其他非常著名的软件一样,Godot 并不是由公司作为商业产品来开发的。相反,一群热心的开发者们无私地贡献他们的时间和专业知识,用于构建引擎、测试和修复错误、制作文档等等。

作为游戏开发者,使用 Godot 的好处是巨大的。因为它不受商业许可的束缚,你可以完全控制你的游戏如何以及在哪里分发。许多商业游戏引擎限制了你可以制作的项目类型,或者要求支付更昂贵的许可费用来制作某些类别的游戏,例如赌博游戏。

Godot 的开源性质还意味着它具有商业游戏引擎所不具备的透明度。例如,如果你发现某个特定的引擎功能并不完全符合你的需求,你可以自由地修改引擎本身并添加你需要的新功能,无需任何许可。这在进行大型项目的调试时也非常有帮助,因为你可以完全访问引擎的内部工作。

这也意味着你可以直接为 Godot 的未来做出贡献。参见第七章,附加主题,了解更多关于如何参与 Godot 开发的信息。

下载 Godot

你可以通过访问godotengine.org/并点击下载来获取 Godot 的最新版本。本书是为 3.0 版本编写的。如果你下载的版本末尾有另一个数字(如 3.0.3),那没关系——这仅仅意味着它包含了修复 3.0 版本错误或其他问题的更新。

目前正在开发 3.1 版本,在你阅读这本书的时候可能已经发布。这个版本可能包含或不包含与本书代码不兼容的更改。请查看本书的 GitHub 仓库以获取信息和勘误:github.com/PacktPublishing/Godot-Game-Engine-Projects

在下载页面,有几个选项需要解释。首先,32 位与 64 位:此选项取决于你的操作系统和你的计算机处理器。如果你不确定,你应该选择 64 位版本。你还会看到一个Mono 版本。这是一个专门为与 C# 编程语言一起使用而构建的版本。除非你计划使用 C# 与 Godot 一起使用,否则不要下载此版本。在撰写本文时,C# 支持仍然是实验性的,不建议初学者使用。

双击下载的文件以解压,你将得到 Godot 应用程序。如果你有“程序”或“应用程序”文件夹,可以选择将其拖放到那里。双击应用程序以启动它,你将看到 Godot 的项目经理窗口。

其他安装方法

除了从 Godot 网站下载之外,还有几种方法可以将 Godot 安装到你的计算机上。请注意,以这种方式安装时功能没有差异。以下仅是下载应用程序的替代方法:

  • Steam:如果你在 Steam 上有账户,可以通过 Steam 桌面应用程序安装 Godot。在 Steam 商店中搜索 Godot,并按照说明进行安装。你可以从 Steam 应用程序中启动 Godot,它甚至还会跟踪你的游戏时间

  • 包管理器:如果你使用以下操作系统的包管理器之一,你可以通过其正常安装过程安装 Godot。有关详细信息,请参阅你的包管理器文档。Godot 可在这些包管理器中使用:

  • Homebrew(macOS)

  • Scoop(Windows)

  • 快照(Linux)

Godot UI 概览

与大多数游戏引擎一样,Godot 有一个统一的开发环境。这意味着你使用相同的界面来处理游戏的所有方面——代码、视觉效果、音频等等。本节是关于界面及其部分的介绍。请注意这里使用的术语;在本书中提及编辑器窗口中的操作时,将使用这些术语。

项目管理器

当你打开 Godot 时,首先看到的是项目经理

图片

在此窗口中,你可以看到你现有的 Godot 项目列表。你可以选择一个现有项目并点击“运行”来玩游戏,或者点击“编辑”在 Godot 编辑器中工作(参考以下截图)。你也可以通过点击“新建项目”来创建一个新项目:

图片

在这里,你可以为项目命名并创建一个文件夹来存储它。始终尝试选择一个描述项目的名称。同时请注意,不同的操作系统在处理文件名中的大小写和空格方面有所不同。为了最大兼容性,最好坚持使用小写字母并使用下划线_代替空格。

注意警告信息——在 Godot 中,每个项目都作为计算机上单独的文件夹存储。项目使用的所有文件都存放在这个文件夹中。项目文件夹之外的内容在游戏中将不可访问,因此你需要将任何图像、声音、模型或其他数据放入项目文件夹中。这使得共享 Godot 项目变得方便;你只需要压缩项目文件夹,就可以有信心其他 Godot 用户能够打开它,并且不会缺少任何必要的数据。

选择文件名

当你给你的新项目命名时,你应该遵循一些简单的规则,这可能会在未来为你节省一些麻烦。给你的项目起一个描述性的名字——巫师战斗竞技场游戏#2要好得多。在未来,你永远也记不住哪个游戏#2 是哪个,所以尽可能描述得详细。

你还应该考虑如何命名你的项目文件夹和其中的文件。一些操作系统是大小写敏感的,区分My_Gamemy_game,而其他则不是。如果你将项目从一个电脑移动到另一个电脑,这可能会导致问题。因此,许多程序员为他们的项目制定了一个标准化的命名方案,例如:文件名中不包含空格,单词之间使用"_"分隔。无论你采用什么命名方案,最重要的是保持一致性。

一旦你创建了项目文件夹,创建与编辑按钮将会在编辑器窗口中打开新的项目。

现在试试:创建一个名为test_project的项目。

如果你正在使用 Windows 操作系统的某个版本,当你运行 Godot 时,你也会看到一个控制台窗口打开。在这个窗口中,你可以看到由引擎和/或你的项目产生的警告和错误。在 macOS 或 Linux 下,这个窗口不会出现,但如果你使用终端程序从命令行启动应用程序,你可以看到控制台输出。

编辑器窗口

以下是 Godot 主编辑器窗口的截图。这是你在 Godot 中构建项目时将花费大部分时间的地方。编辑器界面分为几个部分,每个部分提供不同的功能。每个部分的特定术语如下所述:

图片

Godot 编辑器窗口

编辑器窗口的主要部分是视口。这是你在工作时将看到游戏部分的地方。

在左上角是主菜单,你可以在这里保存和加载文件,编辑项目设置,以及获取帮助。

在顶部中央是一个你可以切换到不同游戏部分时的工作空间列表。你可以切换到 2D 和 3D 模式,以及脚本模式,在那里你可以编辑游戏代码。AssetLib 是一个你可以下载插件和示例项目的地方。有关使用 AssetLib 的更多信息,请参阅第七章,附加主题。参考以下截图:

图片

以下截图显示了工具栏上的工作空间按钮。工具栏中的图标将根据你正在编辑的对象类型而改变。同样,底部面板中的项目也会改变,它将打开各种小窗口以访问特定信息,如调试、音频设置等:

图片

上右角的“Playtest”区域中的按钮用于启动游戏并在运行时与之交互:

图片

最后,在左侧和右侧是你可以用来查看和选择游戏项目及其属性的坞站。左侧坞站包含文件系统标签:

图片

项目文件夹内的所有文件都显示在这里,你可以点击文件夹以打开它们并查看它们包含的内容。你的项目中的所有资源都将位于res://的相对位置,这是项目的根文件夹。例如,文件路径可能看起来像这样:res://player/Player.tscn

在右侧的坞站中,你可以看到几个标签。场景标签显示了你在视图中正在工作的当前场景。在其下面的检查器标签中,你可以查看和调整所选任何对象的属性。参考以下截图:

图片

选择“导入”选项卡并在“文件系统”选项卡中点击一个文件,你可以调整 Godot 导入资源的方式,如以下截图所示:

图片

在本书中通过游戏项目进行工作,你会了解这些项目的功能,并熟悉导航编辑器界面。然而,在开始之前,你还需要了解一些其他概念。

关于节点和场景

节点是 Godot 中创建游戏的基本构建块。节点是一个可以代表各种专用游戏功能的对象。给定类型的节点可能显示图形、播放动画或表示对象的 3D 模型。节点还包含一组属性,允许你自定义其行为。你添加到项目中的节点取决于你需要的功能。这是一个模块化系统,旨在在构建游戏对象时为你提供灵活性。

在你的项目中,你添加的节点组织成一个结构。在树中,节点被添加为其他节点的子节点。一个特定的节点可以有任意数量的子节点,但只有一个父节点。当一组节点被收集到一个树中时,它被称为场景,而树被称为场景树

Godot 中的场景通常用于创建和组织项目中的各种游戏对象。你可能有一个包含所有使玩家角色工作的节点和脚本的玩家场景。然后,你可能创建另一个场景来定义游戏的地图:玩家必须导航的障碍和物品。然后,你可以使用实例化将这些各种场景组合成最终游戏,你将在后面的学习中了解到这一点。

虽然节点具有各种属性和函数,但任何节点的行为和能力也可以通过将脚本附加到节点来扩展。这允许你编写代码,使节点能够执行其默认状态之外的操作。例如,你可以在场景中添加一个 Sprite 节点来显示图像,但如果你想让该图像在点击时移动或消失,你需要添加一个脚本来创建这种行为。

Godot 中的脚本

在撰写本文时,Godot 为脚本节点提供了三种官方语言:GDScript、VisualScript 和 C#。GDScript 是专用内置语言,提供与引擎最紧密的集成,并且使用起来最直接。VisualScript 仍然非常新,处于测试阶段,在你对 Godot 的工作原理有良好理解之前应避免使用。对于大多数项目,C#最好保留在游戏中有特定性能需求的部分;大多数 Godot 项目不需要这种级别的额外性能。对于确实需要这种性能的项目,Godot 提供了灵活性,可以在需要的地方使用 GDScript 和 C#的组合。

除了支持的三种脚本语言之外,Godot 本身是用 C++编写的,你可以通过直接扩展引擎的功能来获得更多的性能和控制。有关使用其他语言和扩展引擎的信息,请参阅第七章,附加主题

本书中的所有游戏都使用 GDScript。对于大多数项目来说,GDScript 是最佳的语言选择。它与 Godot 的应用程序编程接口API)紧密集成,并专为快速开发设计。

关于 GDScript

GDScript 的语法非常接近 Python 语言。如果你已经熟悉 Python,你会发现 GDScript 非常熟悉。如果你对其他动态语言,如 JavaScript,感到舒适,你应该会发现学习它相对容易。Python 经常被推荐为一种良好的入门语言,GDScript 也具有这种用户友好性。

本书假设你已经有一些编程经验。如果你之前从未编码过,你可能觉得这有点困难。学习游戏引擎本身就是一项艰巨的任务;同时学习编码意味着你接受了一个巨大的挑战。如果你发现自己在这本书的代码中遇到困难,你可能发现通过完成一个入门级的 Python 课程将帮助你掌握基础知识。

与 Python 一样,GDScript 是一种 动态类型 语言,这意味着在创建变量时不需要声明其类型,并且它使用 空白字符(缩进)来表示代码块。总的来说,使用 GDScript 为你的游戏逻辑编写代码意味着你写的代码更少,这意味着开发速度更快,需要修复的错误也更少。

为了让你了解 GDScript 的样子,这里有一个小脚本,它可以使精灵以给定的速度在屏幕上从左到右移动:

extends Sprite

var speed = 200

func _ready():
    position = Vector2(100, 100)

func _process(delta):
    position.x += speed * delta
    if position.x > 500:
        position.x = 0

如果你现在还不理解,不要担心。在接下来的章节中,你将编写大量的代码,这些代码将伴随着如何工作的解释。

摘要

在本章中,你了解了游戏引擎的一般概念,特别是 Godot。最重要的是,你下载了 Godot 并启动了它!

你学习了一些重要的词汇,这些词汇将在本书中用来指代 Godot 编辑器窗口的各个部分。你还了解了节点和场景的概念,它们是 Godot 的基本构建模块。

此外,你还得到了一些关于如何处理本书中的项目和游戏开发一般性建议。如果你在阅读本书的过程中感到沮丧,请返回并重新阅读 一般性建议 部分。有很多东西要学习,第一次可能不会全部理解。本书过程中,你将制作五款不同的游戏,每一款都会帮助你更好地理解。

你已经准备好进入 第二章,金币冲刺,在那里你将开始使用 Godot 构建你的第一个游戏。

第二章:Coin Dash

这个第一个项目将引导你完成你的第一个 Godot 引擎项目。你将学习 Godot 编辑器的工作方式,如何构建项目结构,以及如何制作一个小型 2D 游戏。

为什么是 2D?简而言之,3D 游戏比 2D 游戏复杂得多,而你需要了解的许多底层游戏引擎功能是相同的。你应该坚持 2D,直到你对 Godot 的游戏开发过程有很好的理解。到那时,转向 3D 将容易得多。这本书的第五个和最后一个项目将介绍 3D。

重要——即使你不是游戏开发的完全新手,也不要跳过这一章。虽然你可能已经理解了许多底层概念,但这个项目将介绍许多基本的 Godot 功能和设计范式,这些是你今后需要了解的。随着你开发更复杂的项目,你将在此基础上构建这些概念。

本章中的游戏被称为Coin Dash。你的角色必须在屏幕上移动,尽可能多地收集硬币,同时与时间赛跑。完成游戏后,游戏将看起来像这样:

项目设置

启动 Godot 并创建一个新项目,确保使用创建文件夹按钮来确保此项目的文件将与其他项目分开保存。你可以在此处下载游戏的艺术和声音(统称为资产)的 Zip 文件,github.com/PacktPublishing/Godot-Game-Engine-Projects/releases

将此文件解压到你的新项目文件夹中。

在这个项目中,你将制作三个独立的场景:PlayerCoinHUD,它们都将组合到游戏的Main场景中。在一个更大的项目中,创建单独的文件夹来保存每个场景的资源和脚本可能很有用,但在这个相对较小的游戏中,你可以在根文件夹中保存你的场景和脚本,该文件夹被称为res://res资源的缩写)。你的项目中的所有资源都将位于res://文件夹的相对位置。你可以在左上角的文件系统窗口中查看你的项目文件夹:

例如,硬币的图片将位于res://assets/coin/

这个游戏将使用竖屏模式,因此你需要调整游戏窗口的大小。点击项目菜单并选择项目设置,如下面的截图所示:

查找显示/窗口部分,并将宽度设置为480,高度设置为720。在此部分中,还将拉伸/模式设置为2D,并将纵横比设置为保持。这将确保如果用户调整游戏窗口的大小,所有内容都将适当地缩放,而不会拉伸或变形。如果你喜欢,你也可以取消选中可调整大小的复选框,以防止窗口完全调整大小。

向量和 2D 坐标系

注意:本节是 2D 坐标系的一个非常简短的概述,并没有深入探讨向量数学。它旨在为 Godot 游戏开发提供一个高级概述。向量数学是游戏开发中的基本工具,因此如果你需要对此主题有更广泛的理解,请参阅可汗学院的线性代数系列(www.khanacademy.org/math/linear-algebra)。

在 2D 工作中,你将使用笛卡尔坐标系来识别空间中的位置。2D 空间中的特定位置可以表示为一对值,例如(4,3),分别代表沿x轴和y轴的位置。2D 平面上的任何位置都可以用这种方式描述。

在 2D 空间中,Godot 遵循常见的计算机图形学惯例,将x轴向右,y轴向下:

图片 2

如果你刚开始接触计算机图形学或游戏开发,可能会觉得正 y 轴向下而不是向上有点奇怪,正如你可能在数学课上所学的。然而,这种方向在计算机图形学应用中非常常见。

向量

你也可以将位置(4, 3)视为从(0, 0)点或原点的偏移。想象一支箭从原点指向该点:

图片 1

这支箭是一个向量。它代表了许多有用的信息,包括点的位置,(4, 3),其长度,m,以及其与x-轴的夹角,θ。总的来说,这是一个位置向量,换句话说,它描述了空间中的位置。向量也可以表示运动、加速度或任何具有xy分量的其他量。

在 Godot 中,向量(2D 中的Vector2或 3D 中的Vector3)被广泛使用,你将在本书构建的项目过程中使用它们。

像素渲染

Godot 中的向量坐标是浮点数,而不是整数。这意味着Vector2可以有一个分数值,例如(1.5, 1.5)。由于对象不能在半像素处绘制,这可能会为像素艺术游戏带来视觉问题,其中你希望确保所有纹理的像素都被绘制。

为了解决这个问题,打开项目 | 项目设置,在侧边栏中找到渲染/*质量部分并启用使用像素捕捉,如图下截图所示:

图片 3

如果你在游戏中使用 2D 像素艺术,那么在开始项目时始终启用此设置是个好主意。在 3D 游戏中,此设置没有任何效果。

第一部分 – 玩家场景

你将制作的第一个场景定义了玩家对象。创建单独的玩家场景的一个好处是,你可以在创建游戏的其它部分之前独立测试它。随着你的项目规模和复杂性的增长,这种游戏对象的分离将变得越来越有帮助。将单个游戏对象与其他对象保持分离,使它们更容易调试、修改,甚至完全替换而不影响游戏的其它部分。这也使你的玩家可重用——你可以将玩家场景放入一个完全不同的游戏中,它将正常工作。

玩家场景将显示你的角色及其动画,通过响应用户输入相应地移动角色,并检测与游戏中的其他对象的碰撞。

创建场景

首先,点击添加/创建新节点按钮并选择一个 Area2D。然后,点击其名称并将其更改为 Player。点击场景 | 保存场景以保存场景。这是场景的 或顶级节点。你将通过向此节点添加子节点来为 Player 添加更多功能:

在添加任何子节点之前,确保你不小心通过点击它们来移动或调整它们的大小。选择 Player 节点并点击旁边的锁图标:

工具提示将显示确保对象的孩子不可选择,如前面的截图所示。

在创建新场景时始终这样做是个好主意。如果一个身体的碰撞形状或精灵偏移或缩放,可能会导致意外的错误并且难以修复。使用此选项,节点及其所有子节点将始终一起移动。

精灵动画

使用 Area2D,你可以检测其他对象何时与玩家重叠或碰撞,但 Area2D 本身没有外观,因此点击 Player 节点并添加一个作为子节点的 AnimatedSprite 节点。AnimatedSprite 将处理玩家的外观和动画。注意,节点旁边有一个警告符号。AnimatedSprite 需要一个 SpriteFrames 资源,其中包含它可以显示的动画。要创建一个,在检查器中找到 Frames 属性并点击 | 新建 SpriteFrames:

接下来,在相同的位置,点击 打开 SpriteFrames 面板:

在左侧是一个动画列表。点击默认的动画并将其重命名为 run。然后,点击 添加 按钮创建第二个名为 idle 的动画和第三个名为 hurt 的动画。

在左侧的文件系统工具栏中,找到 runidlehurt 玩家图像并将它们拖入相应的动画中:

每个动画都有一个默认的每秒 5 帧的速度设置。这有点慢,所以点击每个动画并将速度(FPS)设置更改为 8。在检查器中,勾选 Playing 属性旁边的复选框并选择一个动画来查看动画效果:

之后,你将编写代码来根据玩家的动作选择这些动画。但首先,你需要完成设置玩家的节点。

碰撞形状

当使用Area2D或 Godot 中的其他碰撞对象时,它需要定义一个形状,否则无法检测碰撞。碰撞形状定义了对象占据的区域,并用于检测重叠和/或碰撞。形状由Shape2D定义,包括矩形、圆形、多边形和其他类型的形状。

为了方便起见,当你需要向区域或物理体添加形状时,你可以添加一个CollisionShape2D作为子节点。然后选择你想要的形状类型,你可以在编辑器中编辑其大小。

将一个CollisionShape2D作为Player的子节点(确保不要将其作为AnimatedSprite的子节点)。这将允许你确定玩家的击打框,或其碰撞区域的边界。在检查器中,在形状旁边点击并选择 New RectangleShape2D。调整形状的大小以覆盖精灵:

请注意不要缩放形状的轮廓!仅使用大小手柄(红色)来调整形状!缩放后的碰撞形状将无法正确工作。

你可能已经注意到碰撞形状没有在精灵上居中。这是因为精灵本身在垂直方向上没有居中。我们可以通过向AnimatedSprite添加一个小偏移量来修复这个问题。点击节点并在检查器中查找 Offset 属性。将其设置为(0, -5)

当你完成时,你的Player场景应该看起来像这样:

编写玩家脚本

现在,你准备好添加脚本了。脚本允许你添加内置节点所不具备的额外功能。点击Player节点并点击添加脚本按钮:

在脚本设置窗口中,你可以保留默认设置不变。如果你已经记得保存场景(参见前面的截图),脚本将自动命名为与场景名称匹配。点击创建,你将被带到脚本窗口。你的脚本将包含一些默认注释和提示。你可以删除注释(以#开头的行)。参考以下代码片段:

extends Area2D

# class member variables go here, for example:
# var a = 2
# var b = "textvar"

func _ready():
 # Called every time the node is added to the scene.
 # Initialization here
 pass

#func _process(delta):
# # Called every frame. Delta is time since last frame.
# # Update game logic here.
# pass

每个脚本的第一个行将描述它附加到的节点类型。接下来,你将定义你的类变量:

extends Area2D

export (int) var speed
var velocity = Vector2()
var screensize = Vector2(480, 720)

speed变量上使用export关键字允许你在检查器中设置其值,同时让检查器知道变量应包含的数据类型。这对于你想要能够调整的值非常有用,就像调整节点的内置属性一样。点击Player节点并将 Speed 属性设置为 350,如图所示:

velocity将包含角色的当前移动速度和方向,而screensize将用于设置玩家的移动限制。稍后,游戏的主场景将设置此变量,但现在你将手动设置它以便测试。

移动玩家

接下来,你将使用_process()函数来定义玩家将做什么。_process()函数在每一帧都会被调用,所以你会用它来更新你预期经常更改的游戏元素。你需要玩家做三件事:

  • 检查键盘输入

  • 按照给定的方向移动

  • 播放适当的动画

首先,你需要检查输入。对于这个游戏,你有四个方向输入需要检查(四个箭头键)。输入操作在项目设置下的输入映射标签中定义。在这个标签中,你可以定义自定义事件并将不同的键、鼠标操作或其他输入分配给它们。默认情况下,Godot 已经将事件分配给了键盘箭头,所以你可以使用它们在这个项目中。

你可以使用Input.is_action_pressed()检测是否按下了输入,如果按键被按下则返回true,如果没有则返回false。结合所有四个按钮的状态将给出运动的结果方向。例如,如果你同时按下rightdown,则结果速度向量将是(1, 1)。在这种情况下,因为我们正在将水平和垂直运动结合起来,所以玩家会比仅水平移动时移动得更快。

你可以通过归一化速度来防止这种情况,这意味着将其长度设置为1,然后乘以期望的速度:

func get_input():
    velocity = Vector2()
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed

通过将所有这些代码组合在一个get_input()函数中,你可以使后续更改变得更加容易。例如,你可以决定改为使用模拟摇杆或其他类型的控制器。从_process()函数中调用此函数,然后通过结果velocity更改玩家的position。为了防止玩家离开屏幕,你可以使用clamp()函数将位置限制在最小和最大值之间:

func _process(delta):
    get_input()

    position += velocity * delta
    position.x = clamp(position.x, 0, screensize.x)
    position.y = clamp(position.y, 0, screensize.y)

点击“播放编辑的场景”(F6)并确认你可以按所有方向移动玩家。

关于 delta

_process()函数包含一个名为delta的参数,然后将其乘以速度。delta是什么?

游戏引擎试图以每秒 60 帧的速率一致运行。然而,这可能会因为 Godot 或计算机本身的减速而改变。如果帧率不一致,那么它将影响你的游戏对象的移动。例如,考虑一个设置为每帧移动10像素的对象。如果一切运行顺利,这将转化为在一秒内移动600像素。然而,如果其中一些帧耗时更长,那么那一秒可能只有 50 帧,所以对象只移动了500像素。

Godot,就像大多数游戏引擎和框架一样,通过传递给你 delta 来解决这个问题,这是自上一帧以来经过的时间。大多数情况下,这将是大约 0.016 秒(或大约 16 毫秒)。如果你然后将你的期望速度(600 px/s)乘以 delta,你将得到精确的 10 像素移动。然而,如果 delta 增加到 0.3,则对象将被移动 18 像素。总的来说,移动速度保持一致,且与帧率无关。

作为一项额外的好处,你可以用 px/s 而不是 px/frame 的单位来表示你的移动,这更容易可视化。

选择动画

现在玩家可以移动了,你需要根据玩家是移动还是静止来更改 AnimatedSprite 播放的动画。run 动画的美术面向右侧,这意味着它应该使用翻转水平属性(使用 Flip H 属性)来翻转,以便向左移动。将以下内容添加到你的 _process() 函数末尾:

    if velocity.length() > 0:
        $AnimatedSprite.animation = "run"
        $AnimatedSprite.flip_h = velocity.x < 0
    else:
        $AnimatedSprite.animation = "idle"

注意,这段代码采取了一些捷径。flip_h 是一个布尔属性,这意味着它可以设置为 truefalse。布尔值也是比较操作(如 <)的结果。正因为如此,我们可以将属性设置为比较操作的结果。这一行代码等同于以下这样写:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false     

再次播放场景并检查每种情况下动画是否正确。确保在 AnimatedSprite 中将 Playing 设置为 On,以便动画可以播放。

开始和结束玩家的移动

当游戏开始时,主场景需要通知玩家游戏已经开始。添加以下 start() 函数,主场景将使用它来设置玩家的起始动画和位置:

func start(pos):
    set_process(true)
    position = pos
    $AnimatedSprite.animation = "idle"

当玩家撞击障碍物或用完时间时,将调用 die() 函数:

func die():
    $AnimatedSprite.animation = "hurt"
    set_process(false)

设置 set_process(false) 将导致 _process() 函数不再为该节点调用。这样,当玩家死亡时,他们就不能通过按键输入移动了。

准备碰撞

玩家应该检测到它撞击硬币或障碍物时,但你还没有让他们这样做。没关系,因为你可以使用 Godot 的 信号 功能来实现这一点。信号是节点发送消息的方式,其他节点可以检测并响应这些消息。许多节点都有内置的信号,例如在身体碰撞时或按钮被按下时发出警报。你还可以定义自定义信号以供自己的用途。

通过 连接 信号到你想监听和响应的节点,使用信号。这种连接可以在检查器或代码中完成。在项目后期,你将学习如何以这两种方式连接信号。

将以下内容添加到脚本顶部(在 extends Area2D 之后):

signal pickup
signal hurt

这些定义了玩家在触摸硬币或障碍物时将发出(发送)的自定义信号。触摸将由Area2D本身检测。选择Player节点并点击检查器旁边的节点标签页,以查看玩家可以发出的信号列表:

图片

注意你的自定义信号也在那里。由于其他对象也将是Area2D节点,你想要area_entered()信号。选择它并点击连接。在连接信号窗口中点击连接 – 你不需要更改任何设置。Godot 将自动在你的脚本中创建一个名为_on_Player_area_entered()的新函数。

当连接一个信号时,你不仅可以让 Godot 为你创建一个函数,还可以指定一个现有函数的名称,将其与信号链接。如果你不希望 Godot 为你创建函数,请将“创建函数”开关切换到关闭状态。

将以下代码添加到这个新函数中:

func _on_Player_area_entered( area ):
    if area.is_in_group("coins"):
        area.pickup()
        emit_signal("pickup")
    if area.is_in_group("obstacles"):
        emit_signal("hurt")
        die()

当检测到另一个Area2D时,它将被传递到函数中(使用area变量)。硬币对象将有一个pickup()函数,该函数定义了捡起硬币时的行为(例如播放动画或声音)。当你创建硬币和障碍物时,你需要将它们分配到适当的,以便可以检测到。

总结一下,以下是到目前为止完整的玩家脚本:

extends Area2D

signal pickup
signal hurt

export (int) var speed
var velocity = Vector2()
var screensize = Vector2(480, 720)

func get_input():
    velocity = Vector2()
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed

func _process(delta):
    get_input()
    position += velocity * delta
    position.x = clamp(position.x, 0, screensize.x)
    position.y = clamp(position.y, 0, screensize.y)

    if velocity.length() > 0:
        $AnimatedSprite.animation = "run"
        $AnimatedSprite.flip_h = velocity.x < 0
    else:
        $AnimatedSprite.animation = "idle"

func start(pos):
    set_process(true)
    position = pos
    $AnimatedSprite.animation = "idle"

func die():
    $AnimatedSprite.animation = "hurt"
    set_process(false)

func _on_Player_area_entered( area ):
    if area.is_in_group("coins"):
        area.pickup()
        emit_signal("pickup")
    if area.is_in_group("obstacles"):
        emit_signal("hurt")
        die()

第二部分 – 硬币场景

在这部分,你将为玩家创建可以收集的硬币。这将是一个独立的场景,描述单个硬币的所有属性和行为。一旦保存,主场景将加载硬币场景并创建多个实例(即副本)。

节点设置

点击“场景”|“新建场景”并添加以下节点。别忘了像处理“玩家”场景那样设置子节点不被选中:

  • Area2D(命名为Coin

  • AnimatedSprite

  • CollisionShape2D

在添加节点后,请确保保存场景。

按照你在玩家场景中设置的方式设置AnimatedSprite。这次,你只有一个动画:一个使硬币看起来不那么扁平和无趣的闪光/闪耀效果。添加所有帧并将速度(FPS)设置为12。图像有点太大,所以将AnimatedSpriteScale设置为(0.50.5)。在CollisionShape2D中使用CircleShape2D并调整其大小以覆盖硬币图像。别忘了:在调整碰撞形状大小时,永远不要使用缩放手柄。圆形形状有一个单独的手柄,用于调整圆的半径。

使用组

组为节点提供了一个标签系统,允许你识别相似的节点。一个节点可以属于任意数量的组。你需要确保所有硬币都将位于一个名为coins的组中,以便玩家脚本能够正确响应触摸硬币。选择Coin节点,点击节点标签页(与找到信号相同的标签页)并选择组。在框中输入coins,然后点击添加,如图所示:

图片

脚本

接下来,向Coin节点添加一个脚本。如果你在模板设置中选择空,Godot 将创建一个没有注释或建议的空脚本。硬币脚本的代码比玩家脚本的代码要短得多:

extends Area2D

func pickup():
    queue_free()

pickup()函数由玩家脚本调用,告诉硬币在被收集时要做什么。queue_free()是 Godot 的节点移除方法。它安全地从树中移除节点,并从内存中删除它及其所有子节点。稍后,你将在这里添加一个视觉效果,但现在硬币消失的效果就足够了。

queue_free()不会立即删除对象,而是将其添加到队列中,在当前帧结束时删除。这比立即删除节点更安全,因为游戏中运行的其它代码可能仍然需要该节点存在。通过等待直到帧的结束,Godot 可以确保所有可能访问该节点的代码都已完成,节点可以安全地被移除。

第三部分 - 主场景

场景是连接游戏所有部件的关键。它将管理玩家、硬币、计时器以及游戏的其它部件。

节点设置

创建一个新的场景并添加一个名为Main的节点。要将玩家添加到场景中,点击实例按钮并选择你保存的Player.tscn

动画

现在,将以下节点作为Main的子节点添加,并按以下命名:

  • 纹理矩形(命名为Background)——用于背景图像

  • 节点(命名为CoinContainer)——用于存放所有硬币

  • 二维位置(命名为PlayerStart)——用于标记玩家的起始位置

  • 计时器(命名为GameTimer)——用于跟踪时间限制

确保将Background作为第一个子节点。节点将按照显示的顺序绘制,所以在这种情况下背景将在玩家后面。通过将assets文件夹中的grass.png图像拖动到Background节点的纹理属性中,向Background节点添加一个图像。然后将拉伸模式更改为平铺,然后点击布局|全矩形以将框架大小调整为屏幕大小,如下面的截图所示:

图片

PlayerStart节点的位置设置为(240350)。

你的场景布局应该看起来像这样:

图片

主脚本

Main节点(使用空模板)添加一个脚本,并添加以下变量:

extends Node

export (PackedScene) var Coin
export (int) var playtime

var level
var score
var time_left
var screensize
var playing = false

当你点击时,CoinPlaytime属性将现在出现在检查器中。从文件系统面板中拖动Coin.tscnCoin属性,并将其放置在Coin属性中。将Playtime设置为30(这是游戏将持续的时间)。剩余的变量将在代码的后续部分使用。

初始化

接下来,添加_ready()函数:

func _ready():
    randomize()
    screensize = get_viewport().get_visible_rect().size
    $Player.screensize = screensize
    $Player.hide()

在 GDScript 中,你可以使用$通过名称引用特定的节点。这允许你找到屏幕的大小并将其分配给玩家的screensize变量。hide()使玩家一开始不可见(你将在游戏真正开始时让它们出现)。

$符号表示法中,节点名称相对于运行脚本的节点。例如,$Node1/Node2将指代一个节点(Node2),它是Node1的子节点,而Node1本身又是当前运行脚本的子节点。Godot 的自动完成功能将在你输入时建议树中的节点名称。请注意,如果节点的名称包含空格,你必须将其放在引号内,例如,$"My Node"

如果你想要每次运行场景时“随机”数字序列都不同,你必须使用randomize()。从技术上讲,这为随机数生成器选择了一个随机的种子

开始新游戏

接下来,new_game()函数将为新游戏初始化一切:

func new_game():
    playing = true
    level = 1
    score = 0
    time_left = playtime
    $Player.start($PlayerStart.position)
    $Player.show()
    $GameTimer.start()
    spawn_coins()

除了将变量设置为起始值外,此函数还调用玩家的start()函数以确保它移动到正确的起始位置。游戏计时器开始,这将倒计时剩余的游戏时间。

你还需要一个函数,该函数将根据当前级别创建一定数量的硬币:

func spawn_coins():
    for i in range(4 + level):
        var c = Coin.instance()
        $CoinContainer.add_child(c)
        c.screensize = screensize
        c.position = Vector2(rand_range(0, screensize.x),
        rand_range(0, screensize.y))

在这个函数中,你创建Coin对象(这次是通过代码,而不是点击实例化场景按钮)的多个实例,并将其添加为CoinContainer的子节点。每次实例化一个新的节点时,都必须使用add_child()将其添加到树中。最后,你为硬币随机选择一个出现的位置。你将在每个级别的开始时调用这个函数,每次生成更多的硬币。

最终,你希望当玩家点击开始按钮时调用new_game()。现在,为了测试一切是否正常工作,将new_game()添加到你的_ready()函数的末尾,并点击播放项目F5)。当你被提示选择主场景时,选择Main.tscn。现在,每次你播放项目时,Main场景将被启动。

到目前为止,你应该在屏幕上看到你的玩家和五个硬币。当玩家触摸一个硬币时,它就会消失。

检查剩余的硬币

主脚本需要检测玩家是否已经捡起所有硬币。由于硬币都是CoinContainer的子节点,你可以使用此节点的get_child_count()来找出剩余多少个硬币。将此放入_process()函数中,以便每帧都会进行检查:

func _process(delta):
    if playing and $CoinContainer.get_child_count() == 0:
        level += 1
        time_left += 5
        spawn_coins()

如果没有更多的硬币剩余,那么玩家将进入下一级。

第四部分 – 用户界面

你的游戏需要的最后一部分是一个用户界面UI)。这是一个用于在游戏过程中显示玩家需要看到的信息的界面。在游戏中,这也被称为抬头显示HUD),因为信息以叠加的形式显示在游戏视图之上。你还会使用这个场景来显示一个开始按钮。

HUD 将显示以下信息:

  • 分数

  • 剩余时间

  • 一条消息,例如游戏结束

  • 一个开始按钮

节点设置

创建一个新的场景并添加一个名为HUDCanvasLayer节点。CanvasLayer节点允许你在游戏其他元素之上绘制 UI 元素,这样显示的信息不会被玩家或金币等游戏元素覆盖。

Godot 提供了各种 UI 元素,可用于创建从健康条等指示器到复杂界面如存货界面等任何内容。实际上,你用来制作这个游戏的 Godot 编辑器就是使用这些元素在 Godot 中构建的。UI 元素的基本节点是从Control扩展的,在节点列表中以绿色图标显示。要创建你的 UI,你将使用各种Control节点来定位、格式化和显示信息。以下是完成后的HUD的外观:

锚点和边距

控制节点具有位置和大小,但它们还具有称为锚点边距的属性。锚点定义了节点边缘相对于父容器的起点或参考点。边距表示控制节点边缘与其对应锚点之间的距离。当你移动或调整控制节点的大小时,边距会自动更新。

消息标签

在场景中添加一个Label节点并将其名称更改为MessageLabel 这个标签将显示游戏标题,以及游戏结束时显示的 Game Over。这个标签应该在游戏屏幕上居中。你可以用鼠标拖动它,但为了精确放置 UI 元素,你应该使用锚点属性。

选择视图 | 显示辅助工具以显示帮助您看到锚点位置的标记,然后点击布局菜单并选择水平居中宽:

MessageLabel现在横跨屏幕宽度并垂直居中。检查器中的文本属性设置标签显示的文本。将其设置为 Coin Dash!并将对齐和垂直对齐设置为居中。

Label节点的默认字体非常小,所以下一步是为其分配一个自定义字体。在检查器中向下滚动到自定义字体部分,并选择新建动态字体,如图下所示:

现在,点击动态字体,你可以调整字体设置。从文件系统坞中,拖动Kenney Bold.ttf字体并将其放入字体数据属性中。将大小设置为48,如图下所示:

得分和时间显示

HUD的顶部将显示玩家的得分和时钟剩余时间。这两个都将使用Label节点,分别位于游戏屏幕的两侧。而不是单独定位它们,你将使用Container节点来管理它们的位置。

容器

UI 容器自动排列其子Control节点(包括其他Containers)的位置。您可以使用它们在元素周围添加填充、居中元素或按行或列排列元素。每种类型的Container都有特殊的属性来控制它们如何排列子元素。您可以在检查器的“自定义常量”部分中查看这些属性。

记住,容器自动排列其子元素。如果您移动或调整容器节点内的控件的大小,会发现它自动回到原始位置。您可以手动排列控件使用容器排列控件,但不能同时进行。

要管理分数和时间标签,向HUDMarginContainer节点添加一个MarginContainer节点。使用布局菜单设置锚点为顶部宽。在“自定义常量”部分中,将边距右、边距顶和边距左设置为10。这将添加一些填充,以便文本不会紧贴屏幕边缘。

由于分数和时间标签将使用与MessageLabel相同的字体设置,因此如果您复制它将节省时间。单击MessageLabel并按Ctrl + D (Cmd + D 在 macOS 上)两次以创建两个副本标签。将它们都拖动并放在MarginContainer上,使它们成为其子元素。将一个命名为ScoreLabel,另一个命名为TimeLabel,并将两者的文本属性都设置为0。将ScoreLabel的对齐方式设置为左对齐,将TimeLabel的对齐方式设置为右对齐。

通过 GDScript 更新 UI

将脚本添加到HUD节点。此脚本将在需要更改属性时更新 UI 元素,例如,每当收集到金币时更新分数文本。参考以下代码:

extends CanvasLayer

signal start_game

func update_score(value):
    $MarginContainer/ScoreLabel.text = str(value)

func update_timer(value):
    $MarginContainer/TimeLabel.txt = str(value)

Main场景的脚本将调用这些函数来更新显示,每当值发生变化时。对于MessageLabel,您还需要一个计时器,以便在短时间内消失。添加一个Timer节点,并将其名称更改为MessageTimer在检查器中,将等待时间设置为2秒,并勾选复选框以设置单次触发为开启。这确保了当启动时,计时器只会运行一次,而不是重复。添加以下代码:

func show_message(text):
    $MessageLabel.text = text
    $MessageLabel.show()
    $MessageTimer.start()

在此函数中,您显示消息并启动计时器。要隐藏消息,连接MessageTimertimeout()信号并添加以下代码:

func _on_MessageTimer_timeout():
    $MessageLabel.hide()

使用按钮

添加一个Button节点,并将其名称更改为StartButton此按钮将在游戏开始前显示,点击后将隐藏自身并向Main场景发送信号以开始游戏。将文本属性设置为“开始”,并更改自定义字体,就像您对MessageLabel所做的那样。在布局菜单中,选择“居中底部”。这将使按钮位于屏幕底部,因此可以通过按箭头键或通过编辑边距并将顶部设置为-150、底部设置为-50来稍微向上移动它。

当按钮被点击时,会发出一个信号。在StartButton的节点标签页中,连接pressed()信号:

func _on_StartButton_pressed():
    $StartButton.hide()
    $MessageLabel.hide()
    emit_signal("start_game")

HUD发出start_game信号,通知Main是时候开始新游戏了。

游戏结束

你 UI 的最终任务是响应游戏结束:

func show_game_over():
    show_message("Game Over")
    yield($MessageTimer, "timeout")
    $StartButton.show()
    $MessageLabel.text = "Coin Dash!"
    $MessageLabel.show()

在这个功能中,你需要游戏结束信息显示两秒钟后消失,这正是show_message()所做到的。然而,你希望在信息消失后显示开始按钮。yield()函数暂停函数的执行,直到给定的节点(MessageTimer)发出给定的信号(timeout)。一旦接收到信号,函数继续执行,返回到初始状态,这样你就可以再次玩游戏。

将 HUD 添加到 Main

现在,你需要设置Main场景和HUD之间的通信。将HUD场景的实例添加到Main场景中。在Main场景中,连接GameTimertimeout()信号,并添加以下内容:

func _on_GameTimer_timeout():
    time_left -= 1
    $HUD.update_timer(time_left)
    if time_left <= 0:
        game_over()

每当GameTimer超时(每秒一次),剩余时间会减少。

接下来,连接Playerpickup()hurt()信号:

func _on_Player_pickup():
    score += 1
    $HUD.update_score(score)

func _on_Player_hurt():
    game_over()

游戏结束时需要发生几件事情,所以添加以下函数:

func game_over():
    playing = false
    $GameTimer.stop()
    for coin in $CoinContainer.get_children():
        coin.queue_free()
    $HUD.show_game_over()
    $Player.die()

此函数使游戏暂停,并遍历硬币,移除任何剩余的硬币,同时调用 HUD 的show_game_over()函数。

最后,StartButton需要激活new_game()函数。点击HUD实例并选择其new_game()信号。在信号连接对话框中,点击“Make Function to Off”,在“Method In Node”字段中输入new_game。这将连接信号到现有函数而不是创建一个新的函数。请看以下截图:

图片

new_game()_ready()函数中移除,并将以下两行添加到new_game()函数中:

$HUD.update_score(score)
$HUD.update_timer(time_left)

现在,你可以开始玩游戏了!确认所有部分都按预期工作:得分、倒计时、游戏结束和重新开始等。如果你发现某个部分不工作,请返回并检查你创建它的步骤,以及它连接到游戏其他部分的步骤。

第五部分 - 完成工作

你已经创建了一个可以工作的游戏,但它仍然可以变得更有趣。游戏开发者使用术语juice来描述使游戏感觉好玩的事物。juice 可以包括声音、视觉效果或任何其他增加玩家享受的东西,而无需改变游戏玩法本身。

在本节中,你将添加一些小的juicy功能来完成游戏。

视觉效果

当你捡起硬币时,它们只是消失,这并不很有吸引力。添加视觉效果将使收集大量硬币变得更加令人满意。

首先,向Coin场景添加一个Tween节点。

什么是 tween?

tween 是一种通过特定函数在时间上(从起始值到结束值)逐渐插值(改变)某些值的方法。例如,你可能选择一个稳定改变值的函数,或者一个开始缓慢但逐渐加速的函数。tweening 也被称为 easing

当在 Godot 中使用 Tween 节点时,你可以将其分配给改变节点的一个或多个属性。在这种情况下,你将增加硬币的 Scale 并使用 Modulate 属性使其淡出。

将此行添加到 Coin_ready() 函数中:

$Tween.interpolate_property($AnimatedSprite, 'scale',
                            $AnimatedSprite.scale,
                            $AnimatedSprite.scale * 3, 0.3,
                            Tween.TRANS_QUAD,
                            Tween.EASE_IN_OUT)

interpolate_property() 函数会导致 Tween 改变节点的属性。有七个参数:

  • 影响的节点

  • 要改变的属性

  • 属性的起始值

  • 属性的结束值

  • 持续时间(以秒为单位)

  • 要使用的函数

  • 方向

当玩家捡起硬币时,tween 应该开始播放。在 pickup() 函数中替换 queue_free()

func pickup():
    monitoring = false
    $Tween.start() 

monitoring 设置为 false 确保当玩家在 tween 动画期间触摸硬币时,不会发出 area_enter() 信号。

最后,当动画结束时,应该删除硬币,因此连接 Tween 节点的 tween_completed() 信号:

func _on_Tween_tween_completed(object, key):
    queue_free()

现在,当你运行游戏时,你应该看到当捡起硬币时,硬币会变大。这是好的,但将 tween 应用到多个属性同时时,效果会更明显。你可以添加另一个 interpolate_property(),这次用来改变精灵的不透明度。这是通过改变 modulate 属性实现的,它是一个 Color 对象,并改变其 alpha 通道从 1(不透明)到 0(透明)。参考以下代码:

$Tween.interpolate_property($AnimatedSprite, 'modulate', 
                            Color(1, 1, 1, 1),
                            Color(1, 1, 1, 0), 0.3,
                            Tween.TRANS_QUAD,
                            Tween.EASE_IN_OUT)

声音

声音是游戏设计中最重要的但经常被忽视的部分之一。良好的声音设计可以在非常小的努力下给你的游戏增添巨大的活力。声音可以给玩家反馈,将他们与角色情感上联系起来,甚至成为游戏玩法的一部分。

对于这个游戏,你将添加三个音效。在 Main 场景中,添加三个 AudioStreamPlayer 节点,并分别命名为 CoinSoundLevelSoundEndSound。将每个声音从 audio 文件夹(你可以在 FileSystem 中的 assets 下找到它)拖动到每个节点的相应 Stream 属性中。

要播放声音,你可以在其上调用 play() 函数。将 $CoinSound.play() 添加到 _on_Player_pickup() 函数中,$EndSound.play() 添加到 game_over() 函数中,以及 $LevelSound.play() 添加到 spawn_coins() 函数中。

提升物品

有很多可能性可以给玩家带来小的优势或提升。在本节中,你将添加一个提升物品,当收集时会给玩家一小段时间奖励。它将偶尔短暂出现,然后消失。

新场景将与你已经创建的 Coin 场景非常相似,所以点击你的 Coin 场景,选择 Scene | Save Scene As 并将其保存为 Powerup.tscn。将根节点名称更改为 Powerup 并通过点击清除脚本按钮移除脚本:。你还应该断开 area_entered 信号(你稍后会重新连接它)。在 Groups 选项卡中,通过点击删除按钮(看起来像垃圾桶)将硬币组移除,并将其添加到名为 powerups 的新组中。

AnimatedSprite 中,将硬币的图像更改为 powerup,你可以在 res://assets/pow/ 文件夹中找到它。

点击添加新脚本,并将 Coin.gd 脚本中的代码复制过来。将 _on_Coin_area_entered 的名称更改为 _on_Powerup_area_entered 并再次将 area_entered 信号连接到它。记住,这个函数名称将由信号连接窗口自动选择。

接下来,添加一个名为 LifetimeTimer 节点。这将限制对象在屏幕上停留的时间。将其等待时间设置为 2,并将单次触发和自动启动都设置为开启。连接其超时信号,以便在时间周期结束时将其移除:

func _on_Lifetime_timeout():
    queue_free()

现在,前往你的主场景并添加另一个名为 PowerupTimerTimer 节点。将其单次触发属性设置为开启。在 audio 文件夹中还有一个 Powerup.wav 声音,你可以通过另一个 AudioStreamPlayer 添加。

连接 timeout 信号并添加以下代码以生成 Powerup

func _on_PowerupTimer_timeout():
    var p = Powerup.instance()
    add_child(p)
    p.screensize = screensize
    p.position = Vector2(rand_range(0, screensize.x),
                         rand_range(0, screensize.y))

Powerup 场景需要通过添加变量,然后将场景拖动到检查器中的属性来链接,就像你之前对 Coin 场景所做的那样:

export (PackedScene) var Powerup

能量提升物品应该以不可预测的方式出现,所以 PowerupTimer 的等待时间需要在开始新关卡时设置。在 spawn_coins() 生成新硬币后,将此代码添加到 _process() 函数中:

$PowerupTimer.wait_time = rand_range(5, 10)
$PowerupTimer.start()

现在你将会有能量提升物品出现,最后一步是在收集到一个时给玩家一些额外的时间。目前,玩家脚本假设它遇到的是硬币或障碍物。将 Player.gd 中的代码更改以检查被击中的对象类型:

func _on_Player_area_entered( area ):
    if area.is_in_group("coins"):
        area.pickup()
        emit_signal("pickup", "coin")
    if area.is_in_group("powerups"):
        area.pickup()
        emit_signal("pickup", "powerup")
    if area.is_in_group("obstacles"):
        emit_signal("hurt")
        die()

注意,现在你正在使用一个额外的参数来命名对象的类型来发射拾取信号。Main.gd 中的相应函数现在可以接受该参数,并使用 match 语句来决定采取什么行动:

func _on_Player_pickup(type):
    match type:
        "coin":
            score += 1
            $CoinSound.play()
            $HUD.update_score(score)
        "powerup":
            time_left += 5
            $PowerupSound.play()
            $HUD.update_timer(time_left)

match 语句是 if 语句的有用替代品,尤其是在你有大量可能值要测试时。

尝试运行游戏并收集能量提升物品。确保声音播放并且计时器增加五秒钟。

硬币动画

当你创建了 Coin 场景时,你添加了一个 AnimatedSprite,但它还没有开始播放。硬币动画显示一个在硬币表面移动的 闪烁 效果。如果所有硬币同时显示这个效果,看起来会太规律,所以每个硬币的动画都需要一个小的随机延迟。

首先,点击 AnimatedSprite,然后点击 Frames 资源。确保 Loop 设置为 Off,并且 Speed 设置为 12

Timer 节点添加到 Coin 场景中,并在 _ready() 中添加以下代码:

$Timer.wait_time = rand_range(3, 8)
$Timer.start()

现在,将 Timertimeout() 信号连接起来,并添加以下内容:

func _on_Timer_timeout():
    $AnimatedSprite.frame = 0
    $AnimatedSprite.play()

尝试运行游戏,并观察硬币的动画效果。这需要很少的努力,却是一个很好的视觉效果。你会在专业游戏中注意到很多这样的效果。虽然很微妙,但视觉吸引力使得游戏体验更加愉悦。

前面的 Powerup 对象有类似的动画,你可以以相同的方式添加。

障碍物

最后,通过引入玩家必须避免的障碍物,可以使游戏更具挑战性。触摸障碍物将结束游戏。

为仙人掌创建一个新的场景,并添加以下节点:

  • Area2D(命名为 Cactus

  • Sprite

  • CollisionShape2D

将仙人掌纹理从 FileSystem 选项卡拖动到 Sprite 的 Texture 属性。向碰撞形状添加一个 RectangleShape2D,并调整其大小以覆盖图像。记得你之前在玩家脚本中添加了 if area.is_in_group("obstacles") 吗?使用节点选项卡(在检查器旁边)将 Cactus 身体添加到 obstacles 组。

现在,将一个 Cactus 实例添加到 Main 场景中,并将其移动到屏幕上半部分的一个位置(远离玩家出生点)。玩玩游戏,看看当你撞到仙人掌时会发生什么。

你可能已经发现了一个问题:硬币可能会在仙人掌后面生成,这使得它们无法被捡起。当硬币放置时,如果它检测到与障碍物重叠,它需要移动。连接硬币的 area_entered() 信号并添加以下内容:

func _on_Coin_area_entered( area ):
    if area.is_in_group("obstacles"):
        position = Vector2(rand_range(0, screensize.x), rand_range(0, screensize.y))

如果你已经添加了前面的 Powerup 对象,你还需要对其 area_entered 信号做同样的处理。

概述

在本章中,你通过创建一个基本的 2D 游戏学习了 Godot 引擎的基础知识。你设置了项目并创建了多个场景,处理精灵和动画,捕获用户输入,使用 signals 与事件通信,并使用 Control 节点创建 UI。在这里学到的技能是你在任何 Godot 项目中都会用到的关键技能。

在进入下一章之前,查看一下项目。你理解每个节点的作用吗?有没有你不理解的代码片段?如果有,请返回并复习该章节的相关部分。

此外,你也可以自由地尝试游戏并改变一些东西。了解游戏不同部分如何工作的最好方法之一就是改变它们并观察会发生什么。

在下一章中,你将探索更多 Godot 的功能,并通过构建一个更复杂的游戏来学习如何使用更多节点类型。

第三章:逃离迷宫

在上一章中,您学习了 Godot 的节点系统如何工作,允许您使用更小的构建块构建复杂的场景,每个构建块都为您的游戏对象提供不同的功能。随着您进入更大、更复杂的项目,这个过程将继续。然而,有时您可能会在多个不同的对象中重复相同的节点和/或代码,本项目将介绍一些减少重复代码的技术。

在本章中,您将构建一个名为 逃离迷宫 的游戏。在这个游戏中,您将尝试在迷宫中导航以找到出口,同时避开游走的敌人:

图片

在本项目中,您将学习以下关键主题:

  • 继承

  • 基于网格的移动

  • Spritesheet 动画

  • 使用 TileMaps 进行关卡设计

  • 场景之间的转换

项目设置

创建一个新的项目,并从 github.com/PacktPublishing/Godot-Game-Engine-Projects/releases 下载项目资源。

如您之前所见,Godot 默认包含了一系列映射到各种键盘输入的动作。例如,在第一个项目中,您使用了 ui_leftui_right 来进行箭头键移动。然而,通常您需要不同的输入,或者您想自定义动作的名称。您可能还希望添加鼠标或游戏手柄的输入动作。您可以在项目设置窗口中完成这些操作。

点击 Input Map 选项卡,通过在 Action: 框中输入名称并点击 Add 添加四个新的输入动作(左、右、上、下)。然后,对于每个新动作,点击 + 按钮添加一个键动作并选择相应的箭头键。如果您愿意,还可以添加 WASD 控制:

图片

这款游戏将在屏幕上显示各种对象。其中一些对象应该检测碰撞(例如玩家与墙壁之间的碰撞),而其他对象应该相互忽略(例如敌人与金币之间的碰撞)。您可以通过设置对象的物理层和物理层掩码属性来解决此问题。为了使这些层更容易操作,Godot 允许您为游戏物理层指定自定义名称。

点击 General 选项卡,找到 Layer Names/2D Physics 部分。将前四个层命名为以下内容:

图片

您将在项目后期看到碰撞层系统如何与游戏中的各种对象协同工作。

接下来,在 Display/Window 部分,将 Mode 设置为 viewport,将 Aspect 设置为 keep。这将使您能够在保持显示比例不变的情况下调整游戏窗口的大小。请参考以下截图:

图片

最后,在 渲染/质量 部分,将 使用像素捕捉 设置为开启。此设置非常有用,特别是对于像素艺术风格的游戏,因为它确保所有对象都以整数像素值绘制。请注意,这 不会影响移动、物理或其他属性;它仅适用于对象的渲染。请参考以下截图:

项目组织

随着你的项目变得更大、更复杂,你会发现将所有场景和脚本保存在同一个文件夹中变得难以管理。

Godot 初学者通常对此的反应是创建一个 scenes 文件夹和一个 scripts 文件夹,并将每种类型的文件保存在相应的文件夹中。这并不很有效。很快,你就会发现自己正在 scripts 文件夹中寻找需要的脚本,因为它与其他所有游戏脚本混在一起。

更合理的组织方式是为每种类型的对象创建一个文件夹。例如,一个 player 文件夹将包含玩家的场景文件、脚本(们)以及它需要的任何其他资源。以这种方式组织你的项目更具可扩展性,如果你有大量对象,还可以进一步扩展。例如,请参考以下截图:

在整个项目中,示例将假设每个新的场景类型都保存在该类型的文件夹中,包括其脚本。例如,Player.tscnPlayer.gd 文件将保存在一个 player 文件夹中。

继承

面向对象编程OOP)中,继承是一个强大的工具。简要来说,你可以定义一个从另一个类继承的类。使用第一个类创建的对象将包含主类的所有方法和成员变量,以及它自己的。

Godot 是强面向对象的,这给了你使用继承的机会,不仅限于对象(脚本),还可以用于场景,这在你设计游戏架构时提供了很大的灵活性。它还消除了代码重复的需要——如果两个对象需要共享一组方法和变量,例如,你可以创建一个公共脚本,并让两个对象从它继承。如果你修改了那段代码,它将应用到两个对象上。

在这个项目中,玩家的角色将由按键事件控制,而怪物将在迷宫中随机游荡。然而,这两种类型的角色都需要一些共同的属性和功能:

  • 包含四个方向移动动画的精灵图集

  • 一个用于播放动作动画的 AnimationPlayer

  • 基于网格的移动(角色每次只能移动一个完整的 方块

  • 碰撞检测(角色不能穿过墙壁)

通过使用继承,您可以创建一个包含所有角色所需的节点的通用Character场景。玩家和怪物场景可以从中继承共享节点。同样,实际的运动代码(尽管不是控制)在玩家和怪物之间将是相同的,因此它们都可以从相同的脚本中继承以处理运动。

角色场景

通过添加一个名为CharacterArea2D并命名它来开始创建Character场景。Area2D是这类角色的好选择,因为其主要功能将是检测重叠——例如,当它移动到物品或敌人上时。

添加以下子项:

  • Sprite

  • CollisionShape2D

  • Tween(命名为MoveTween

  • AnimationPlayer

Sprite上不添加纹理,但在检查器中,在Sprite的动画部分,将 Vframes 和 Hframes 属性分别设置为45。这告诉 Godot 将纹理切割成 5 x 4 个单独图像的网格。

您将用于玩家和敌人的精灵表将按照完全相同的模式排列,每一行包含一个移动方向的动画帧:

图片

当使用 Vframes 和 Hframes 属性切割了精灵表后,您可以使用 Frame 属性来设置要使用的单个帧。在上面的玩家表中,面向左侧的动画将使用从左上角开始的第 5 帧到第 9 帧(从 0 开始计数)。您将使用AnimationPlayer来更改下面的 Frame 属性。参考以下截图:

图片

接下来,在碰撞形状的 Shape 中创建一个新的RectangleShape2D。点击新的RectangleShape2D,并在检查器中将它的 Extents 属性设置为(16, 16)。请注意,Extents 测量每个方向从中心点的距离,因此这会产生一个 32 x 32 像素的碰撞形状。

由于所有角色都以相同的比例绘制,我们可以确信相同大小的碰撞形状适用于所有角色。如果使用您使用的艺术作品中这种情况不成立,您可以在此处跳过设置碰撞形状,并在稍后为单个继承场景进行配置。

动画

AnimationPlayer节点中创建四个新的动画。将它们命名为与输入动作中使用的四个方向(左、右、上和下)相匹配。在这里拼写非常重要:输入动作的名称必须与动画名称具有相同的拼写和大小写。如果您在命名上不一致,当您到达脚本阶段时,这会使事情变得困难得多。请参考以下截图:

图片

对于每个动画,将Length设置为1,将Step设置为0.2。这些属性位于动画面板的底部:

图片

Starting with the down animation, click on the Sprite node and set its Frame property to 0. Click the key icon next to the Frame property and confirm that you want to add a new track for the Frame property:

The Frame property will automatically be incremented by one and the animation track will be advanced by one step (0.2 seconds). Click the key again until you've reached frame 4. You should now have five keyframes on the animation track. If you drag the bar back and forth, you'll see the Frame property change as you reach each keyframe:

If, for some reason, you find that the frames aren't correct, you can delete any of the keyframes by clicking on the dot and pressing Delete on your keyboard, or right-clicking on the dot and choosing Remove Selection. Remember, whatever value you set Frame to, that will be the value of the keyframe when you press the Add Keyframe button. You can also click and drag keyframes to change their order in the timeline.

Repeat the process for the other animations, using the following table to guide you on which keyframes to use for each direction:

动画
0, 1, 2, 3, 4
5, 6, 7, 8, 9
10, 11, 12, 13, 14
15, 16, 17, 18, 19

As long as the spritesheet for a character follows the same 5 x 4 arrangement, this AnimationPlayer configuration will work, and you won't need to create separate animations for each character. In larger projects, it can be a huge time-saver to create all your spritesheet animations while following a common pattern.

碰撞检测

Because the characters are moving on a grid, they need to either move the full distance to the next tile or not at all. This means that, before moving, the character needs to check to see if the move is possible. One way to test if an adjacent square has anything in it is by using a raycast光线投射意味着从角色的位置向一个指定的目的地发射一条光线。如果光线在途中遇到任何物体,它将报告接触。通过向角色添加四个光线,它可以观察周围的方块,以查看它们是否被占用。

Add four RayCast2D nodes and set their names and 投射到 properties as follows:

名称 投射到
RayCastRight (64, 0)
RayCastLeft (-64, 0)
RayCastDown (0, 64)
RayCastUp (0, -64)

Make sure to set the Enabled property on each one (RayCast2D options are disabled by default). Your final node setup should look like this:

Character script

Now, add a script to the Character node (make sure you've saved the scene first, and the script will automatically be named Character.gd). First, define the class variables:

extends Area2D

export (int) var speed

var tile_size = 64
var can_move = true
var facing = 'right'
var moves = {'right': Vector2(1, 0),
             'left': Vector2(-1, 0),
             'up': Vector2(0, -1),
             'down': Vector2(0, 1)}
onready var raycasts = {'right': $RayCastRight,
                        'left': $RayCastLeft,
                        'up': $RayCastUp,
                        'down': $RayCastDown}

speed将控制角色的移动和动画速度,允许你自定义移动速度。正如你在第一章“简介”中学习的,使用export可以通过检查器设置变量的值。保存脚本,并在检查器中将速度属性设置为3

can_move是一个标志,用于跟踪角色在当前帧期间是否允许移动。在移动进行时,它将被设置为false,以防止在上一移动完成之前启动第二次移动。facing是一个表示当前移动方向的字符串(再次强调,拼写和首字母大小写必须与项目开始时创建的输入动作完全一致)。moves字典包含描述四个方向的向量,而raycasts字典包含四个射线投射节点的引用。请注意,这两个字典的键与输入动作名称匹配。

在变量声明期间引用另一个节点时,必须使用onready以确保在引用的节点准备好之前变量没有被设置。你可以将其视为在_ready()函数中编写代码的快捷方式。这一行:

onready var sprite = $Sprite

等同于以下写法:

var sprite

func _ready():

    sprite = $Sprite

以下代码将执行从一个方块到另一个方块的移动:

func move(dir):
    $AnimationPlayer.playback_speed = speed
    facing = dir
    if raycasts[facing].is_colliding():
        return

    can_move = false
    $AnimationPlayer.play(facing)
    $MoveTween.interpolate_property(self, "position", position,
                position + moves[facing] * tile_size,
                1.0 / speed, Tween.TRANS_SINE, Tween.EASE_IN_OUT)
    $MoveTween.start()
    return true

move()函数接受一个方向作为参数。如果给定方向的RayCast2D检测到碰撞,则移动将被取消,函数将返回而不执行进一步操作(注意,返回值将为null)。否则,它将facing更改为新方向,使用can_move禁用额外的移动,并开始播放匹配的动画。为了实际执行移动,Tween节点将position属性从当前值插值到当前值加上给定方向一个方块大小的移动。持续时间(1.0 / speed秒)设置为与动画长度相匹配。

使用Tween.TRANS_SINE过渡类型会产生令人愉悦、平滑的移动,先加速然后减速到最终位置。你可以自由尝试其他过渡类型来改变移动风格。

最后,为了再次启用移动,需要在移动完成后重置can_move。将MoveTweentween_completed信号连接并添加以下代码:

func _on_MoveTween_tween_completed( object, key ):
    can_move = true

玩家场景

玩家场景需要包含我们给Character的所有相同节点。这是你将利用继承的强大功能的地方。

首先创建一个新的场景。然而,不要创建一个新的空场景,而是在菜单中点击“场景”|“新建继承场景”。在“打开基础场景”窗口中,选择res://character/Character.tscn,如下截图所示:

将这个新场景的根节点从Character重命名为Player并保存新场景。注意,所有的Character节点也都存在。如果你更改了Character.tscn并保存它,这些更改也会在Player场景中生效。

现在,你需要设置玩家的物理层,所以在检查器中找到碰撞部分,并设置层和掩码属性。层应设置为仅玩家,而掩码应显示墙壁、敌人和物品。参考以下截图:

碰撞层系统是一个强大的工具,它允许你自定义哪些对象可以检测彼此。层属性将对象放置在一个或多个碰撞层中,而掩码属性定义了对象可以看到哪些层。如果另一个对象不在其掩码层之一中,它将不会被检测或碰撞。

需要更改的唯一其他节点是Sprite,在那里你需要设置纹理。从res://assets文件夹拖动玩家精灵表单,并将其放入Sprite的纹理属性中。接下来,测试AnimationPlayer中的动画,确保它们显示正确的方向。如果你发现任何动画有问题,确保你在Character场景中修复它,它也会自动在Player场景中修复:

Camera节点作为Player的子节点添加,并检查其当前属性是否为 On。Godot 将自动在游戏窗口中渲染当前相机看到的任何内容。这将允许你制作任何大小的地图,并且相机会随着玩家在地图上移动而滚动。注意,当你添加相机时,会出现一个紫色的框,它位于玩家的中心。这代表相机的可见区域,因为它作为玩家的子节点,所以会跟随玩家的移动。如果你查看检查器中的相机属性,你会看到四个限制属性。这些属性用于阻止相机滚动过某个点;例如,地图的边缘。尝试调整它们,看看当你拖动它时,框是如何停止跟随Player的(确保你移动的是Player节点本身,而不是其子节点)。稍后,这些限制将由关卡本身自动设置,这样相机就不会滚动到“关卡之外”。

玩家脚本

玩家脚本也需要扩展角色的。通过选择Player节点并点击清除脚本按钮来删除附加的脚本(Character.gd):

现在,再次点击按钮以附加新的脚本。在附加节点脚本对话框中,点击继承选项旁边的文件夹图标,并选择Character.gd

这里是玩家脚本(注意它extends角色脚本):


extends "res://character/Character.gd"

signal moved

func _process(delta):
    if can_move:
        for dir in moves.keys():
            if Input.is_action_pressed(dir):
                if move(dir):
                    emit_signal('moved')

因为它继承了 Character.gd 中的所有行为,玩家也将拥有 move() 函数。你只需要用代码扩展它,根据输入事件调用 move()。正如你之前看到的,你可以使用 process() 函数来检查每一帧的输入状态。然而,只有当 can_move 允许时,你才实际上检查输入并调用 move()

因为你在输入动作以及 movesraycasts 字典的键中使用了 updownleftright 这些名称,你可以遍历这些键并检查每个键作为输入。

记住,move() 函数在成功时返回 true。如果成功,玩家会发出 moved 信号,你可以在以后与敌人一起使用这个信号。

运行场景并尝试在屏幕上移动玩家角色。

玩家还没有可以行走的关卡,但你可以继续添加玩家稍后需要的代码。当玩家在关卡中移动时,它会遇到各种对象并需要对其做出响应。通过使用信号,你可以在创建关卡之前就添加相应的代码。向脚本中添加三个额外的信号:

signal dead
signal grabbed_key
signal win

然后,连接 Playerarea_entered 信号并添加以下代码:

func _on_Player_area_entered( area ):
    if area.is_in_group('enemies'):
        emit_signal('dead')
    if area.has_method('pickup'):
        area.pickup()
    if area.type == 'key_red':
        emit_signal('grabbed_key')
    if area.type == 'star':
        emit_signal('win')

当玩家遇到另一个 Area2D 时,此函数将运行。如果该对象是敌人,玩家将输掉游戏。注意 has_method() 的使用。这允许你通过检查是否有 pickup() 方法来识别可收集的对象,并且只有在方法存在时才调用该方法。

敌人场景

希望你现在已经看到了继承是如何工作的。你将使用相同的步骤创建 Enemy 场景。创建一个新的场景,从 Character.tscn 继承,并命名为 Enemy。将怪物精灵图集 res://assets/slime.png 拖到 Sprite 的纹理上。

在检查器的碰撞部分,设置 LayerMask 属性。Layer 应该设置为敌人,而 Mask 应该显示墙壁和玩家。

Player 一样,移除现有的脚本并附加一个新的脚本,从 Character.gd 继承:

extends "res://character/Character.gd"

func _ready():
    can_move = false
    facing = moves.keys()[randi() % 4]
    yield(get_tree().create_timer(0.5), 'timeout')
    can_move = true

func _process(delta):
    if can_move:
         if not move(facing) or randi() % 10 > 5:
             facing = moves.keys()[randi() % 4] 

_ready() 函数中的代码起着重要的作用:因为敌人被添加到树形结构中 TileMap 节点之下,它们会被首先处理。你不想让敌人在墙壁被处理之前开始移动,否则它们可能会踩到墙壁的瓦片上而卡住。在它们开始之前需要有一个小的延迟,这也有助于给玩家准备的时间。为此,你不需要在场景中添加一个 Timer 节点,而是可以使用 SceneTreecreate_timer() 函数创建一个一次性定时器,直到超时信号触发才执行。

GDScript 的 yield() 函数提供了一种方法,可以在稍后时间暂停函数的执行,同时允许游戏的其他部分继续运行。当传递一个对象和一个命名信号时,执行将在该对象发出给定信号时恢复。

每一帧,如果敌人能够移动,它就会移动。如果它撞到墙壁(即当move()返回null时),或者有时是随机地,它会改变方向。结果将是一个不可预测的(并且难以躲避!)敌人移动。记住,你可以独立地在它们的场景中调整PlayerEnemy的速度,或者改变Character场景中的speed,这将影响它们两个。

可选 - 轮流移动

对于不同风格的游戏,你可以将_process()移动代码放入一个名为_on_Player_moved()的函数中,并将其连接到玩家的moved信号。这将使敌人只在玩家移动时移动,使游戏更具策略感,而不是快节奏的动作。

创建关卡

在本节中,你将创建所有动作将发生的地图。正如其名所示,你可能想要制作一个迷宫般的关卡,有很多转弯和曲折。

这里是一个示例关卡:

玩家的目标是到达星星。锁着的门只能通过捡起钥匙才能打开。绿色圆点标记着敌人的出生位置,而红色圆点标记着玩家的起始位置。金币是可以在路上捡到的额外物品,可以用来获得额外分数。请注意,整个关卡比显示窗口要大。当玩家在地图周围移动时,Camera会滚动地图。

你将使用TileMap节点来创建地图。使用TileMap进行关卡设计有几个好处。首先,它们通过在网格上绘制瓦片来绘制关卡布局,这比逐个放置Sprite节点要快得多。其次,它们允许创建更大的关卡,因为它们通过将瓦片批处理在一起并只绘制给定时间可见的地图的来优化绘制大量瓦片。最后,你可以向单个瓦片和整个地图添加碰撞形状,这样整个地图将作为一个单独的碰撞体,简化你的碰撞代码。

完成这一部分后,你将能够创建你想要的任意数量的这些地图。你可以按顺序放置它们,以实现关卡到关卡的发展。

项目

首先,为玩家可以捡起的可收集对象创建一个新的场景。这些物品将在游戏运行时由地图生成。以下是场景树:

留下Sprite纹理为空。由于你正在使用这个对象来表示多个项目,纹理可以在创建项目时在项目的脚本中设置。

Pickup的碰撞层设置为项目,并将其掩码设置为玩家。你不想在到达之前敌人就收集金币(尽管这可能会让游戏有一个有趣的变体,即你需要在坏人吞掉它们之前尽可能多地收集金币)。

CollisionShape2D节点赋予矩形形状,并将其范围设置为(32, 32)(严格来说,你可以使用任何形状,因为玩家无论如何都会完全移动到瓦片上并完全覆盖物品)。

下面是Pickup的脚本:

extends Area2D

var textures = {'coin': 'res://assets/coin.png',
                'key_red': 'res://assets/keyRed.png',
                'star': 'res://assets/star.png'}
var type

func _ready():
    $Tween.interpolate_property($Sprite, 'scale', Vector2(1, 1),
        Vector2(3, 3), 0.5, Tween.TRANS_QUAD, Tween.EASE_IN_OUT)
    $Tween.interpolate_property($Sprite, 'modulate',
        Color(1, 1, 1, 1), Color(1, 1, 1, 0), 0.5,
        Tween.TRANS_QUAD, Tween.EASE_IN_OUT)

func init(_type, pos):
    $Sprite.texture = load(textures[_type])
    type = _type
    position = pos

func pickup():
    $CollisionShape2D.disabled = true
    $Tween.start()

当物品创建并使用时,type变量将被设置,并用于确定对象应使用哪种纹理。在函数参数中使用_type作为变量名称允许你使用该名称而不与已使用的type冲突。

一些编程语言使用私有函数或变量的概念,这意味着它们仅用于本地。GDScript 中的_命名约定用于视觉上指定应被视为私有的变量或函数。请注意,它们实际上与其他名称没有任何不同;这仅仅是对程序员的一种视觉指示。

使用Tween的拾取效果与你在 Coin Dash 中用于硬币的效果相似——动画Sprite的缩放和透明度。将Tweentween_completed信号连接起来,以便在效果完成后删除物品:

func _on_Tween_tween_completed( object, key ):
     queue_free()

TileSets

为了使用TileMap绘制地图,它必须分配一个TileSetTileSet包含所有单个瓦片纹理以及它们可能具有的碰撞形状。

根据你有多少瓦片,创建TileSet可能很耗时,尤其是第一次。因此,assets文件夹中包含了一个预先生成的TileSet,标题为tileset.tres。你可以自由使用它,但请不要跳过以下部分。它包含有关TileSet如何工作的有用信息。

创建 TileSet

在 Godot 中,TileSet是一种Resource类型。其他资源的例子包括纹理、动画和字体。它们是包含特定类型数据的容器,通常保存为.tres文件。

默认情况下,Godot 以基于文本的格式保存文件,例如.tscn.tres文件中的t表示。与二进制格式相比,基于文本的文件更受欢迎,因为它们是可读的。它们对版本控制系统VCS)也更加友好,这允许你在构建项目的过程中跟踪文件更改。

要创建TileSet,你需要创建一个包含来自你的艺术资产的纹理的Sprite节点的场景。然后你可以向这些Sprite瓦片添加碰撞和其他属性。一旦创建了所有瓦片,你就可以将场景导出为TileSet资源,然后由TileMap节点加载。

下面是TileSetMaker.tscn场景的截图,其中包含你将用于构建游戏关卡所需的瓦片:

图片

首先添加一个 Sprite 节点,并将其纹理设置为 res://assets/sokoban_tilesheet.png。要选择单个瓦片,将区域/启用属性设置为开启,并在编辑器窗口底部点击纹理区域以打开面板。将捕捉模式设置为网格捕捉,并在 xy 方向上将步长设置为 64px。现在,当你点击并拖动纹理时,它只会允许你选择 64 x 64 的纹理区域:

给精灵一个合适的名称(例如 crate_brownwall_red)——这个名称将作为瓦片名称出现在 TileSet 中。添加一个 StaticBody2D 作为子节点,然后向其中添加一个 CollisionPolygon2D。确保碰撞多边形的大小适当,以便与放置在其旁边的瓦片对齐。在编辑器窗口中开启网格捕捉是最简单的方法。

点击使用捕捉按钮(看起来像一块磁铁),然后通过点击其旁边的三个点打开捕捉菜单:

选择配置捕捉... 并将网格步长设置为 64 x 64

现在,当选择 CollisionPolygon2D 时,你可以逐个点击瓦片的四个角来创建一个闭合的方形(它将显示为红色橙色):

这个瓦片现在已完成。你可以复制它(Ctrl + D)并创建另一个,你只需要更改纹理区域。请注意,只有墙壁瓦片需要碰撞体。地面和物品瓦片不应包含它们。

当你创建完所有瓦片后,点击场景 | 转换为 | 瓦片集,并以适当的名称保存,例如 tileset.tres。如果你回来再次编辑场景,你需要重新进行转换。特别注意合并现有选项。如果设置为开启,当前场景的瓦片将与 tileset 文件中的瓦片合并。有时,这可能会导致瓦片索引发生变化,并以不希望的方式更改你的地图。请查看以下截图:

tres 代表文本资源,是 Godot 存储其资源文件的最常见格式。将其与 tscn 进行比较,这是文本场景存储格式。

你的 TileSet 资源现在可以使用了!

瓦片图

现在,让我们为游戏关卡创建一个新的场景。这个关卡将是一个独立的场景,包括地图和玩家,并处理在关卡中生成任何物品和敌人。对于根节点,使用 Node2D 并将其命名为 Level1(稍后,你可以复制此节点设置以创建更多关卡)。

你可以从资产文件夹中打开 Level1.tscn 文件,以查看本节中完成的关卡场景,尽管鼓励你创建自己的关卡。

当使用 TileMap 时,你可能会希望在一个给定位置出现多个瓦片对象。例如,你可能想要放置一棵树,但也希望在它下面有一个地面瓦片。这可以通过多次使用 TileMap 来创建数据层来实现。对于你的关卡,你将创建三个层来显示玩家可以行走的地面;墙壁,它们是障碍物;以及可收集的物品,它们是生成如硬币、钥匙和敌人的标记。

添加一个 TileMap 并将其命名为 Ground。将 tileset.tres 拖放到瓦片集属性中,你将在编辑器窗口的右侧看到瓦片出现,准备使用:

在编辑器窗口中不小心点击并拖动很容易移动整个瓦片图。为了防止这种情况,确保你选择了 Ground 节点并点击锁定按钮: 

将此 TileMap 复制两次,并将新的 TileMap 节点命名为 WallsItems。记住,Godot 按照节点树中从上到下的顺序绘制对象,所以 Ground 应该在顶部,WallsItems 在其下方。

当你在绘制你的关卡时,请注意你正在绘制哪个层!你应该只在物品层放置物品标记,例如,因为代码将在这里查找要创建的对象。不过,不要在该层放置任何其他对象,因为在游戏过程中该层本身将是不可见的。

最后,添加一个 Player 场景的实例。确保 Player 节点位于三个 TileMap 节点之下,这样它就会被绘制在最上面。最终的场景树应该看起来像这样:

关卡脚本

现在关卡已经完成,附加一个创建关卡行为的脚本。此脚本将首先扫描 Items 映射以生成任何敌人和可收集物品。它还将用于监控游戏过程中发生的事件,例如捡起钥匙或遇到敌人:

extends Node2D

export (PackedScene) var Enemy
export (PackedScene) var Pickup

onready var items = $Items
var doors = []

前两个变量包含了对从 Items 映射中实例化的场景的引用。由于该特定映射节点将被频繁引用,你可以将 $Items 查找缓存到一个变量中以节省一些时间。最后,一个名为 doors 的数组将包含地图上找到的门的位置。

保存脚本并将 Enemy.tscnPickup.tscn 文件拖放到检查器中相应的属性。

现在,添加以下代码到 _ready() 中:

func _ready():
    randomize()
    $Items.hide()
    set_camera_limits()
    var door_id = $Walls.tile_set.find_tile_by_name('door_red')
    for cell in $Walls.get_used_cells_by_id(door_id):
        doors.append(cell)
    spawn_items()
    $Player.connect('dead', self, 'game_over')
    $Player.connect('grabbed_key', self, '_on_Player_grabbed_key')
    $Player.connect('win', self, '_on_Player_win')

函数首先确保 Items 瓦片图是隐藏的。你不希望玩家看到这些瓦片;它们的存在是为了让脚本检测物品的生成位置。

接下来,需要设置摄像机的限制,确保它不能滚动到地图的边缘。你需要创建一个函数来处理这个问题(见下面的代码)。

当玩家找到钥匙时,门需要打开,所以下一部分会在Walls地图中搜索任何door_red瓦片并将它们存储在数组中。请注意,你必须首先从TileSet中找到瓦片的id,因为TileMap的单元格只包含指向瓦片集的 ID 数字。

关于spawn_items()函数的更多内容将在后面介绍。

最后,Player信号都连接到将处理其结果的函数。

这是如何设置相机限制以匹配地图大小的:

func set_camera_limits():
    var map_size = $Ground.get_used_rect()
    var cell_size = $Ground.cell_size
    $Player/Camera2D.limit_left = map_size.position.x * cell_size.x
    $Player/Camera2D.limit_top = map_size.position.y * cell_size.y
    $Player/Camera2D.limit_right = map_size.end.x * cell_size.x
    $Player/Camera2D.limit_bottom = map_size.end.y * cell_size.y

get_used_rect()返回一个包含Ground层大小的Vector2。将其乘以cell_size给出整个地图的像素大小,这用于在Camera节点上设置四个限制值。设置这些限制确保当你靠近边缘时,你不会看到地图外的任何死区

现在,添加spawn_items()函数:

func spawn_items():
    for cell in items.get_used_cells():
        var id = items.get_cellv(cell)
        var type = items.tile_set.tile_get_name(id)
        var pos = items.map_to_world(cell) + items.cell_size/2
        match type:
            'slime_spawn':
                var s = Enemy.instance()
                s.position = pos
                s.tile_size = items.cell_size
                add_child(s)
            'player_spawn':
                $Player.position = pos
                $Player.tile_size = items.cell_size
            'coin', 'key_red', 'star':
                var p = Pickup.instance()
                p.init(type, pos)
                add_child(p)

此函数在Items层中查找瓦片,由get_used_cells()返回。每个单元格都有一个id,它映射到TileSet中的一个名称(在创建TileSet时分配给每个瓦片的名称)。如果你创建了自定义瓦片集,请确保在这个函数中使用与你的瓦片匹配的名称。前面代码中使用的名称与包含在资产下载中的瓦片集相匹配。

map_to_world()将瓦片地图位置转换为像素坐标。这给出了瓦片的左上角,因此你必须添加半个瓦片大小以找到瓦片的中心。然后,根据找到的瓦片,实例化匹配的项目对象。

最后,添加玩家信号的相关三个函数:

func game_over():
    pass

func _on_Player_win():
    pass

func _on_Player_grabbed_key():
    for cell in doors:
        $Walls.set_cellv(cell, -1)

玩家信号deadwin应结束游戏并转到游戏结束屏幕(你尚未创建)。由于你目前还不能编写这些函数的代码,暂时使用pass。拾取钥匙的信号应移除任何门瓦片(通过将它们的瓦片索引设置为-1,这意味着空瓦片)。

添加更多关卡

如果你想要创建另一个关卡,你只需要复制这个场景树并将其相同的脚本附加到它上面。这样做最简单的方法是使用“场景”|“另存为”并将关卡保存为Level2.tscn。然后,你可以使用一些现有的瓦片或绘制一个全新的关卡布局。

随意使用你喜欢的关卡数量,确保将它们全部保存在levels文件夹中。在下一节中,你将看到如何将它们链接起来,以便每个关卡都将引导到下一个关卡。如果你编号错误,不用担心;你可以将它们按照你喜欢的任何顺序排列。

游戏流程

现在你已经完成了基本构建块,你需要将所有这些内容结合起来。在本节中,你将创建:

  • 开始和游戏结束屏幕

  • 一个全局脚本用于管理持久数据

游戏的基本流程遵循以下图表:

图片

当玩家死亡或完成最后一个关卡时,他们会被发送到结束屏幕。经过短暂的时间后,结束屏幕会将玩家返回到起始屏幕,以便可以开始新游戏。

开始和结束屏幕

你需要两个场景来完成这部分:一个在游戏开始前显示的起始或标题屏幕(并允许玩家开始游戏),以及一个游戏结束屏幕,以通知玩家游戏已经结束。

创建一个新的场景并添加一个名为StartScreenControl节点。添加一个子标签,并添加res://assets/Unique.ttf作为新的DynamicFont,字体大小为64。将“对齐”和“垂直对齐”属性设置为居中,并将文本设置为“逃离迷宫!”。在布局菜单中,选择“全矩形”。现在,复制此节点并将第二个标签的文本设置为按空格。

对于这个演示,StartScreen被保持得很简单。一旦它开始工作,你可以自由地添加装饰,甚至添加一个AnimationPlayer来让玩家精灵在屏幕上跑动。

选择“场景”|“另存为”来保存此场景的另一个副本,并将其命名为EndScreen。删除第二个Label(显示“按空格”的标签)并添加一个Timer节点。将“自动启动”属性设置为开启,将“单次启动”设置为开启,并将“等待时间”设置为3

这个Timer在到期后会将游戏送回StartScreen

然而,在你可以连接这些其他场景之前,你需要了解如何处理持久数据和自动加载

全局变量

在游戏开发中,有一个非常常见的场景,你需要一些需要在多个场景中持久存在的数据。当场景切换时,场景中的数据会丢失,因此持久数据必须存在于当前场景之外。

Godot 通过使用自动加载来解决此问题。这些是每个场景都会自动加载的脚本或节点。因为 Godot 不支持全局变量,所以自动加载就像一个单例。这是一个(带有附加脚本的)节点,它会在每个场景中自动加载。自动加载的常见用途包括存储全局数据(得分、玩家数据等)、处理场景切换函数,或任何需要独立于当前运行场景的函数。

单例是编程中一个著名的模式,它描述了一个只能允许自身存在一个实例的类,并提供对其成员变量和函数的直接访问。在游戏开发中,它通常用于需要被游戏各个部分访问的持久数据。

当决定是否需要单例时,问问自己这个对象或数据是否需要始终存在,并且是否将始终只有一个该对象的实例。

全局脚本

首先,在脚本窗口中点击“文件”|“新建”来创建一个新的脚本。确保它从Node继承(这是默认设置),并在“路径”字段中设置名称为Global.gd。点击创建,并将以下代码添加到新脚本中:

extends Node

var levels = ['res://levels/Level1.tscn',
              'res://levels/Level2.tscn']
var current_level

var start_screen = 'res://ui/StartScreen.tscn'
var end_screen = 'res://ui/EndScreen.tscn'

func new_game():
    current_level = -1
    next_level()

func game_over():
    get_tree().change_scene(end_screen)

func next_level():
    current_level += 1
    if current_level >= Global.levels.size():
        # no more levels to load :(
        game_over()
    else:
        get_tree().change_scene(levels[current_level])

此脚本提供了一些你需要的功能。

大部分工作是由SceneTreechange_scene()方法完成的。SceneTree代表当前正在运行的场景的基础。当一个场景被加载或添加新节点时,它成为SceneTree的一个成员。change_scene()用给定的场景替换当前场景。

next_level()函数遍历你制作的关卡列表,这些列表在levels数组中列出。如果你到达列表的末尾,游戏结束。

要将此脚本作为自动加载,打开项目设置并点击自动加载选项卡。点击路径旁边的..按钮,选择你的Global.gd脚本。节点名称将自动设置为 Global(这是你在脚本中引用节点的名称,如以下截图所示):

图片

现在,你可以通过在任何脚本中使用其名称来访问全局脚本的所有属性,例如,Global.current_level

将以下脚本附加到StartScreen

extends Control

func _input(event):
    if event.is_action_pressed('ui_select'):
        Global.new_game()

此脚本等待空格键被按下,然后调用Globalnew_game()函数。

将此添加到EndScreen

extends Control

func _on_Timer_timeout():
    get_tree().change_scene(Global.start_screen)

你还需要连接Timertimeout信号。为此,你必须首先创建脚本,然后Connect按钮会为你创建新的函数。

Level.gd脚本中,你现在可以填写剩下的两个函数:

func _on_Player_win():
    Global.next_level()

func game_over():
    Global.game_over()

分数

全局单例是一个很好的地方来保存玩家的分数,以便它在关卡之间保持持久。首先在文件顶部添加一个var score变量,然后在new_game()中添加score = 0

现在,每次收集硬币时都需要添加一个点。转到Pickup.gd并添加signal coin_pickup在顶部。你可以在pickup()函数中发出此信号:

func pickup():
    match type:
        'coin':
            emit_signal('coin_pickup', 1)
    $CollisionShape2D.disabled = true
    $Tween.start()

这里包含1的值,以防你以后想更改硬币的价值数量,或者添加其他增加不同分数的对象。这个信号将用于更新显示,因此现在你可以创建HUD

创建一个新的场景,命名为HUDCanvasLayer并保存场景。添加一个MarginContainer节点作为子节点,并在其下添加一个名为ScoreLabelLabel

MarginContainer布局设置为顶部宽,并设置其四个边距属性(在自定义常量下找到)均为20。然后添加与之前开始和结束屏幕相同的自定义字体属性,接着附加一个脚本:

extends CanvasLayer

func _ready():
    $MarginContainer/ScoreLabel.text = str(Global.score)

func update_score(value):
    Global.score += value
    $MarginContainer/ScoreLabel.text = str(Global.score)

HUD实例添加到Level场景中。记得从上一个项目中,CanvasLayer节点将保持在游戏其他部分之上。它还将忽略任何相机移动,因此显示将保持在玩家在关卡中移动时固定位置。

最后,在Level.gd脚本中,当你生成一个新的可收集对象时,将信号连接到HUD函数:

    'coin', 'key_red', 'star':
        var p = Pickup.instance()
        p.init(type, pos)
        add_child(p)
        p.connect('coin_pickup', $HUD, 'update_score')

运行游戏并收集一些硬币以确认分数正在更新。

保存高分

许多游戏需要你在游戏会话之间保存某种信息。这是你希望保持可用的信息,即使应用程序本身已经退出。例如包括保存的游戏、用户创建的内容或可下载的资源包。对于这个游戏,你将保存一个高分值,该值将在游戏会话之间持续存在。

读取和写入文件

如你之前所见,Godot 将所有资源存储为项目文件夹中的文件。从代码中,这些资源可以通过res://文件夹路径访问。例如,res://project.godot将始终指向当前项目的配置文件,无论项目实际上存储在电脑上的哪个位置。

然而,当项目运行时,res://文件系统被设置为只读以保障安全。当项目导出时,它也是只读的。任何需要用户保留的数据都放置在user://文件路径中。这个文件夹的物理位置将根据游戏运行的平台而有所不同。

你可以使用OS.get_user_data_dir()找到当前平台的用户可写数据文件夹。将一个print()语句添加到你的脚本中的一个ready()函数中,以查看系统上的位置。

使用File对象来读取和写入文件。此对象用于以读取和/或写入模式打开文件,也可以用于检查文件是否存在。

将以下代码添加到Global.gd中:

var highscore = 0
var score_file = "user://highscore.txt"

func setup():
    var f = File.new()
    if f.file_exists(score_file):
        f.open(score_file, File.READ)
        var content = f.get_as_text()
        highscore = int(content)
        f.close()

首先,你需要测试文件是否存在。如果存在,你可以读取存储为可读文本的值,并将其分配给highscore变量。如果需要,文件中也可以存储二进制数据,但文本将允许你自己查看文件并检查一切是否正常工作。

将以下代码添加以检查玩家是否打破了之前的高分:

func game_over():
    if score > highscore:
        highscore = score
        save_score()
    get_tree().change_scene(end_screen)

func save_score():
    var f = File.new()
    f.open(score_file, File.WRITE)
    f.store_string(str(highscore))
    f.close()

save_score()函数用于打开文件以写入新值。请注意,如果文件不存在,以WRITE模式打开将自动创建它。

接下来,当游戏开始时,你需要调用setup()函数,所以将以下内容添加到Global.gd中:

func _ready():
    setup()

最后,为了显示高分,将另一个Label节点添加到StartScreen场景中(你可以复制现有的一个)。将其安排在其他的标签下面(或按你喜欢的任何顺序)并命名为ScoreNotice。将以下内容添加到脚本中:

func _ready():
    $ScoreNotice.text = "High Score: " + str(Global.highscore)

运行游戏并检查你的高分是否在击败它时增加,并在退出并重新开始游戏时持续存在。

完成细节

现在游戏的主要功能已经完成,你可以添加一些更多功能来稍微润色一下。

死亡动画

当敌人击中玩家时,你可以添加一个小动画而不是直接结束游戏。效果将使角色围绕旋转并缩小其缩放属性。

首先,选择Player中的AnimationPlayer节点,然后点击新建动画按钮:。将新动画命名为die

在这个动画中,你将动画 Sprite 的旋转角度和缩放属性。在检查器中找到旋转角度属性,点击 添加一个轨道。将刮擦器移动到动画的末尾,将旋转角度更改为 360,然后再次点击键。尝试播放动画以查看角色旋转。

请记住,虽然通常使用度数来表示检查器属性,但在编写代码时,大多数 Godot 函数期望角度以 弧度 来度量。

现在,用相同的操作处理 缩放 属性。在开始处添加一个关键帧 (1, 1),然后在结束时添加另一个关键帧,将缩放设置为 (0.2, 0.2)。再次尝试播放动画以查看结果。

当玩家击中敌人时,需要触发新的动画。将以下代码添加到玩家的 _on_Player_area_entered() 函数中:

if area.is_in_group('enemies'):
    area.hide()
    set_process(false)
    $CollisionShape2D.disabled = true
    $AnimationPlayer.play("die")
    yield($AnimationPlayer, 'animation_finished')
    emit_signal('dead')

添加的代码处理了一些需要发生的事情。首先,隐藏被击中的敌人确保它不会遮挡玩家并阻止你看到我们新的动画。接下来,使用 set_process(false) 停止 _process() 函数的运行,这样玩家在动画期间就不能继续移动。你还需要禁用玩家的碰撞检测,这样它就不会检测到另一个敌人,如果它恰好经过的话。

在开始 die 动画后,你需要让它完成,然后再发出 dead 信号,因此使用 yield 等待 AnimationPlayer 的信号。

尝试运行游戏并让敌人攻击你以查看动画。如果一切正常,你会在下一次播放时注意到一些问题:玩家变得很小!动画结束时,Sprite 的缩放设置为 (0.2, 0.2),没有任何东西将其设置回正常大小。向玩家的脚本中添加以下内容,以便缩放始终从正确的值开始:

func _ready():
    $Sprite.scale = Vector2(1, 1)

音效

res://assets/audio 文件夹中有六个音效可供你在游戏中使用。这些音频文件是 OGG 格式。默认情况下,Godot 在导入时将 OGG 文件设置为循环。在 FileSystem 选项卡中选择 OGG 文件(你可以使用 Shift + 点击来选择多个文件),然后在编辑器窗口的右侧点击 Import 选项卡。取消选择 Loop 并点击 Reimport 按钮:

首先,添加物品的拾取声音。在 Pickup 场景中添加两个 AudioStreamPlayer 节点,分别命名为 KeyPickupCoinPickup。将相应的音频文件拖动到每个节点的 Stream 属性中。

你还可以通过其 Volume Db 属性调整音量,如图所示:

将以下代码添加到 pickup() 函数的开始部分:

match type:
    'coin':
        emit_signal('coin_pickup', 1)
        $CoinPickup.play()
    'key_red':
        $KeyPickup.play()

其他音效将添加到 Player 场景中。添加三个 AudioStreamPlayer 节点,分别命名为 WinLoseFootsteps,并将匹配的音频文件添加到每个节点的 Stream 中。更新 _on_Player_area_entered() 函数如下:

    if area.type == 'star':
        $Win.play()
        $CollisionShape2D.disabled = true
        yield($Win, "finished")
        emit_signal('win')

你需要禁用碰撞和 yield 以确保声音播放完成,否则它会被下一个关卡加载时立即终止。这样,玩家在继续前进之前有足够的时间听到声音。

要播放脚步声,在 _process() 函数中的 if move(dir): 后面添加 $Footsteps.play()。注意:你可能想要降低脚步声的音量,以免它们压过其他所有声音;它们应该是微妙的背景声音。在 Footsteps 节点中,将 Volume Db 属性设置为 -30

最后,为了播放 Lose 声音,将其添加到敌人碰撞代码中:


if area.is_in_group('enemies'):
    area.hide()
    $CollisionShape2D.disabled = true
    set_process(false)
    $Lose.play()
    $AnimationPlayer.play("die")
    yield($Lose, 'finished')
    emit_signal('dead')

注意,你需要更改 yield 函数。由于声音比动画稍长,如果你在动画完成时结束它,它会被截断。或者,你可以调整动画的持续时间以匹配声音的长度。

摘要

在这个项目中,你学习了如何利用 Godot 的继承系统来组织和共享游戏中的不同对象之间的代码。这是一个非常强大的工具,每次你开始构建新游戏时都应该记住。如果你开始创建多个具有相同属性和/或代码的对象,你可能应该停下来思考一下你在做什么。问问自己:我是否可以使用继承来共享这些对象共有的内容? 在一个包含更多对象的大游戏中,这可以为你节省大量时间。

你看到了 TileMap 节点的工作原理以及它如何让你快速设计地图和生成新对象。它们在许多游戏类型中都有很多用途。正如你将在本书后面看到的那样,TileMaps 也非常适合设计平台游戏关卡。

你还介绍了 AutoLoad 功能,它允许你创建一个包含跨多个场景使用的持久数据的全局脚本。你还学习了如何实现基于网格的移动,并使用 AnimationPlayer 来处理精灵图动画。

在下一章中,你将学习关于 Godot 强大的物理体:RigidBody2D。你将使用它来创建一个经典类型的游戏:太空射击游戏。

第四章:太空岩石

到现在为止,您应该已经对在 Godot 中工作感到更加得心应手;添加节点、创建脚本、在检查器中修改属性等等。随着您通过这本书的进展,您将不会被迫一次又一次地重复基础知识。如果您发现自己遇到了困难,或者感觉不太记得如何做某事,请随时回到之前的项目中,那里有更详细的解释。随着您在 Godot 中重复更常见的操作,它们将开始变得越来越熟悉。同时,每一章都将向您介绍更多节点和技术,以扩展您对 Godot 功能的理解。

在下一个项目中,您将制作一个类似于街机经典游戏《小行星》的空间射击游戏。玩家将控制一艘可以旋转和向任何方向移动的飞船。目标将是避开漂浮的太空岩石并用飞船的激光射击它们。请参考以下截图:

图片

在这个项目中,您将学习以下关键主题:

  • 使用RigidBody2D进行物理运算

  • 有限状态机

  • 构建动态、可扩展的用户界面

  • 声音和音乐

  • 粒子效果

项目设置

创建一个新的项目,并从github.com/PacktPublishing/Godot-Game-Engine-Projects/releases下载项目资源。

对于这个项目,您将使用输入映射来设置自定义输入操作。使用此功能,您可以定义自定义事件并将不同的键、鼠标事件或其他输入分配给它们。这使您在游戏设计上具有更大的灵活性,因为您的代码可以编写为响应例如jump输入,而无需确切知道用户按下了什么输入来触发事件。这允许您使相同的代码在不同的设备上工作,即使它们具有不同的硬件。此外,由于许多玩家期望能够自定义游戏的输入,这也使您能够为用户提供此选项。

要设置游戏的输入,请打开项目 | 项目设置并选择输入映射选项卡。

您需要创建四个新的输入操作:rotate_leftrotate_rightthrustshoot。将每个操作的名称输入到动作框中并点击添加。然后,对于每个操作,点击+按钮并选择要分配的输入类型。例如,为了允许玩家使用箭头键和流行的 WASD 替代方案,设置将如下所示:

图片

如果您的计算机连接了游戏手柄或其他控制器,您也可以以相同的方式将其输入添加到操作中。注意:我们目前只考虑按钮式输入,因此虽然您可以使用这个项目中的十字键,但使用模拟摇杆将需要修改项目的代码。

刚体物理

在游戏开发中,你经常需要知道游戏空间中的两个物体何时相交或接触。这被称为碰撞检测。当检测到碰撞时,你通常希望发生某些事情。这被称为碰撞响应

Godot 提供了三种类型的物理刚体,这些刚体被归类在PhysicsBody2D对象类型下:

  • StaticBody2D:静态刚体是指不会被物理引擎移动的刚体。它参与碰撞检测,但不会对碰撞做出移动。这种类型的刚体通常用于环境中的物体或不需要任何动态行为的物体,例如墙壁或地面。

  • RigidBody2D:这是 Godot 中提供模拟物理的物理刚体。这意味着你不会直接控制RigidBody2D。相反,你对其施加力(重力、冲量等),然后 Godot 的内置物理引擎计算结果运动,包括碰撞、弹跳、旋转和其他效果。

  • KinematicBody2D:这种身体类型提供碰撞检测,但没有物理效果。所有运动都必须通过代码实现,并且你必须自己实现任何碰撞响应。运动刚体通常用于玩家角色或其他需要街机风格物理而不是真实模拟的演员。

了解何时使用特定的物理刚体类型是构建游戏的重要组成部分。使用正确的节点可以简化你的开发,而试图强制错误的节点完成工作可能会导致挫败感和不良结果。随着你与每种类型的刚体一起工作,你会了解它们的优缺点,并学会何时它们可以帮助构建你需要的东西。

在这个项目中,你将使用RigidBody2D节点来控制玩家飞船以及太空岩石本身。你将在后面的章节中了解其他刚体类型。

单个RigidBody2D节点有许多你可以用来自定义其行为的属性,例如质量摩擦弹跳。这些属性可以在检查器中设置:

图片

刚体也受到世界属性的影响,这些属性可以在项目设置下的物理 | 2D 中设置。这些设置适用于世界中的所有刚体。请参考以下截图:

图片

在大多数情况下,你不需要修改这些设置。但是,请注意,默认情况下,重力值为98,方向为(0, 1)(向下)。如果你想更改世界重力,你可以在这里进行更改。你还应该注意最后两个属性,默认线性阻尼和默认角阻尼。这些属性控制刚体将如何快速失去前进速度和旋转速度。将它们设置为较低的值会使世界感觉没有摩擦,而使用较大的值会使你的物体感觉像是在泥中移动。

Area2D节点也可以通过使用空间覆盖属性来影响刚体物理。然后,将自定义重力和阻尼值应用于进入该区域的任何物体。

由于这款游戏将在外太空进行,因此不需要重力,所以将默认重力设置为0。您可以保留其他设置不变。

玩家飞船

玩家飞船是游戏的核心。您将为这个项目编写的绝大部分代码都将关于使飞船工作。它将以经典的《小行星》风格进行控制,包括左右旋转和前进推进。它还将检测射击输入,以便玩家可以发射激光并摧毁漂浮的岩石。

身体设置和物理

创建一个新的场景,并将名为PlayerRigidBody2D作为根节点添加,带有SpriteCollisionShape2D子节点。将res://assets/player_ship.png图像添加到Sprite的纹理属性中。飞船图像相当大,因此将Sprite的缩放属性设置为(0.5, 0.5),并将其旋转设置为90

飞船的图像是向上绘制的。在 Godot 中,0度的旋转指向右侧(沿x轴)。这意味着您需要将Sprite节点的旋转设置为90,以便它与身体的朝向相匹配。

CollisionShape2DShape属性中,添加一个CircleShape2D并将其缩放以尽可能紧密地覆盖图像(记住不要移动矩形大小手柄):

图片

保存场景。在处理更大规模的项目时,建议根据每个游戏对象组织场景和脚本到文件夹中。例如,如果您创建一个player文件夹,可以将与玩家相关的文件保存在那里。这样,与将所有文件都放在单个文件夹中相比,更容易找到和修改您的文件。虽然这个项目相对较小,但随着项目规模和复杂性的增长,养成这种习惯是很好的。

状态机

在游戏过程中,玩家飞船可以处于多种不同的状态。例如,当存活时,飞船是可见的,并且可以被玩家控制,但容易受到岩石的攻击。另一方面,当无敌时,飞船应该看起来半透明,并且对伤害免疫。

程序员处理这类情况的一种方式是在代码中添加布尔标志变量。例如,当玩家生成时,将invulnerable标志设置为true,或者当玩家死亡时,将alive标志设置为false。然而,这可能会导致错误和奇怪的情况,即aliveinvulnerable标志同时被设置为true。在这种情况下,如果一块石头击中玩家,会发生什么?这两个状态是互斥的,因此不应该允许这种情况发生。

解决这个问题的方法之一是使用有限状态机FSM)。当使用 FSM 时,实体在给定时间只能处于一个状态。为了设计你的 FSM,你需要定义一些状态以及什么事件或动作可以导致从一个状态转换到另一个状态。

以下图概述了玩家飞船的 FSM:

图片

有四个状态,箭头表示允许的转换以及触发转换的事件。通过检查当前状态,你可以决定玩家被允许做什么。例如,在DEAD状态,不允许输入,或者在INVULNERABLE状态,不允许射击。

高级 FSM 实现可能相当复杂,细节超出了本书的范围(参见附录以获取进一步阅读)。在最纯粹的意义上,技术上你不会创建一个真正的 FSM,但为了这个项目的目的,它将足以说明这个概念并避免布尔标志问题。

将脚本添加到Player节点,并开始创建 FSM 实现的骨架:

extends RigidBody2D

enum {INIT, ALIVE, INVULNERABLE, DEAD}
var state = null

enum(枚举的缩写)是创建一组常量的便捷方式。前面代码片段中的enum语句等同于以下代码:

const INIT = 0
const ALIVE = 1
const INVULNERABLE = 2
const DEAD = 3

你也可以给一个enum赋予一个名称,这在单个脚本中有多个常量集合时很有用。例如:

enum States {INIT, ALIVE}

var state = States.INIT

然而,在这个脚本中不需要这个,因为您只会使用一个enum来跟踪飞船的状态。

接下来,创建change_state函数来处理状态转换:

func _ready():
    change_state(ALIVE)

func change_state(new_state):
    match new_state:
        INIT:
            $CollisionShape2D.disabled = true
        ALIVE:
            $CollisionShape2D.disabled = false
        INVULNERABLE:
            $CollisionShape2D.disabled = true
        DEAD:
            $CollisionShape2D.disabled = true
    state = new_state

每次你需要更改玩家的状态时,你将调用change_state()函数并传递新状态的值。然后,通过使用match语句,你可以执行伴随状态转换到新状态的任何代码。为了说明这一点,CollisionShape2D是通过new_state值启用/禁用的。在_ready()中,你指定初始状态——目前是ALIVE以便测试,但稍后你会将其更改为INIT

控制器

将以下变量添加到脚本中:

export (int) var engine_power
export (int) var spin_power

var thrust = Vector2()
var rotation_dir = 0

engine_powerspin_power控制飞船加速和转向的速度。在检查器中,将它们分别设置为50015000thrust将代表飞船引擎施加的力:当滑行时为(0, 0),当开启动力时为一个长度为engine_power的向量。rotation_dir将代表飞船转向的方向并施加扭矩,即旋转力。

默认情况下,物理设置提供了一些阻尼,这会减少物体的速度和旋转。在太空中,没有摩擦,所以为了真实感,不应该有任何阻尼。然而,为了达到街机风格的体验,当您松开按键时,飞船应该停止。在检查器中,将玩家的线性/阻尼设置为1,其角/阻尼设置为5

下一步是检测输入并移动船只:

func _process(delta):
    get_input()

func get_input():
    thrust = Vector2()
    if state in [DEAD, INIT]:
        return
    if Input.is_action_pressed("thrust"):
        thrust = Vector2(engine_power, 0)
    rotation_dir = 0
    if Input.is_action_pressed("rotate_right"):
        rotation_dir += 1
    if Input.is_action_pressed("rotate_left"):
        rotation_dir -= 1

func _physics_process(delta):
    set_applied_force(thrust.rotated(rotation))
    set_applied_torque(spin_power * rotation_dir)

get_input()函数捕获关键操作并设置飞船的推力开启或关闭,以及旋转方向(rotation_dir)为正或负值(表示顺时针或逆时针旋转)。此函数在_process()中每帧都会被调用。注意,如果状态是INITDEADget_input()将在检查按键操作之前使用return退出。

当使用物理体时,它们的移动和相关函数应在_physics_process()中调用。在这里,你可以使用set_applied_force()将引擎推力应用到飞船面向的任何方向。然后,你可以使用set_applied_torque()使飞船旋转。

播放场景后,你应该能够自由飞行。

屏幕卷曲

经典 2D 街机游戏的一个特点是屏幕卷曲。如果玩家离开屏幕的一侧,他们就会出现在另一侧。在实践中,你将传送或瞬间改变飞船的位置到另一侧。将以下内容添加到脚本顶部的类变量中:

var screensize = Vector2() 

并将以下内容添加到_ready()中:

screensize = get_viewport().get_visible_rect().size

之后,游戏的主脚本将处理设置所有游戏对象的screensize,但现在,这将允许你仅使用玩家场景测试屏幕卷曲。

当首次接近这个问题时,你可能认为可以使用身体的position属性,如果它超出屏幕边界,就将其设置为相反的一侧。然而,当使用RigidBody2D时,你不能直接设置其position,因为这会与物理引擎正在计算的移动冲突。一个常见的错误是尝试在_physics_process()中添加类似以下内容:

func _physics_process(delta):
    if position.x > screensize.x:
        position.x = 0
    if position.x < 0:
        position.x = screensize.x
    if position.y > screensize.y:
        position.y = 0
    if position.y < 0:
        position.y = screensize.y
    set_applied_force(thrust.rotated(rotation))
    set_applied_torque(rotation_dir * spin_thrust)

这将失败,将玩家困在屏幕边缘(并且偶尔在角落处不可预测地闪烁)。那么,为什么这不起作用呢?Godot 文档建议使用_physics_process()来编写与物理相关的代码——它甚至包含物理这个词。乍一看,这似乎应该能正常工作。

事实上,解决这个问题的正确方法不是使用_physics_process()

引用RigidBody2D文档:

“你不应该在每一帧或非常频繁地更改 RigidBody2D 的position或线性速度。如果你需要直接影响身体的state,请使用_integrate_forces,这允许你直接访问物理状态。”

并且在_integrate_forces()的描述中:

“(它)允许你读取和安全地修改对象的模拟状态。如果你需要直接更改身体的position或其他物理属性,请使用此方法代替_physics_process。(强调部分)”

答案是将物理回调更改为_integrate_forces(),这让你可以访问身体的Physics2DDirectBodyState。这是一个包含大量关于身体当前物理状态的有用信息的 Godot 对象。在位置方面,关键信息是身体的Transform2D

一个 变换 是一个表示二维空间中一个或多个变换(如平移、旋转和/或缩放)的矩阵。通过访问 Transform2Dorigin 属性可以找到平移(即位置)信息。

使用这些信息,你可以通过将 _physics_process() 改为 _integrate_forces() 并改变变换的原点来实现环绕效果:

func _integrate_forces(physics_state):
    set_applied_force(thrust.rotated(rotation))
    set_applied_torque(spin_power * rotation_dir)
    var xform = physics_state.get_transform()
    if xform.origin.x > screensize.x:
        xform.origin.x = 0
    if xform.origin.x < 0:
        xform.origin.x = screensize.x
    if xform.origin.y > screensize.y:
        xform.origin.y = 0
    if xform.origin.y < 0:
        xform.origin.y = screensize.y
    physics_state.set_transform(xform)

注意,函数的参数名称已从默认的 state 改为 physics_state。这是为了避免与已经存在的 state 变量产生任何可能的混淆,该变量跟踪玩家当前分配到的 FSM 状态。

再次运行场景并检查一切是否按预期工作。确保你尝试在所有四个方向上进行环绕。一个常见的错误是意外地翻转大于或小于符号,所以如果你在屏幕的某个边缘遇到问题,首先检查这一点。

射击

现在,是时候给你的飞船装备一些武器了。当按下 shoot 动作时,子弹应该从飞船的前端生成并沿直线飞行,直到它退出屏幕。然后,直到经过一小段时间后,枪才允许再次开火。

子弹场景

这是子弹的节点设置:

  • Area2D(命名为 Bullet

  • Sprite

  • CollisionShape2D

  • VisibilityNotifier2D

使用从资源文件夹 res://assets/laser.png 中的 laser.png 作为 Sprite 的纹理,以及 CapsuleShape2D 作为碰撞形状。你需要将 CollisionShape2D 的旋转设置为 90 以确保正确匹配。你还应该将 Sprite 缩小到一半大小((0.5, 0.5))。

将以下脚本添加到 Bullet 节点:

extends Area2D

export (int) var speed
var velocity = Vector2()

func start(pos, dir):
    position = pos
    rotation = dir
    velocity = Vector2(speed, 0).rotated(dir)

func _process(delta):
    position += velocity * delta

将导出的 speed 属性设置为 1000

VisibilityNotifier2D 是一个节点,它可以通知你(使用信号)每当一个节点变为可见或不可见时。你可以使用这个功能在子弹离开屏幕时自动删除它。连接 VisibilityNotifier2Dscreen_exited 信号并添加以下内容:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()

最后,连接子弹的 body_entered 信号,以便你可以检测到子弹击中岩石的情况。子弹不需要 知道 任何关于岩石的信息,只需知道它击中了某个东西。当你创建岩石时,你将它们添加到名为 rocks 的组中,并给它们一个 explode() 方法:

func _on_Bullet_body_entered( body ):
    if body.is_in_group('rocks'):
        body.explode()
        queue_free()

发射子弹

现在,每当玩家开火时,你需要创建子弹的实例。然而,如果你将子弹设置为玩家的子节点,那么它会随着玩家移动和旋转,而不是独立移动。相反,你应该将子弹添加到主场景的子节点。一种方法是通过使用get_parent().add_child()来实现,因为当游戏运行时,Main场景将是玩家的父节点。但是,这意味着你将无法像以前那样单独运行Player场景,因为get_parent()会产生错误。或者,如果在Main场景中你决定以不同的方式安排事物,使玩家成为某个其他节点的子节点,子弹就不会出现在你期望的位置。

通常,编写假设固定树布局的代码是一个坏主意。特别是尽可能避免使用get_parent()。一开始你可能觉得这种方式很难想通,但它将导致一个更模块化的设计,并防止一些常见的错误。

相反,玩家将通过信号将子弹“给予”主场景。这样,Player场景就不需要“知道”关于Main场景如何设置的信息,甚至不知道Main场景是否存在。生成子弹并将其传递是Player对象唯一的职责。

向玩家添加一个名为MuzzlePosition2D节点。这将标记枪的枪口——子弹将从中生成的位置。将其位置设置为(50, 0)以将其直接放置在船的前方。

接下来,添加一个名为GunTimerTimer节点。这将给枪提供一个冷却时间,防止在经过一定时间后再次发射子弹。勾选“单次射击”和“自动播放”框。

将以下新变量添加到玩家的脚本中:

signal shoot

export (PackedScene) var Bullet
export (float) var fire_rate

var can_shoot = true

Bullet.tscn拖放到检查器中的新Bullet属性上,并将射击频率设置为0.25(此值以秒为单位)。

将以下内容添加到_ready()中:

$GunTimer.wait_time = fire_rate

然后将以下内容添加到get_input()中:

if Input.is_action_pressed("shoot") and can_shoot:
    shoot()

现在,创建一个shoot()函数,该函数将处理创建子弹:

func shoot():
    if state == INVULNERABLE:
        return
    emit_signal("shoot", Bullet, $Muzzle.global_position, rotation)
    can_shoot = false
    $GunTimer.start()

当发射shoot信号时,你传递Bullet本身及其起始位置和方向。然后,通过can_shoot标志禁用射击,并启动GunTimer。为了允许枪再次射击,连接GunTimertimeout信号:

func _on_GunTimer_timeout():
    can_shoot = true

现在,创建你的主场景。添加一个名为MainNode和一个名为BackgroundSprite。使用res://assets/space_background.png作为纹理。将Player的实例添加到场景中。

Main添加一个脚本,然后连接Player节点的shoot信号,并将以下内容添加到创建的函数中:

func _on_Player_shoot(bullet, pos, dir):
    var b = bullet.instance()
    b.start(pos, dir)
    add_child(b)

播放Main场景并测试你是否可以飞行和射击。

岩石

游戏的目标是摧毁漂浮的太空岩石,因此,现在您能够射击,是时候添加它们了。像飞船一样,岩石也将是 RigidBody2D,这将使它们以恒定的速度直线运动,除非受到干扰。它们还会以逼真的方式相互弹跳。为了使事情更有趣,岩石最初是大的,当您射击它们时,会破碎成多个较小的岩石。

场景设置

通过创建一个 RigidBody2D,将其命名为 Rock,并使用 res://assets/rock.png 纹理添加一个 Sprite 来开始一个新的场景。添加一个 CollisionShape2D,但 不要 向其中添加形状。因为您将生成不同大小的岩石,碰撞形状需要在代码中设置并调整到正确的大小。

Rock 的弹跳属性设置为 1,并将线性/阻尼和角/阻尼都设置为 0

变量大小

添加一个脚本并定义成员变量:

extends RigidBody2D

var screensize = Vector2()
var size
var radius
var scale_factor = 0.2

Main 脚本将处理生成新的岩石,包括在关卡开始时以及在大岩石爆炸后出现的较小岩石。大岩石将具有 3size,并破碎成 2 尺寸的岩石,依此类推。scale_factor 乘以 size 以设置精灵的缩放、碰撞半径等。您可以在以后调整它以改变每种岩石的大小。

所有这些都将通过 start() 方法设置:

func start(pos, vel, _size):
    position = pos
    size = _size
    mass = 1.5 * size
    $Sprite.scale = Vector2(1, 1) * scale_factor * size
    radius = int($Sprite.texture.get_size().x / 2 * scale_factor * size)
    var shape = CircleShape2D.new()
    shape.radius = radius
    $CollisionShape2D.shape = shape
    linear_velocity = vel
    angular_velocity = rand_range(-1.5, 1.5)

这里您需要根据岩石的 size 计算正确的碰撞形状并将其添加到 CollisionShape2D。请注意,由于 size 已经作为类变量使用,您可以使用 _size 作为函数参数。

岩石还需要在屏幕周围环绕,因此使用您为 Player 使用过的相同技术:

func _integrate_forces(physics_state):
    var xform = physics_state.get_transform()
    if xform.origin.x > screensize.x + radius:
       xform.origin.x = 0 - radius
    if xform.origin.x < 0 - radius:
       xform.origin.x = screensize.x + radius
    if xform.origin.y > screensize.y + radius:
       xform.origin.y = 0 - radius
    if xform.origin.y < 0 - radius:
       xform.origin.y = screensize.y + radius
    physics_state.set_transform(xform)

这里的不同之处在于包含身体的 radius 会使得传送看起来更平滑。岩石看起来会完全退出屏幕,然后从对面进入。您可能还想对玩家飞船做同样的事情。试试看,看看您更喜欢哪种效果。

实例化

当生成新的岩石时,主场景需要选择一个随机的起始位置。为此,您可以使用一些几何形状来选择屏幕边缘的随机点,但您可以利用另一种 Godot 节点类型。您将在屏幕边缘绘制一个路径,脚本将选择路径上的一个随机位置。添加一个 Path2D 节点并将其命名为 RockPath。当您点击 Path2D 时,您将在编辑器的顶部看到一些新的按钮:

图片

选择中间的(添加点)通过点击添加显示的点来绘制路径。为了使点对齐,请确保启用“吸附到网格”。此选项位于“锁定”按钮左侧的“吸附选项”按钮下。它看起来像一系列三个垂直点。请参考以下截图:

图片

按照以下截图所示的顺序绘制点。点击第四个点后,点击关闭曲线按钮(5),你的路径将完成:

现在路径已经定义,将PathFollow2D节点作为RockPath的子节点添加,并命名为RockSpawn。此节点的作用是在移动时自动跟随路径,使用其set_offset()方法。偏移量越高,它沿着路径移动的距离就越远。由于我们的路径是闭合的,如果偏移值大于路径长度,它将循环。

接下来,添加一个Node并命名为Rocks。此节点将作为容器来保存所有岩石。通过检查其子节点数量,你可以判断是否还有剩余的岩石。

现在,将以下内容添加到Main.gd中:

export (PackedScene) var Rock

func _ready():
    randomize()
    screensize = get_viewport().get_visible_rect().size
    $Player.screensize = screensize
    for i in range(3):
        spawn_rock(3)

脚本首先获取screensize并将其传递给Player。然后,使用在以下代码中定义的spawn_rock(),生成三个大小为3的岩石。不要忘记将Rock.tscn拖放到检查器中的Rock属性:

func spawn_rock(size, pos=null, vel=null):
    if !pos:
        $RockPath/RockSpawn.set_offset(randi())
        pos = $RockPath/RockSpawn.position
    if !vel:
        vel = Vector2(1, 0).rotated(rand_range(0, 2*PI)) * rand_range(100, 150)
    var r = Rock.instance()
    r.screensize = screensize
    r.start(pos, vel, size)
    $Rocks.add_child(r)

此函数将有两个作用。当只传递一个大小参数时,它会在RockPath上随机选择一个位置和一个随机速度。然而,如果也提供了这些值,它将使用它们。这将允许你在爆炸的位置生成较小的岩石。

运行游戏后,你应该看到三个岩石在周围漂浮。然而,你的子弹不会影响它们。

爆炸岩石

Bullet正在检查rocks组中的身体,因此,在Rock场景中,点击节点选项卡并选择组。键入rocks并点击添加:

现在,如果你运行游戏并射击岩石,你会看到一个错误消息,因为子弹正在尝试调用岩石的explode()方法,但你还没有定义它。此方法需要做三件事:

  • 移除岩石

  • 播放爆炸动画

  • 通知Main生成新的、更小的岩石

爆炸场景

爆炸将是一个独立的场景,你可以将其添加到Rock和后来到Player。它将包含两个节点:

  • Sprite(命名为Explosion)

  • AnimationPlayer

对于精灵的纹理,使用res://assets/explosion.png。你会注意到这是一个精灵图集——由 64 个较小的图像组成的网格图案。这些图像是动画的单独帧。你经常会发现以这种方式打包的动画,并且 Godot 的Sprite节点支持将它们作为单独的帧使用。

在检查器中,找到精灵的动画部分。将 Vframes 和 Hframes 都设置为8。这将切割精灵图集成其单独的图像。你可以通过将帧属性更改为063之间的不同值来验证这一点。完成时,请确保将帧属性恢复到0

AnimationPlayer 可以用来动画化任何节点的任何属性。你将使用 AnimationPlayer 来随时间改变帧属性。首先点击节点,你将看到动画面板在底部打开,如下截图所示:

图片

点击新建动画按钮,将其命名为 explosion。设置长度为 0.64,步长为 0.01。现在,点击 Sprite 节点,你会注意到检查器中现在每个属性旁边都有一个键按钮。每次点击键,你就在当前动画中创建一个关键帧。帧属性旁边的键按钮上还有一个 + 符号,表示当你添加关键帧时,它将自动增加值。

点击键并确认你想要创建一个新的动画轨道。注意,帧属性已增加到 1。重复点击键按钮,直到达到最终帧(63)。

在动画面板中点击播放按钮,以查看动画播放。

添加到岩石

Rock 场景中,添加一个 Explosion 实例,并在 start() 中添加此行:

$Explosion.scale = Vector2(0.75, 0.75) * size

这将确保爆炸的缩放与岩石的大小相匹配。

在脚本顶部添加一个名为 exploded 的信号,然后添加 explode() 函数,该函数将在子弹击中岩石时被调用:

func explode():
    layers = 0
    $Sprite.hide()
    $Explosion/AnimationPlayer.play("explosion")
    emit_signal("exploded", size, radius, position, linear_velocity)
    linear_velocity = Vector2()
    angular_velocity = 0

layers 属性确保爆炸效果将被绘制在屏幕上其他精灵之上。然后,你将发送一个信号,让 Main 知道生成新的岩石。此信号还需要传递必要的数据,以便新岩石具有正确的属性。

当动画播放完毕后,AnimationPlayer 将发出一个信号。要连接它,你需要使 AnimationPlayer 节点可见。右键单击实例化的爆炸,选择 Editable Children,然后选择 AnimationPlayer 并连接其 animation_finished 信号。确保在连接到节点部分选择 Rock。动画的结束意味着可以安全地删除岩石:

func _on_AnimationPlayer_animation_finished( name ):
    queue_free()

现在,测试游戏并检查在射击岩石时是否可以看到爆炸效果。此时,你的岩石场景应该看起来像这样:

图片

生成较小的岩石

Rock 正在发出信号,但需要在 Main 中连接。你不能使用节点标签页来连接它,因为岩石实例是在代码中创建的。信号也可以在代码中连接。将此行添加到 spawn_rock() 的末尾:

r.connect('exploded', self, '_on_Rock_exploded')

这将把岩石的信号连接到 Main 中名为 _on_Rock_exploded() 的函数。创建该函数,它将在岩石发送其 exploded 信号时被调用:

func _on_Rock_exploded(size, radius, pos, vel):
    if size <= 1:
        return
    for offset in [-1, 1]:
        var dir = (pos - $Player.position).normalized().tangent() * offset
        var newpos = pos + dir * radius
        var newvel = dir * vel.length() * 1.1
        spawn_rock(size - 1, newpos, newvel)

在这个函数中,除非刚刚被摧毁的石头是它可能的最小尺寸,否则将创建两个新的石头。offset循环变量将确保它们向相反方向(即,一个将是另一个的负值)生成和移动。dir变量找到玩家和石头之间的向量,然后使用tangent()找到该向量的垂直向量。这确保了新石头会远离玩家:

再次玩游戏并检查一切是否按预期工作。

UI

创建游戏 UI 可能非常复杂,或者至少很耗时。精确放置单个元素并确保它们在不同大小的屏幕和设备上工作,对于许多程序员来说,是游戏开发中最不有趣的部分。Godot 提供了各种Control节点来协助这个过程。学习如何使用各种Control节点将有助于减轻创建游戏 UI 的痛苦。

对于这个游戏,你不需要一个非常复杂的 UI。游戏需要提供以下信息和交互:

  • 开始按钮

  • 状态信息(准备或游戏结束)

  • 分数

  • 生命计数器

以下是你将能够创建的预览:

创建一个新的场景,并添加一个名为HUDCanvasLayer作为根节点。UI 将通过使用 Godot 的Control布局功能构建在这个层上。

布局

Godot 的Control节点包括许多专门的容器。这些节点可以嵌套使用,以创建所需的精确布局。例如,MarginContainer会自动为其内容添加填充,而HBoxContainerVBoxContainer则分别按行或列组织其内容。

首先添加一个MarginContainer,它将包含分数和生命计数器。在布局菜单下,选择顶部宽。然后,向下滚动到自定义常量部分,将所有四个边距设置为20

接下来,添加一个HBoxContainer,它将包含左边的分数计数器和右边的生命计数器。在这个容器下,添加一个Label(命名为ScoreLabel)和另一个HBoxContainer(命名为LivesCounter)。

ScoreLabel的文本设置为0,在Size Flags下设置水平为填充、扩展。在自定义字体中,添加一个DynamicFont,就像你在第一章“简介”中做的那样,使用res://assets/kenvector_future_thin.ttfassets文件夹中设置大小为64

LivesCounter下添加一个TextureRect并命名为L1。将res://assets/player_small.png拖到纹理属性中,并将拉伸模式设置为保持宽高比居中。确保选中L1节点,然后按两次复制(Ctrl + D)来创建L2L3(它们将被自动命名)。在游戏中,HUD将显示/隐藏这三个纹理,以指示用户剩余多少生命。

在一个更大、更复杂的 UI 中,你可以将这一部分保存为其自己的场景,并将其嵌入 UI 的其他部分。然而,这个游戏只需要 UI 的几个更多组件,所以将它们全部组合在一个场景中是完全可以的。

作为HUD节点的子节点,添加一个名为StartButtonTextureButton,一个名为MessageLabelLabel,以及一个名为MessageTimerTimer

res://assets文件夹中,有两个用于StartButton的纹理,一个正常纹理(play_button.png)和一个当鼠标悬停时显示的纹理(play_button_h.png)。将它们分别拖到“Textures/Normal”和“Textures/Hover”属性中。在布局菜单中,选择居中。

对于MessageLabel,在指定布局之前,请确保首先设置字体,否则它将无法正确居中。你可以使用与ScoreLabel相同的设置。设置字体后,将布局设置为全矩形。

最后,将MessageTimer的“One Shot”属性设置为“On”以及其等待时间为2

完成后,你的 UI 场景树应该看起来像这样:

图片

UI 函数

你已经完成了 UI 布局,现在让我们给HUD添加一个脚本,以便你可以添加功能:

extends CanvasLayer

signal start_game

onready var lives_counter = [$MarginContainer/HBoxContainer/LivesCounter/L1,
                             $MarginContainer/HBoxContainer/LivesCounter/L2,
                             $MarginContainer/HBoxContainer/LivesCounter/L3]

当玩家点击StartButton时,将发出start_game信号。变量lives_counter是一个包含三个生命计数器图像引用的数组。名称相当长,所以请确保让编辑器的自动完成功能帮你填写,以避免出错。

接下来,你需要函数来处理更新显示信息:

func show_message(message):
    $MessageLabel.text = message
    $MessageLabel.show()
    $MessageTimer.start()

func update_score(value):
    $MarginContainer/MarginContainer/HBoxContainer/ScoreLabel.text = str(value)

func update_lives(value):
    for item in range(3):
        lives_counter[item].visible = value > item

每个函数将在值改变时被调用以更新显示。

接下来,添加一个处理“游戏结束”状态的功能:

func game_over():
    show_message("Game Over")
    yield($MessageTimer, "timeout")
    $StartButton.show()

现在,连接StartButtonpressed信号,以便它可以发出信号到Main

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal("start_game")

最后,连接MessageTimertimeout信号,以便它可以隐藏信息:

func _on_MessageTimer_timeout():
    $MessageLabel.hide()
    $MessageLabel.text = ''

主场景代码

现在,你可以在Main场景中添加一个HUD实例。将以下变量添加到Main.gd中:

var level = 0
var score = 0
var playing = false

这些将跟踪命名数量。以下代码将处理开始新游戏:

func new_game():
    for rock in $Rocks.get_children():
        rock.queue_free()
    level = 0
    score = 0
    $HUD.update_score(score)
    $Player.start()
    $HUD.show_message("Get Ready!")
    yield($HUD/MessageTimer, "timeout")
    playing = true
    new_level()

首先,你需要确保删除任何从上一局游戏留下的剩余岩石并初始化变量。不用担心玩家上的start()函数;你很快就会添加它。

在显示"Get Ready!"信息后,你将使用yield等待信息消失,然后实际开始关卡:

func new_level():
    level += 1
    $HUD.show_message("Wave %s" % level)
    for i in range(level):
        spawn_rock(3)

这个函数将在每次关卡改变时被调用。它宣布关卡编号并生成与数量相匹配的岩石。注意——由于你将level初始化为0,这将使它对于第一个关卡设置为1

为了检测关卡是否结束,你需要持续检查Rocks节点有多少个子节点:

func _process(delta):
    if playing and $Rocks.get_child_count() == 0:
        new_level()

现在,你需要将 HUD 的 start_game 信号(在按下播放按钮时发出)连接到 new_game() 函数。选择 HUD,点击节点标签页,并连接 start_game 信号。将“Make Function”设置为关闭,并在“Method In Node”字段中键入 new_game

接下来,添加以下函数来处理游戏结束时发生的事情:

func game_over():
    playing = false
    $HUD.game_over()

播放游戏并检查按下播放按钮是否开始游戏。注意,Player 目前处于 INIT 状态,所以你还不能飞来飞去——Player 还不知道游戏已经开始。

玩家代码

Player.gd 中添加一个新的信号和一个新的变量:

signal lives_changed

var lives = 0 setget set_lives

在 GDScript 中,setget 语句允许你指定一个函数,每当给定变量的值发生变化时,该函数将被调用。这意味着当 lives 减少,你可以发出一个信号让 HUD 知道它需要更新显示:

func set_lives(value):
    lives = value
    emit_signal("lives_changed", lives)

当新游戏开始时,Main 会调用 start() 函数:

func start():
    $Sprite.show()
    self.lives = 3
    change_state(ALIVE)

当使用 setget 时,如果你在本地(在本地脚本中)访问变量,必须在变量名前加上 self.。如果不这样做,setget 函数将不会被调用。

现在,你需要将来自 Player 的此信号连接到 HUD 中的 update_lives 方法。在 Main 中,点击 Player 实例,并在节点标签页中找到其 lives_changed 信号。点击连接,在连接窗口中,在“Connect to Node”下选择 HUD。对于“Method In Node”,键入 update_lives。确保“Make Function”已关闭,并按连接,如图所示:

图片

游戏结束

在本节中,你将使玩家检测它被岩石击中,添加一个无敌特性,并在玩家生命耗尽时结束游戏。

Explosion 的一个实例添加到 Player,以及一个 Timer 节点(命名为 InvulnerabilityTimer)。在检查器中,将 InvulnerabilityTimer 的“Wait Time”设置为 2 并将其 One Shot 设置为开启。将此添加到 Player.gd 的顶部:

signal dead

此信号将通知 Main 场景玩家生命耗尽且游戏结束。在此之前,你需要更新状态机以在每个状态下做更多的事情:

func change_state(new_state):
    match new_state:
        INIT:
            $CollisionShape2D.disabled = true
            $Sprite.modulate.a = 0.5
        ALIVE:
            $CollisionShape2D.disabled = false
            $Sprite.modulate.a = 1.0
        INVULNERABLE:
            $CollisionShape2D.disabled = true
            $Sprite.modulate.a = 0.5
            $InvulnerabilityTimer.start()
        DEAD:
            $CollisionShape2D.disabled = true
            $Sprite.hide()
            linear_velocity = Vector2()
            emit_signal("dead")
    state = new_state

一个精灵的 modulate.a 属性设置了其 alpha 通道(透明度)。将其设置为 0.5 使其半透明,而 1.0 是不透明的。

进入 INVULNERABLE 状态后,你开始 InvulnerabilityTimer。连接其 timeout 信号:

func _on_InvulnerabilityTimer_timeout():
    change_state(ALIVE)

此外,像在 Rock 场景中那样连接 Explosion 动画的 animation_finished 信号:

func _on_AnimationPlayer_animation_finished( name ):
    $Explosion.hide()

检测物理实体之间的碰撞

当你飞行时,玩家飞船会从岩石上弹开,因为这两个物体都是RigidBody2D节点。然而,如果你想当两个刚体碰撞时发生某些事情,你需要启用接触监控。选择Player节点,并在检查器中设置接触监控为开启。默认情况下,不会报告任何接触,因此你还必须将接触报告设置为1。现在,当身体接触另一个身体时,它将发出信号。点击节点标签页,并连接body_entered信号:

func _on_Player_body_entered( body ):
    if body.is_in_group('rocks'):
        body.explode()
        $Explosion.show()
        $Explosion/AnimationPlayer.play("explosion")
        self.lives -= 1
        if lives <= 0:
            change_state(DEAD)
        else:
            change_state(INVULNERABLE)

现在,转到场景,并将玩家的死亡信号连接到game_over()函数。玩玩游戏,尝试撞上岩石。你的飞船应该会爆炸,变得无敌(两秒钟),并失去一条生命。检查如果你被击中三次,游戏是否会结束。

暂停游戏

许多游戏都需要某种暂停模式,以便玩家在动作中休息。在 Godot 中,暂停是场景树的一个功能,可以使用get_tree().paused = true来设置。当SceneTree暂停时,会发生三件事:

  • 物理线程停止运行

  • _process_physics_process不再被调用,因此那些方法中的代码不再运行

  • _input_input_event也没有被调用

当暂停模式被触发时,正在运行的游戏中的每个节点都可以根据你的配置做出相应的反应。这种行为是通过节点的暂停/模式属性设置的,你可以在检查器列表的最底部找到它。

暂停模式可以设置为三个值:INHERIT(默认值)、STOPPROCESSSTOP表示在树暂停时节点将停止处理,而PROCESS将节点设置为继续运行,忽略树的暂停状态。由于在游戏中为每个节点设置此属性会很麻烦,INHERIT允许节点使用与父节点相同的暂停模式。

打开输入映射标签页(在项目设置中),创建一个新的输入动作,命名为pause。选择你想要用来切换暂停模式的键;例如,P 是一个不错的选择。

接下来,将以下函数添加到Main.gd中,以响应输入动作:

func _input(event):
    if event.is_action_pressed('pause'):
        if not playing:
            return
    get_tree().paused = not get_tree().paused
    if get_tree().paused:
        $HUD/MessageLabel.text = "Paused"
        $HUD/MessageLabel.show()
    else:
        $HUD/MessageLabel.text = ""
        $HUD/MessageLabel.hide()

如果你现在运行游戏,你会遇到问题——所有节点都处于暂停状态,包括Main。这意味着由于它没有处理_input,它无法再次检测输入来暂停游戏!为了解决这个问题,你需要将MainPause/Mode设置为PROCESS。现在,你遇到了相反的问题:Main下面的所有节点都继承了这个设置。这对大多数节点来说是可以的,但你需要在这三个节点上设置模式为STOPPlayerRocksHUD

敌人

空间中充满了比岩石更多的危险。在本节中,你将创建一个会定期出现并向玩家开火的敌人士兵飞船。

沿着路径移动

当敌人出现时,它应该在屏幕上跟随一条路径。为了防止它看起来过于重复,你可以创建多条路径,并在敌人开始时随机选择一条。

创建一个新的场景并添加一个 Node。将其命名为 EnemyPaths 并保存场景。要绘制路径,添加一个 Path2D 节点。正如您之前所看到的,此节点允许您绘制一系列连接的点。添加节点时,会出现一个新的菜单栏:

图片

这些按钮允许您绘制和修改路径的点。点击带有 + 符号的按钮以添加点。点击以在游戏窗口(蓝色紫色矩形)的外侧开始路径,然后点击几个更多点以创建一个曲线。暂时不用担心让它变得平滑:

图片

当敌舰跟随路径时,当它遇到尖锐的角落时,看起来不会非常平滑。为了平滑曲线,点击路径工具栏中的第二个按钮(其工具提示说选择控制点)。现在,如果您点击并拖动曲线的任何点,您将添加一个控制点,允许您调整线和曲线的角度。平滑前面的线会产生类似这样的效果:

图片

向场景中添加更多 Path2D 节点,并按照您喜欢的样式绘制路径。添加循环和曲线而不是直线会使敌人看起来更加动态(并且更难被击中)。请记住,您点击的第一个点将是路径的起点,因此请确保将它们放置在屏幕的不同侧面,以增加多样性。以下是一些示例路径:

图片

保存场景。您将将其添加到敌人的场景中,以提供它可以跟随的路径。

敌人场景

为敌人创建一个新的场景,使用 Area2D 作为其根节点。添加一个 Sprite 并使用 res://assets/enemy_saucer.png 作为其纹理。将动画/帧数设置为 3 以便在不同颜色的飞船之间进行选择:

图片

如您之前所做的那样,添加一个 CollisionShape2D 并将其 CircleShape2D 缩放以覆盖精灵图像。接下来,添加一个 EnemyPaths 场景实例和一个 AnimationPlayer。在 AnimationPlayer 中,您需要两个动画:一个用于使飞碟在移动时旋转,另一个用于当飞碟被击中时产生闪光效果:

  • 旋转动画:添加一个名为 rotate 的新动画,并将其 长度 设置为 3。在将 Sprite 的 Transform/Rotation Degrees 属性设置为 0 后,添加一个关键帧,然后将播放条拖动到末尾并添加一个旋转设置为 360 的关键帧。点击循环按钮和自动播放按钮。

  • 击中动画:添加一个名为 flash 的第二个动画。将其 长度 设置为 0.25,将 步长 设置为 0.01。您将动画化的属性是精灵的 Modulate(在 可见性 下找到)。为 Modulate 添加一个关键帧以创建轨迹,然后将刮擦器移动到 0.04 并将 Modulate 颜色更改为红色。再向前移动 0.04 并将颜色改回白色。

重复此过程两次,以便总共有三个闪光效果。

如同其他对象一样,添加Explosion场景的实例。同样,就像岩石一样,连接爆炸的AnimationPlayeranimation_finished信号,并在爆炸完成后删除敌人:

func _on_AnimationPlayer_animation_finished(anim_name):
    queue_free()

接下来,添加一个名为GunTimerTimer节点,它将控制敌人射击玩家的频率。将其等待时间设置为1.5,自动启动设置为On。连接其timeout信号,但现在请保留代码读取为pass

最后,点击Area2D和节点标签,并将其添加到名为enemies的组中。就像岩石一样,这将为你提供一种识别对象的方法,即使屏幕上同时有多个敌人。

移动敌人

将脚本附加到Enemy场景。首先,你将编写选择路径并使敌人沿着该路径移动的代码:

extends Area2D

signal shoot

export (PackedScene) var Bullet
export (int) var speed = 150
export (int) var health = 3

var follow
var target = null

func _ready():
    $Sprite.frame = randi() % 3
    var path = $EnemyPaths.get_children()[randi() % $EnemyPaths.get_child_count()]
    follow = PathFollow2D.new()
    path.add_child(follow)
    follow.loop = false

PathFollow2D节点是一种可以自动沿着父Path2D移动的节点。默认情况下,它设置为在路径上循环,因此你需要手动将属性设置为false

下一步是沿着路径移动:

func _process(delta):
    follow.offset += speed * delta
    position = follow.global_position
    if follow.unit_offset > 1:
        queue_free()

offset大于路径总长度时,你可以检测路径的结束。然而,使用unit_offset会更简单,它在路径长度上从零变化到一。

生成敌人

打开Main场景,并添加一个名为EnemyTimerTimer节点。将其一次性属性设置为On。然后,在Main.gd中添加一个变量来引用你的敌人场景(在保存脚本后将其拖动到检查器中):

export (PackedScene) var Enemy

将以下代码添加到new_level()中:

$EnemyTimer.wait_time = rand_range(5, 10)
$EnemyTimer.start()

连接EnemyTimertimeout信号,并添加以下内容:

func _on_EnemyTimer_timeout():
    var e = Enemy.instance()
    add_child(e)
    e.target = $Player
    e.connect('shoot', self, '_on_Player_shoot')
    $EnemyTimer.wait_time = rand_range(20, 40)
    $EnemyTimer.start()

EnemyTimer计时器超时时,此代码实例化敌人。当你给敌人添加射击功能时,它将使用与Player相同的流程,因此你可以重用相同的子弹生成函数,即_on_Player_shoot()

玩这个游戏,你应该会看到一个飞碟出现在你的路径之一上。

敌人射击和碰撞

敌人需要射击玩家,并且当被玩家或玩家的子弹击中时也要做出反应。

打开Bullet场景,并将其另存为EnemyBullet.tscn(之后,别忘了将根节点也重命名)。通过选择根节点并点击清除脚本按钮来删除脚本:

图片 2

你还需要通过点击节点标签并选择断开连接来断开信号连接:

图片 1

assets文件夹中还有一个不同的纹理,你可以使用它使敌人子弹看起来与玩家的子弹不同。

此脚本将与普通子弹非常相似。连接区域的body_entered信号和VisibilityNotifier2Dscreen_exited信号:

extends Area2D

export (int) var speed

var velocity = Vector2()

func start(_position, _direction):
    position = _position
    velocity = Vector2(speed, 0).rotated(_direction)
    rotation = _direction

func _process(delta):
    position += velocity * delta

func _on_EnemyBullet_body_entered(body):
    queue_free()

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()

目前,子弹对玩家不会造成任何伤害。你将在下一节中为玩家添加护盾,因此你可以同时添加它。

保存场景,并将其拖动到EnemyBullet属性上。

Enemy.gd中添加shoot函数:

func shoot():
    var dir = target.global_position - global_position
    dir = dir.rotated(rand_range(-0.1, 0.1)).angle()
    emit_signal('shoot', Bullet, global_position, dir)

首先,你必须找到指向玩家位置的向量,然后给它添加一点随机性,这样子弹就不会沿着完全相同的路径飞行。

为了增加挑战,你可以让敌人以脉冲的形式射击,或者进行多次快速射击:

func shoot_pulse(n, delay):
    for i in range(n):
        shoot()
        yield(get_tree().create_timer(delay), 'timeout')

这个函数创建了一定数量的子弹,它们之间有delay时间间隔。你可以使用这个函数 whenever the GunTimer 触发射击:

func _on_GunTimer_timeout():
    shoot_pulse(3, 0.15)

这将发射一串3个子弹,它们之间有0.15秒的间隔。很难躲避!

接下来,当敌人被玩家的射击击中时,它需要受到伤害。它将使用你制作的动画闪烁,然后当其健康值达到0时爆炸。

将以下功能添加到Enemy.gd中:

func take_damage(amount):
    health -= amount
    $AnimationPlayer.play('flash')
    if health <= 0:
        explode()
    yield($AnimationPlayer, 'animation_finished')
    $AnimationPlayer.play('rotate')

func explode():
    speed = 0
    $GunTimer.stop()
    $CollisionShape2D.disabled = true
    $Sprite.hide()
    $Explosion.show()
    $Explosion/AnimationPlayer.play("explosion")
    $ExplodeSound.play()

此外,连接区域的body_entered信号,这样当玩家撞到敌人时,敌人会爆炸:

func _on_Enemy_body_entered(body):
    if body.name == 'Player':
        pass
    explode()

再次强调,你正在等待玩家护盾将伤害添加到玩家身上,所以现在暂时留下pass占位符。

目前,玩家的子弹只检测物理体,因为它的body_entered信号已连接。然而,敌人是一个Area2D,所以它不会触发该信号。为了检测敌人,你还需要连接area_entered信号:

func _on_Bullet_area_entered(area):
    if area.is_in_group('enemies'):
        area.take_damage(1)
    queue_free()

再次尝试玩游戏,你将与一个侵略性的外星对手战斗!验证所有碰撞组合是否被处理。此外,请注意敌人的子弹可以被岩石阻挡——也许你可以躲在它们后面作为掩护!

额外功能

游戏的结构已经完成。你可以开始游戏,玩一遍,当它结束时,再次玩。在本节中,你将添加一些额外的效果和功能来改善游戏体验。效果是一个广泛的概念,可以指许多不同的技术,但在这个案例中,你将具体解决三件事:

  • 音效和音乐:音频往往被忽视,但可以是游戏设计中非常有效的部分。好的音效可以提升游戏的感觉。糟糕或令人讨厌的声音可能会引起无聊或挫败感。你将添加一些充满动作的背景音乐,以及游戏中几个动作的声音效果。

  • 粒子效果:粒子效果是图像,通常是小的,由粒子系统生成并动画化。它们可以用于无数令人印象深刻的视觉效果。Godot 的粒子系统非常强大;在这里完全探索它可能过于强大,但你会学到足够的知识来开始实验。

  • 玩家护盾:如果你觉得游戏太难,尤其是在有大量岩石的高级关卡中,给玩家添加一个护盾将大大提高你的生存机会。你还可以让大岩石对护盾造成的伤害比小岩石更多。你还会在 HUD 上制作一个漂亮的显示条,以显示玩家剩余的护盾等级。

音频/音乐

res://assets/sounds文件夹中,有几个包含不同 OggVorbis 格式声音的音频文件。默认情况下,Godot 会将导入的.ogg文件设置为循环播放。在explosion.ogglaser_blast.ogglevelup.ogg的情况下,你不想让声音循环,因此需要更改这些文件的导入设置。为此,在文件系统窗口中选择文件,然后点击位于编辑器窗口右侧场景标签旁边的导入标签。取消循环旁边的框,然后点击重新导入。为这三个声音都这样做。参考以下截图:

截图

要播放声音,需要通过AudioStreamPlayer节点加载。在Player场景中添加两个这样的节点,分别命名为LaserSoundEngineSound。将相应的声音拖入每个节点的 Stream 属性中,在检查器中进行操作。要在射击时播放声音,请将以下行添加到Player.gd中的shoot()函数:

$LaserSound.play()

播玩游戏并尝试射击。如果你觉得声音有点响,可以调整 Volume Db 属性。尝试使用-10的值。

引擎声音的工作方式略有不同。它需要在推力开启时播放,但如果你尝试在get_input()函数中直接play()声音,只要按下输入,声音就会在每一帧重新开始播放。这听起来不太好,所以你只想在声音尚未播放时开始播放。以下是get_input()函数中的相关部分:

if Input.is_action_pressed("thrust"):
    thrust = Vector2(engine_power, 0)
    if not $EngineSound.playing:
        $EngineSound.play()
 else:
     $EngineSound.stop()

注意,如果玩家在按住推力键的情况下死亡,引擎声音会卡在播放状态。这可以通过在change_state()函数中的DEAD状态添加$EngineSound.stop()来解决。

Main场景中,添加三个额外的AudioStreamPlayer节点:ExplodeSoundLevelupSoundMusic。在它们的 Stream 属性中,分别放置explosion.ogglevelup.oggFunky-Gameplay_Looping.ogg

_on_Rock_exploded()的第一行添加$ExplodeSound.play(),并将$LevelupSound.play()添加到new_level()中。

要开始/停止音乐,请在new_game()中添加$Music.play(),在game_over()中添加$Music.stop()

敌人也需要一个ExplodeSound和一个ShootSound。你可以使用与玩家相同的爆炸声,但有一个enemy_laser.wav声音用于射击。

粒子

玩家飞船的推力是使用粒子效果的完美例子,可以从引擎中创建一条流动的火焰。在Player场景中添加一个Particles2D节点,并将其命名为Exhaust。你可能需要在执行这部分操作时放大飞船图像。

当首次创建时,Particles2D节点有一个警告:未分配处理粒子的材质。粒子将不会发射,直到你在检查器中分配一个Process Material。有两种类型的材质:ShaderMaterialParticlesMaterialShaderMaterial允许你用类似 GLSL 的语言编写着色器代码,而ParticlesMaterial在检查器中配置。在Particles Material旁边点击向下箭头,然后选择“新建 ParticlesMaterial”。

你会看到从玩家飞船中心流下的一行白色点。你现在的挑战是将这些点变成尾气火焰。

在配置粒子时,有非常多的属性可供选择,尤其是在ParticlesMaterial下。在开始之前,设置这些Particles2D的属性:

  • 数量:25

  • 变换/位置:*(-28, 0)

  • 变换/旋转:180

  • 可见性/显示在父级之后:开启

现在,点击ParticlesMaterial。这是你找到影响粒子行为的大多数属性的地方。从发射形状开始——将其更改为框。这将揭示框范围,应设置为(1, 5, 1)。现在,粒子是在一个小区域内发射,而不是从单个点发射。

接下来,将扩散/扩散设置为0,将重力/重力设置为(0, 0, 0)。现在,粒子不会下落或扩散,但它们移动得非常慢。

下一个属性是初始速度。将速度设置为400。然后,向下滚动到缩放并设置为8

要使大小随时间变化,你可以设置一个缩放曲线。点击“新建曲线纹理”并点击它。会出现一个新的标签为“曲线”的面板。左侧的点代表起始缩放,右侧的点代表结束。将右侧的点向下拖动,直到你的曲线看起来像这样:

现在,粒子随着年龄的增长而缩小。点击检查器顶部的左箭头返回上一部分。

最后要调整的部分是颜色。为了让粒子看起来像火焰,粒子应该从明亮的橙黄色开始,逐渐变为红色,同时逐渐消失。在颜色渐变属性中,点击“新建渐变纹理”。然后,在渐变属性中,选择“新建渐变”:

标有 1 和 2 的滑块选择起始和结束颜色,而 3 显示当前所选滑块上设置的颜色。点击滑块 1,然后点击 3 选择橙色,然后点击滑块 2 并将其设置为深红色。

现在我们已经可以看到粒子在做什么了,它们持续的时间太长了。回到Exhaust节点,将寿命改为0.1

希望你的飞船尾气看起来有点像火焰。如果不像,请随意调整ParticlesMaterial属性,直到你满意为止。

现在船的Exhaust已配置,需要根据玩家输入开启/关闭。转到玩家脚本,并在get_input()的开始处添加$Exhaust.emitting = false。然后,在检查推力输入的if语句下添加$Exhaust.emitting = true

敌人轨迹

你也可以使用粒子在敌人后面创建轨迹效果。将Particles2D添加到敌人场景中,并设置以下属性:

  • 数量:20

  • 本地坐标:Off

  • 纹理:res://assets/corona.png

  • 在父元素后面显示:On

注意你使用的纹理效果是白色背景上的白色。这张图片需要更改其混合模式。为此,在粒子节点上,找到材质属性(它在CanvasItem部分)。选择新的CanvasItemMaterial,然后在生成的材质中,将混合模式更改为Add

现在,创建一个ParticlesMaterial,就像你之前所做的那样,并使用以下设置:

  • 发射形状:

    • 形状:Box

    • 箱体范围:(25, 25, 1)

  • 扫描范围:25

  • 重力:(0, 0, 0)

现在,创建一个ScaleCurve,就像你为玩家排气所做的那样。这次,使曲线看起来像以下这样:

尝试运行游戏并查看效果。你可以随意调整设置,直到你满意为止。

玩家护盾

在本节中,你将为玩家添加一个护盾,并在HUD中添加一个显示当前护盾等级的显示元素。

首先,将以下内容添加到Player.gd脚本的顶部:

signal shield_changed

export (int) var max_shield
export (float) var shield_regen

var shield = 0 setget set_shield

shield变量将类似于lives,每当它改变时都会向HUD发送信号。保存脚本,并在检查器中将max_shield设置为100,将shield_regen设置为5

接下来,添加以下函数,该函数处理更改护盾值:

func set_shield(value):
    if value > max_shield:
        value = max_shield
    shield = value
    emit_signal("shield_changed", shield/max_shield)
    if shield <= 0:
        self.lives -= 1

此外,由于一些事情,如再生,可能会增加护盾的值,你需要确保它不会超过最大允许值。然后,当你发送shield_changed信号时,传递shield/max_shield的比率。这样,HUD 的显示就不需要了解实际值,只需了解护盾的相对状态。

将此行添加到start()set_lives()中:

    self.shield = max_shield

击中岩石会损坏护盾,较大的岩石应该造成更多伤害:

func _on_Player_body_entered( body ):
    if body.is_in_group('rocks'):
        body.explode()
        $Explosion.show()
        $Explosion/AnimationPlayer.play("explosion")
        self.shield -= body.size * 25

敌人的子弹也应该造成伤害,所以更新EnemyBullet.gd中的以下内容:

func _on_EnemyBullet_body_entered(body):
    if body.name == 'Player':
        body.shield -= 15
    queue_free()

此外,撞到敌人应该会伤害玩家,所以更新Enemy.gd中的以下内容:

func _on_Enemy_body_entered(body):
    if body.name == 'Player':
        body.shield -= 50
        explode()

玩家脚本中的最后一个添加是每帧再生护盾。将此行添加到_process()中:

    self.shield += shield_regen * delta

下一步是向HUD添加显示元素。而不是在Label中显示护盾的值,你将使用TextureProgress节点。这是一个Control节点,它是一种ProgressBar:一个显示给定值的填充条的节点。TextureProgress节点允许你为条形显示分配纹理。

在现有的HBoxContainer中添加TextureRectTextureProgress。将它们放置在ScoreLabel之后和LivesCounter之前。将TextureProgress的名称改为 ShieldBar。你的节点设置应该看起来像这样:

图片

res://assets/shield_gold.png纹理拖动到TextureRectTexture属性中。这将是一个图标,表示条形显示的内容。

ShieldBar 有三个纹理属性:Under、Over 和 Progress。Progress 是作为条形值显示的纹理。将res://assets/barHorizontal_green_mid 200.png拖动到这个属性中。其他两个纹理属性允许你通过设置图像来自定义外观,该图像将被绘制在进度纹理的下方或上方。将res://assets/glassPanel_200.png拖动到Over纹理属性中。

范围部分,你可以设置条形的数值属性。最小值Min Value和最大值Max Value应设置为0100,因为这条条形将显示护盾的百分比值,而不是其原始值。值是控制当前显示填充值的属性。将其更改为75以查看条形部分填充。同时,设置其水平大小标志为填充、扩展。

现在,你可以更新 HUD 脚本以控制护盾条。在顶部添加以下变量:

onready var ShieldBar = $MarginContainer/HBoxContainer/ShieldBar
var red_bar = preload("res://assets/barHorizontal_red_mid 200.png")
var green_bar = preload("res://assets/barHorizontal_green_mid 200.png")
var yellow_bar = preload("res://assets/barHorizontal_yellow_mid 200.png")

除了绿色的条形纹理外,你还在assets文件夹中有红色和黄色的条形。这将允许你随着值的降低改变护盾的颜色。以这种方式加载纹理使得在脚本中稍后更容易访问,当你想要为TextureProgress节点分配适当的图像时:

func update_shield(value):
    ShieldBar.texture_progress = green_bar
    if value < 40:
        ShieldBar.texture_progress = red_bar
    elif value < 70:
        ShieldBar.texture_progress = yellow_bar
    ShieldBar.value = value

最后,点击Main场景的Player节点,并将shield_changed信号连接到你刚刚创建的update_shield()函数。运行游戏并验证你是否能看到护盾并且它正在工作。你可能想要增加或减少再生速率以调整到你喜欢的高速。

摘要

在本章中,你学习了如何使用RigidBody2D节点,并更深入地了解了 Godot 物理的工作原理。你还实现了一个基本的有限状态机——随着你的项目越来越大,你会发现它越来越有用。你看到了Container节点如何帮助组织和保持 UI 节点对齐。最后,你添加了一些音效,并通过使用AnimationPlayerParticles2D节点,第一次尝到了高级视觉效果的滋味。

你还使用标准的 Godot 层次结构创建了许多游戏对象,例如将CollisionShapes附加到CollisionObjects。在这个阶段,一些这些节点配置应该开始对你来说变得熟悉。

在继续之前,再次查看项目。播放它。确保你理解每个场景在做什么,并阅读脚本以回顾一切是如何连接在一起的。

在下一章中,你将学习关于运动体,并使用它们来创建一个侧滚动平台游戏。

第五章:Jungle Jump (平台游戏)

在本章中,您将构建一个经典的平台风格游戏,遵循超级马里奥兄弟的传统。平台游戏是一个非常受欢迎的类型,了解它们的工作原理可以帮助您制作各种不同的游戏风格。平台游戏的物理可能具有欺骗性的复杂性,您将看到 Godot 的KinematicBody2D物理节点具有帮助您实现所需的角色控制器功能的特性。请看以下截图:

在本项目,您将了解:

  • 使用KinematicBody2D物理节点

  • 结合动画和用户输入以产生复杂的角色行为

  • 使用 ParallaxLayers 创建无限滚动的背景

  • 组织您的项目并规划扩展

项目设置

创建一个新的项目。在您从以下链接下载资源之前,您需要为游戏艺术准备导入设置。本项目使用的艺术资源采用像素艺术风格,这意味着它们在没有过滤的情况下看起来最好,这是 Godot 为纹理的默认设置。过滤是一种通过平滑图像像素的方法。它可以改善某些艺术作品的外观,但不是基于像素的图像:

对于每个图像都必须禁用此功能是不方便的,因此 Godot 允许您自定义默认导入设置。在文件系统浮动窗口中单击icon.png文件,然后单击右侧场景标签旁边的导入标签。此窗口允许您更改所选文件的导入设置。取消选中过滤属性,然后单击预设并选择将“纹理”设置为默认值。这样,所有图像都将导入时禁用过滤。请参考以下截图:

如果您已经导入了图像,它们的导入设置不会自动更新。在更改默认设置后,您必须重新导入任何现有图像。您可以在文件系统浮动窗口中选择多个文件,然后单击重新导入按钮,一次性应用设置到多个文件。

现在,您可以从以下链接下载游戏资源,并在您的项目文件夹中解压缩它们。Godot 将使用新的默认设置导入所有图像,github.com/PacktPublishing/Godot-Game-Engine-Projects/releases

接下来,打开项目 | 项目设置,在渲染/质量下,将使用像素捕捉设置为开启。这将确保所有图像都能正确对齐——这在您设计游戏关卡时将非常重要。

当您打开设置窗口时,转到显示/窗口部分,将拉伸/模式更改为2d并将纵横比更改为expand。这些设置将允许用户在保持图像质量的同时调整游戏窗口的大小。一旦项目完成,您将能够看到此设置的效果。

接下来,设置碰撞层名称,以便更方便地设置不同类型对象之间的碰撞。转到“层名称/2d Physics”并按如下方式命名前四个层:

最后,在“输入映射”选项卡下“项目”|“项目设置”中为玩家控制添加以下动作:

动作名称 按键(s)
向右 D, →
向左 A, ←
跳跃 空格
蹲下 S, ↓
爬升 W, ↑

介绍运动学体

平台游戏需要重力、碰撞、跳跃和其他物理行为,因此您可能会认为 RigidBody2D 是实现角色移动的完美选择。然而,在实践中,您会发现刚体的真实物理特性对于平台角色来说并不理想。对于玩家来说,真实感不如响应控制和动作感重要。因此,作为开发者,您希望对角色的移动和碰撞响应有精确的控制。因此,对于平台角色来说,运动学体通常是更好的选择。

KinematicBody2D 节点是为了实现那些需要由用户直接控制或通过代码控制的物体而设计的。这些节点在移动时检测与其他物体的碰撞,但不受全局物理属性(如重力或摩擦)的影响。这并不意味着运动学体不能受到重力和其他力的作用,只是您必须在代码中计算这些力和它们的效果;引擎不会自动移动运动学体。

当移动 KinematicBody2D 时,与 RigidBody2D 类似,您不应直接设置其 position。相反,您可以使用 move_and_collide()move_and_slide() 方法。这些方法沿着给定的向量移动身体,并在检测到与其他物体的碰撞时立即停止。在 KinematicBody2D 碰撞后,任何碰撞响应都必须手动编码。

碰撞响应

在碰撞后,您可能希望物体弹跳、沿墙壁滑动或改变它撞击物体的属性。您处理碰撞响应的方式取决于您用于移动物体的方法。

move_and_collide

当使用 move_and_collide() 时,在碰撞发生时,该函数返回一个 KinematicCollision2D 对象。此对象包含有关碰撞和碰撞体的信息。您可以使用这些信息来确定响应。请注意,当运动成功完成且没有碰撞时,函数返回 null

例如,如果您想让物体从碰撞对象上弹开,可以使用以下脚本:

extends KinematicBody2D

var velocity = Vector2(250, 250)

func _physics_process(delta):
    var collide = move_and_collide(velocity * delta)
    if collide:
        velocity = velocity.bounce(collide.normal)

move_and_slide

滑动是碰撞响应中一个非常常见的选项。想象一下玩家在俯视游戏中沿着墙壁移动或在平台游戏中上下跑斜坡。虽然在使用move_and_collide()后可以自己编写这个响应的代码,但move_and_slide()提供了一个方便的方式来实现滑动移动。当使用这种方法时,物体将自动沿着碰撞表面滑动。此外,滑动碰撞允许你使用is_on_floor()等方法来检测碰撞表面的方向。

由于这个项目不仅需要在地面上移动,还需要在斜坡上跑上跑下,move_and_slide() 函数将在你的玩家移动中扮演重要角色。当你构建玩家对象时,你会看到它是如何工作的。

玩家场景

打开一个新的场景,并将名为PlayerKinematicBody2D对象作为根添加到场景中,并保存场景(别忘了点击“使子对象不可选择”按钮)。当保存Player场景时,你还应该创建一个新的文件夹来包含它。这将有助于在你添加更多场景和脚本时保持你的项目文件夹组织有序。

就像你在其他项目中做的那样,你将在Player场景中包含玩家角色需要的功能节点。对于这个游戏,这意味着处理与各种游戏对象的碰撞,包括平台、敌人和可收集物品;显示跑步或跳跃等动作的动画;以及一个跟随玩家在关卡中移动的摄像头。

编写各种动画可能会很快变得难以管理,所以你会使用一个有限状态机来管理和跟踪玩家的状态。参见第三章,逃离迷宫,回顾如何构建简化的 FSM。你将遵循类似的项目模式。

碰撞层/掩码

一个物体的碰撞层属性设置了这个物体所在的层。Player需要分配到你在项目设置中命名的玩家层。

碰撞/掩码属性允许你设置身体将检测的对象类型。将Player层设置为player,其掩码设置为环境、敌人和可收集物品(134):

图片

精灵

Player中添加一个 Sprite 节点。从 FileSystem 面板拖动res://assets/player_sheet.png文件并将其放入Sprite的 Texture 属性中。玩家动画以精灵图集的形式保存:

图片

你将使用AnimationPlayer来处理动画,所以在Sprite的 Animation 属性中,将 Vframes 设置为1,将 Hframes 设置为19。将 Frame 设置为7开始,因为这个帧显示了角色静止不动(它是idle动画的第一帧):

图片

碰撞形状

与其他物理体一样,KinematicBody2D需要一个形状来定义其碰撞边界。添加一个CollisionShape2D对象并在其中创建一个新的RectangleShape2D对象。在调整矩形大小时,您希望它达到图像的底部,但不要那么宽。一般来说,使碰撞形状比图像略小,在游戏时会有更好的感觉,避免击中看起来不会导致碰撞的东西的体验。

您还需要稍微偏移形状以使其适合。将位置设置为(0, 5)效果很好。完成时,它应该看起来大约是这样的:

图片

形状

一些开发者更喜欢胶囊形状而不是矩形形状用于横版滚动角色。胶囊是一种两端圆润的药片形状碰撞:

图片

然而,虽然这个形状可能看起来能更好地覆盖精灵,但在实现平台式移动时可能会遇到困难。例如,当站在平台边缘太近时,角色可能会因为圆润的底部而滑落,这对玩家来说可能非常令人沮丧。

在某些情况下,根据您角色的复杂性和与其他对象的交互,您可能需要将多个形状添加到同一个对象中。您可能需要在角色的脚下放置一个形状以检测地面碰撞,另一个在其身体上以检测伤害(有时称为受伤框),还有一个覆盖玩家前方以检测与墙壁接触。

建议您坚持使用如图所示的前一个屏幕截图中的RectangleShape2D。然而,一旦您完成了项目,您应该尝试将玩家的碰撞形状更改为CapsuleShape2D并观察其行为。如果您更喜欢它,请随时使用它代替。

动画

AnimationPlayer节点添加到Player场景中。您将使用此节点来更改Sprite上的帧属性以显示角色的动画。首先创建一个名为idle的新动画:

图片

将长度设置为0.4秒,并保持步长为0.1秒。将Sprite的帧更改为7,然后点击帧属性旁边的添加关键帧按钮以创建一个新的动画轨道,然后再次点击它,注意它会自动增加帧属性:

图片

持续按住它,直到您有帧710。最后,点击启用/禁用循环按钮以启用循环,然后按播放以查看您的动画。您的动画设置应如下所示:

图片

现在,您需要为其他动画重复此过程。以下表格列出了设置列表:

名称 长度 循环
idle 0.4 7, 8, 9 ,10 on
run 0.5 13, 14, 15, 16, 17, 18 on
hurt 0.2 5, 6 on
jump_up 0.1 11 off
jump_down 0.1 12 0ff

完成场景树

Camera2D 添加到 Player 场景中。这个节点将在玩家在关卡中移动时保持游戏窗口在玩家中心。你还可以使用它来放大玩家,因为像素艺术相对较小。记住,由于你在导入设置中关闭了过滤,当放大时,玩家的纹理将保持像素化和块状。

要启用相机,点击当前属性将其设置为 On,然后设置缩放属性为 (0.4, 0.4)。小于一的值会使相机拉近,而大于一的值会使相机拉远。

玩家状态

玩家角色有多种行为,如跳跃、奔跑和蹲下。编写这样的行为可能会变得非常复杂且难以管理。一种解决方案是使用布尔变量(例如 is_jumpingis_running),但这可能导致可能令人困惑的状态(如果 is_crouchingis_jumping 都为 true 会怎样?)并且很快就会导致代码混乱。

解决这个问题的更好方法是使用状态机来处理玩家的当前状态并控制到其他状态的转换。有限状态机在第三章逃离迷宫中进行了讨论。

这里是玩家状态及其之间转换的图示:

图片

如你所见,状态机图可以变得相当复杂,即使状态数量相对较少。

注意,虽然精灵表包含它们的动画,但蹲下和攀爬动画不包括在内。这是为了在项目开始时保持状态数量可管理。稍后,你将有机会将它们添加到玩家的状态机中。

玩家脚本

将一个新的脚本附加到 Player 节点上。添加以下代码以创建玩家的状态机:

extends KinematicBody2D
enum {IDLE, RUN, JUMP, HURT, DEAD}
var state
var anim
var new_anim

func ready():
    change_state(IDLE)

func change_state(new_state):
    state = new_state
    match state:
        IDLE:
            new_anim = 'idle'
        RUN:
            new_anim = 'run'
        HURT:
            new_anim = 'hurt'
        JUMP:
            new_anim = 'jump_up'
        DEAD:
            hide()

func _physics_process(delta):
    if new_anim != anim:
        anim = new_anim
        $AnimationPlayer.play(anim)

再次提醒,你正在使用 enum 来列出系统允许的状态。当你想要改变玩家的状态时,你会调用 change_state(),例如:change_state(IDLE)。目前,脚本只改变动画值,但之后你将添加更多状态功能。

你可能会问,为什么不在状态改变时直接播放动画?为什么要引入新的动画业务?这是因为当你对 AnimationPlayer 调用 play() 时,它会从动画的开始处开始播放。如果你在运行时这样做,例如,你将只能看到跑步动画的第一帧,因为每一帧都会重新开始。通过使用 new_anim 变量,你可以让当前动画继续平滑播放,直到你想要改变它。

玩家移动

玩家需要三个控制键——左、右和跳跃。当前状态与按下的键的组合将触发状态改变,如果状态规则允许转换的话。添加 get_input() 函数来处理输入并确定结果:

extends KinematicBody2D

export (int) var run_speed
export (int) var jump_speed
export (int) var gravity

enum {IDLE, RUN, JUMP, HURT, DEAD}
var state
var anim
var new_anim
var velocity = Vector2()

func get_input():
    if state == HURT:
        return # don't allow movement during hurt state
    var right = Input.is_action_pressed('right')
    var left = Input.is_action_pressed('left')
    var jump = Input.is_action_just_pressed('jump')

    # movement occurs in all states
    velocity.x = 0
    if right:
        velocity.x += run_speed
        $Sprite.flip_h = false
    if left:
        velocity.x -= run_speed
        $Sprite.flip_h = true
    # only allow jumping when on the ground
    if jump and is_on_floor():
        change_state(JUMP)
        velocity.y = jump_speed
    # IDLE transitions to RUN when moving
    if state == IDLE and velocity.x != 0:
        change_state(RUN)
    # RUN transitions to IDLE when standing still
    if state == RUN and velocity.x == 0:
        change_state(IDLE)
    # transition to JUMP when falling off an edge
    if state in [IDLE, RUN] and !is_on_floor():
        change_state(JUMP)

注意,跳跃检查使用的是is_action_just_pressed()而不是is_action_pressed()。虽然后者只要按键按下就会始终返回true,但前者只有在按键按下后的帧中才是true。这意味着玩家必须每次想要跳跃时都按下跳跃键。

现在,从_physics_process()函数中调用此函数,将重力拉力添加到玩家的velocity中,然后调用move_and_slide()方法来移动身体:

func _physics_process(delta):
    velocity.y += gravity * delta
    get_input()
    if new_anim != anim:
        anim = new_anim
        $AnimationPlayer.play(anim)
    # move the player
    velocity = move_and_slide(velocity, Vector2(0, -1))

move_and_slide()的第二个参数是一个法线向量,表示引擎应考虑为地面的表面方向。在物理学和几何学中,法线是一个垂直于表面的向量,定义了表面的朝向方向。使用(0, -1),这是一个向上指的向量,水平表面的顶部将被视为地面。请参考以下截图:

在使用move_and_slide()移动后,物理引擎将使用这些信息来设置is_on_floor()is_on_wall()is_on_ceiling方法。你可以在移动后添加以下内容来检测跳跃何时结束:

    if state == JUMP and is_on_floor():
        change_state(IDLE)

最后,如果动画在下落时从jump_up切换到jump_down,跳跃看起来会更好:

    if state == JUMP and velocity.y > 0:        new_anim = 'jump_down'Testing the moves

在这一点上,测试移动并确保一切正常工作是个好主意。但你不能只是运行玩家场景,因为玩家会因为没有站立表面而开始下落。

创建一个新的场景并添加一个名为MainNode(稍后,这将成为你的真实主场景)。添加一个Player实例,然后添加一个带有矩形CollisionShape2DStaticBody2D。将碰撞形状水平拉伸,使其足够宽,可以来回走动(就像一个平台)并将其放置在角色下方:

按下“播放场景”,你应该看到玩家在撞击静态身体时停止下落并运行idle动画。

在继续之前,请确保所有移动和动画都正常工作。在所有方向上跑和跳,并检查状态改变时是否播放了正确的动画。如果你发现任何问题,请回顾前面的部分,并确保你没有错过任何步骤。

之后,一旦关卡完成,玩家将获得一个出生位置。为了处理这个问题,将此函数添加到Player.gd脚本中:

func start(pos):
    position = pos
    show()
    change_state(IDLE)

玩家健康

最终,玩家会遇到危险,因此你应该添加一个伤害系统。玩家将开始时有三个心形,每次受伤都会失去一个。

将以下内容添加到脚本顶部:

signal life_changed
signal dead

var life

life的值改变时,life_changed信号将被发出,通知显示更新。当life达到0时,将发出dead。将这两行添加到start()函数中:

    life = 3
    emit_signal('life_changed', life)

玩家受伤有两种可能的方式:在环境中撞到尖刺对象,或者被敌人击中。在任何一种情况下,都可以调用以下函数:

func hurt():
    if state != HURT:
        change_state(HURT)

这是对玩家的一种友好行为:如果他们已经受伤,他们就不能再次受伤(至少在受伤动画停止播放的短暂时间内)。

change_state()中的状态变为HURT时,需要执行几件事情:

HURT:
    new_anim = 'hurt'
    velocity.y = -200
    velocity.x = -100 * sign(velocity.x)
    life -= 1
    emit_signal('life_changed', life)
    yield(get_tree().create_timer(0.5), 'timeout')
    change_state(IDLE)
    if life <= 0:
        change_state(DEAD)
DEAD:
    emit_signal('dead')
    hide()

不仅玩家会失去生命,他们还会被弹起并远离造成伤害的对象。经过一段时间后,状态会变回IDLE

此外,当玩家处于HURT状态时,将禁用输入。将以下内容添加到get_input()的开始部分:

if state == HURT:
    return

现在,当游戏的其他部分设置完毕后,玩家就可以开始承受伤害了。

可收集物品

在你开始制作关卡之前,你需要为玩家创建一些可收集的物品,因为那些也将是关卡的一部分。assets/sprites文件夹包含两种类型可收集物品的精灵图集:樱桃和宝石。

而不是为每种类型的物品创建单独的场景,你可以使用一个单一的场景,只需更换精灵图集纹理即可。这两个对象将具有相同的行为:在原地动画并在被玩家接触时消失(即被收集)。你还可以为拾取动作添加一个Tween动画(参见第一章,简介,以获取示例)。

可收集场景

使用Area2D启动新场景并将其命名为Collectible。区域是这些对象的好选择,因为你想要检测玩家何时接触它们(使用body_entered信号),但你不需要从它们那里获得碰撞响应。在检查器中,将碰撞/层设置为可收集物品(层 4)和碰撞/掩码设置为玩家(层 2)。这将确保只有Player节点能够收集物品,而敌人将直接穿过。

添加三个子节点:SpriteCollisionShape2DAnimationPlayer,然后将res://assets/cherry.png精灵图集拖放到精灵的纹理中。将 Vframes 设置为1,Hframes 设置为5。向CollisionShape2D添加一个矩形形状并适当调整其大小。

作为一般规则,你应该调整你的对象的碰撞形状,以便它们对玩家有益。这意味着敌人的击中框应该通常比图像略小,而有益物品的击中框应该略微放大。这减少了玩家的挫败感,并导致更好的游戏体验。

AnimationPlayer添加一个新的动画(你只需要一个,所以你可以直接命名为anim)。将长度设置为1.6秒,步长设置为0.2秒。

将精灵的帧属性设置为0并单击关键帧按钮以创建轨迹。当你达到第四帧时,开始按顺序反向回到1。完整的关键帧序列应该是:

0 → 1 → 2 → 3 → 4 → 3 → 2 → 1 

启用循环并按下播放按钮。现在,你有一个漂亮的樱桃动画了!将res://assets/gem.png拖入纹理中,并检查它是否也进行了动画处理。最后,点击“加载时自动播放”按钮,以确保动画将在场景开始时自动播放。请参考以下截图:

截图

可收集物品脚本

可收集物品的脚本需要完成两件事:

  • 设置起始条件(textureposition

  • 检测玩家何时进入区域

对于第一部分,将以下代码添加到新脚本中:

extends Area2D

signal pickup

var textures = {'cherry': 'res://assets/sprites/cherry.png',
                'gem': 'res://assets/sprites/gem.png'}

func init(type, pos):
    $Sprite.texture = load(textures[type])
    position = pos

当玩家收集物品时,将发出pickup信号。在textures字典中,你有一个物品类型及其对应纹理位置的列表。请注意,你可以通过在 FileSystem 窗口中右键单击文件并选择“复制路径”来快速粘贴这些文件路径:

截图

接下来,你有一个init()函数,它将textureposition设置为给定的值。级别脚本将使用此函数来生成你添加到级别地图中的所有可收集物品。

最后,你需要检测物体何时被拾起。点击Area2D并连接其body_entered信号。将以下代码添加到创建的函数中:

func _on_Collectible_body_entered(body):
    emit_signal('pickup')
    queue_free()

发出信号将允许游戏脚本适当地对物品拾取做出反应。它可以增加分数、提高玩家的速度,或者实现你希望物品产生的任何其他效果。

设计级别

如果没有跳跃,那就不是平台游戏。对于大多数读者来说,这一部分将占用最多的时间。一旦你开始设计一个级别,你会发现布置所有部件、创建挑战性的跳跃、秘密路径和危险遭遇非常有趣。

首先,你将创建一个通用的Level场景,包含所有级别共有的节点和代码。然后你可以创建任意数量的级别场景,这些场景继承自这个主级别。

TileSet 配置

在项目开始时下载的assets文件夹中有一个tilesets文件夹。它包含三个使用 16x16 艺术风格的预制的TileSet资源:

  • tiles_world.tres:地面和平台瓦片

  • tiles_items.tres:装饰物品、前景物体和可收集物品

  • tiles_spikes.tres:危险物品

建议你使用这些瓦片集来创建本项目的级别。然而,如果你更愿意自己制作,原始艺术作品在res://assets/environment/layers中。参见第二章,Coin Dash,以复习如何创建TileSet资源。

基础级别设置

创建一个新的场景并添加一个名为LevelNode2D。将场景保存在名为levels的新文件夹中。这是你保存从Level.tscn继承的所有其他级别的位置。所有级别的节点层次结构都将相同——只有布局会有所不同。

接下来,添加一个TileMap并将其单元格/大小设置为(16, 16),然后将其复制三次(按Ctrl + D复制节点)。这些将是你的关卡层,包含不同的瓦片和布局信息。将四个TileMap实例命名为以下名称,并将相应的TileSet拖放到每个的“Tile Set”属性中。请参考以下表格:

TileMap Tile Set
World tiles_world.tres
Objects tiles_items.tres
Pickups tiles_items.tres
Danger tiles_spikes.tres

在你制作地图时按下TileMap节点的锁定按钮是个好主意,以防止意外移动它们。

接下来,添加一个Player场景和一个名为PlayerSpawnPosition2D。点击Player上的隐藏按钮——你将在关卡脚本中使用show()使玩家在开始时出现。你的场景树现在应该看起来像这样:

将脚本附加到Level节点:

extends Node2D

onready var pickups = $Pickups

func _ready():
    pickups.hide()
    $Player.start($PlayerSpawn.position)

之后,你将扫描Pickups地图以在指定位置生成可收集物品。这个地图层本身不应该被看到,但与其在场景树中将它设置为隐藏,这很容易在运行游戏之前忘记,你可以在_ready()中确保它在游戏过程中始终隐藏。因为将会有许多对节点的引用,将$Pickups的结果存储在pickups变量中会缓存结果。(记住,$NodeName与写入get_node("NodeName")相同。)

设计第一个关卡

现在,你准备好开始绘制关卡了!点击“场景”|“新建继承场景”并选择Level.tscn。将新节点命名为Level01并保存(仍在levels文件夹中)。

World地图开始,发挥创意。你喜欢很多跳跃,还是曲折的隧道去探索?长跑,还是小心翼翼的向上攀登?

在深入设计之前,尝试跳跃距离。你可以通过更改玩家的jump_speedrun_speedgravity属性来改变他们可以跳多高和多远。设置一些不同的间隙大小并运行场景来尝试它们。别忘了将PlayerSpawn节点拖到你想让角色开始的位置。

例如,玩家能否完成这个跳跃?请查看以下截图:

你设置玩家运动属性的方式将对你的关卡布局产生重大影响。在花费太多时间在完整设计之前,确保你对设置满意。

一旦你设置了World层,使用Objects层放置装饰和点缀,如植物、岩石和藤蔓。

使用Pickups层标记你将生成可收集物品的位置。有两种类型:宝石和樱桃。生成它们的瓦片以洋红色背景绘制,以便突出显示。记住,它们将在运行时被实际物品替换,而瓦片本身将不会被看到。

一旦你布置好你的关卡,你可以限制玩家相机的水平滚动以匹配地图的大小(并在每端各加 5 个方块缓冲区):

signal score_changed
var score 

func _ready():
    score = 0
    emit_signal('score_changed', score)
    pickups.hide()
    $Player.start($PlayerSpawn.position)
    set_camera_limits()

func set_camera_limits():
    var map_size = $World.get_used_rect()
    var cell_size = $World.cell_size
    $Player/Camera2D.limit_left = (map_size.position.x - 5) * cell_size.x
    $Player/Camera2D.limit_right = (map_size.end.x + 5) * cell_size.x

脚本还需要扫描Pickups层并查找物品标记:

func spawn_pickups():
    for cell in pickups.get_used_cells():
        var id = pickups.get_cellv(cell)
        var type = pickups.tile_set.tile_get_name(id)
        if type in ['gem', 'cherry']:
            var c = Collectible.instance()
            var pos = pickups.map_to_world(cell)
            c.init(type, pos + pickups.cell_size/2)
            add_child(c)
            c.connect('pickup', self, '_on_Collectible_pickup')

func _on_Collectible_pickup():
    score += 1
    emit_signal('score_changed', score)

func _on_Player_dead():
    pass

此函数使用get_used_cells()获取Pickups地图上使用的瓦片的数组。TileMap将每个瓦片的值设置为引用TileSet中单个瓦片对象的id。然后你可以使用tile_set.tile_get_name()查询TileSet以获取瓦片的名称。

spawn_pickups()添加到_ready()中,并在脚本顶部添加以下内容:

var Collectible = preload('res://items/Collectible.tscn')

尝试运行你的关卡,你应该能看到你放置的宝石和/或樱桃出现在那里。同时检查它们在你遇到它们时是否会消失。

滚动背景

res://assets/environment/layers文件夹中有两个背景图像:back.pngmiddle.png,分别用于远背景和近背景。通过将这些图像放置在瓦片地图后面并以相对于相机的不同速度滚动,你可以在背景中创建一个吸引人的深度错觉。

首先,将ParallaxBackground节点添加到Level场景中。此节点与相机自动协同工作,以创建滚动效果。将此节点拖动到场景树顶部,以便它将在其余节点之后绘制。接下来,将ParallaxLayer节点作为子节点添加——ParallaxBackground可以有任意数量的ParallaxLayer子节点,允许你创建多个独立滚动的层。将Sprite节点作为子节点添加到ParallaxLayer,并将res://assets/environment/layers/back.png图像拖动到纹理中。 重要——取消选中精灵旁边Centered属性旁边的框。

背景图像有点小,所以将精灵的缩放设置为(1.5, 1.5)

ParallaxLayer上,将运动/缩放设置为(0.2, 1)。此设置控制背景相对于相机的滚动速度。通过将其设置为低数值,背景将在玩家左右移动时仅移动一小段距离。

接下来,你想要确保如果你的关卡非常宽,图像会重复,所以将镜像设置为(576, 0)。这正是图像的宽度(384乘以1.5),所以当图像移动了这么多距离时,图像将会重复。

注意,这种背景最适合宽关卡而不是高关卡。如果你跳得太高,你会到达背景图像的顶部,然后突然看到灰色的空白。你可以通过设置相机的顶部限制来修复这个问题。如果你没有移动它,图像的左上角将位于(0, 0),因此你可以将相机的顶部限制设置为0。如果你已经移动了ParallaxLayer,你可以通过查看节点的y值来找到正确的值。

现在,为中间背景层添加另一个ParallaxLayer(作为第一个的兄弟),并给它一个Sprite子项。这次,使用res://assets/environment/layers/middle.png纹理。这个纹理比云/天空图像窄得多,所以你需要做一些额外的调整来使其正确重复。这是因为ParallaxBackground需要具有至少与视口区域一样大的图像。

首先,在 FileSystem 窗口中点击纹理,并选择导入标签。将重复属性更改为镜像,并勾选 Mipmaps。按重新导入。现在,纹理可以重复以填充屏幕(并且透视系统将在之后重复它):

图片的原始大小是176x368,需要水平重复。在Sprite属性中,点击启用区域。接下来,将矩形属性设置为(0, 0, 880, 368)(880 是 176 乘以 5,所以你现在应该看到五次图片的重复)。移动ParallaxLayer,使图片与海洋/云图片的下半部分重叠:

ParallaxLayer的 Motion/Scale 设置为(0.6, 1),将镜像设置为(880, 0)。使用更高的缩放因子意味着这个层将比后面的云层滚动得更快,从而产生令人满意的深度效果,如以下截图所示:

一旦你确定一切正常工作,尝试调整两个层的缩放值,看看它如何变化。例如,尝试在中间层使用(1.2, 1)的值,以获得非常不同的视觉效果。

你主场景的树现在应该看起来像这样:

危险物品

危险地图层是用来存放如果被触摸会伤害玩家的尖刺对象的。尝试在你的地图上放置几个它们,这样你可以轻松测试撞到它们。注意,由于 TileMaps 的工作方式,与这个层上的任何瓷砖发生碰撞都会对玩家造成伤害!

关于滑动碰撞

当使用move_and_slide()移动KinematicBody2D时,它可能在给定帧内与多个对象发生碰撞。例如,当撞到角落时,角色可能会同时撞到墙和地板。你可以使用get_slide_count()方法找出发生了多少次碰撞,然后使用get_slide_collision()获取每次碰撞的信息。

Player的情况下,你想要检测当与危险TileMap对象发生碰撞时。你可以在使用Player.gd中的move_and_slide()之后这样做:

    velocity = move_and_slide(velocity, Vector2(0, -1))
    if state == HURT:
        return
    for idx in range(get_slide_count()):
        var collision = get_slide_collision(idx)
        if collision.collider.name == 'Danger':
            hurt()

在检查与Danger的碰撞之前,你可以检查玩家是否已经处于HURT状态,如果是的话就跳过检查。接下来,你必须使用get_slide_count()来遍历可能发生的任何碰撞。对于每一个,你可以检查collider.name是否为Danger

运行场景,并尝试撞到其中一个尖刺对象。就像你之前在hurt()函数中写的,你应该看到玩家在短时间内变为HURT状态,然后返回到IDLE状态。经过三次打击后,玩家进入DEAD状态,当前设置可见性为隐藏。

敌人

目前,地图非常空旷,所以是时候添加一些敌人来活跃一下气氛了。

你可以为敌人创建许多不同的行为。对于这个项目,敌人将沿着平台直线行走,并在碰到障碍物时改变方向。

场景设置

KinematicBody2D开始,有三个子节点:SpriteAnimationPlayerCollisionShape2D。将场景保存为enemies文件夹中的Enemy.tscn。如果你决定为游戏添加更多敌人类型,你都可以在这里保存。

将身体的碰撞层设置为enemies,并将其碰撞掩码设置为environmentplayerenemies。将敌人分组也是有用的,因此点击节点标签页,并将身体添加到名为enemies的组中。

res://assets/opossum.png精灵图集添加到精灵的纹理。将 Vframes 设置为1,Hframes 设置为6。添加一个矩形碰撞形状,覆盖图像的大部分(但不是全部),确保碰撞形状的底部与图像脚部的底部对齐:

AnimationPlayer添加一个新的动画,命名为walk。将长度设置为0.6秒,步长设置为0.1秒。开启循环和自动播放。

walk动画将有两个轨道:一个设置纹理属性,一个更改帧属性。点击纹理旁边的添加关键帧按钮一次以添加第一个轨道,然后点击帧旁边的按钮并重复,直到你有05的帧。按播放并验证行走动画是否正确播放。动画面板应该看起来像这样:

脚本

添加以下脚本:

extends KinematicBody2D

export (int) var speed
export (int) var gravity

var velocity = Vector2()
var facing = 1

func _physics_process(delta):
    $Sprite.flip_h = velocity.x > 0
    velocity.y += gravity * delta
    velocity.x = facing * speed

    velocity = move_and_slide(velocity, Vector2(0, -1))
    for idx in range(get_slide_count()):
        var collision = get_slide_collision(idx)
        if collision.collider.name == 'Player':
            collision.collider.hurt()
        if collision.normal.x != 0:
            facing = sign(collision.normal.x)
            velocity.y = -100

    if position.y > 1000:
        queue_free()

在这个脚本中,facing变量跟踪移动方向(1-1)。与玩家一样,在移动时,你遍历滑动碰撞。如果碰撞的对象是Player,你调用它的hurt()函数。

接下来,你可以检查碰撞体的法线向量是否有非0x分量。这意味着它指向左边或右边(即,它是一堵墙、一个箱子或其他障碍物)。法线的方向用于设置新的朝向。最后,给身体一个小的向上速度会使反向过渡看起来更有吸引力。

最后,如果由于某种原因,敌人从平台上掉落,你不想让游戏永远跟踪它的掉落,所以删除任何y坐标变得太大的敌人。

在检查器中将速度设置为50,重力设置为900,然后在你的关卡场景中创建一个Enemy。确保它两侧都有一个障碍物,并播放场景。检查敌人是否在障碍物之间来回走动。尝试将玩家放在它的路径上,并验证玩家的hurt()方法是否被调用。

损害敌人

如果玩家不能反击,那就太不公平了,所以按照超级马里奥兄弟的传统,跳到敌人的上面可以击败它。

首先,向EnemyAnimationPlayer添加一个新的动画,命名为death。将长度设置为0.3秒,步长设置为0.05不要为这个动画开启循环。

这个动画还将设置纹理和帧。这次,在添加该属性的帧之前,将res://assets/enemy-death.png图像拖入精灵的纹理中。和之前一样,将所有值从05全部设置为关键帧。按播放键查看死亡动画的运行情况。

将以下代码添加到Enemy的脚本中:

func take_damage():
    $AnimationPlayer.play('death')
    $CollisionShape2D.disabled = true
    set_physics_process(false)

Player在正确条件下击中Enemy时,它将调用take_damage(),这将播放death动画。它还禁用了动画期间的碰撞和移动。

death动画完成后,可以移除敌人,因此连接AnimationPlayeranimation_finished()信号。这个信号在每次动画完成后都会被调用,所以你需要检查它是否正确:

func _on_AnimationPlayer_animation_finished(anim_name):
  if anim_name == 'death':
    queue_free()

为了完成这个过程,转到Player.gd脚本,并在_physics_process()方法中的碰撞检查中添加以下内容:

for idx in range(get_slide_count()):
    var collision = get_slide_collision(idx)
    if collision.collider.name == 'Danger':
        hurt()
    if collision.collider.is_in_group('enemies'):
        var player_feet = (position + $CollisionShape2D.shape.extents).y
        if player_feet < collision.collider.position.y:
            collision.collider.take_damage()
            velocity.y = -200
        else:
            hurt()

此代码检查玩家的脚的 y 坐标(即碰撞形状的底部)与敌人的 y 坐标。如果玩家更高,敌人会受到伤害;否则,玩家会受到伤害。

运行关卡并尝试跳到敌人身上,以确保一切按预期工作。

HUD

HUD 的目的是在游戏过程中显示玩家需要知道的信息。收集物品会增加玩家的分数,因此需要显示这一信息。玩家还需要看到他们剩余的生命值,这将以一系列心形图案显示。

场景设置

创建一个新的场景,并使用MarginContainer节点。将其命名为HUD并保存在ui文件夹中。将布局设置为顶部宽。在检查器的自定义常量部分,设置以下值:

  • 右侧边距:50

  • 顶部边距:20

  • 左侧边距:50

  • 底部边距:20

添加一个HBoxContainer节点。这个节点将包含所有 UI 元素并保持它们的对齐。它将有两个子节点:

  • LabelScoreLabel

  • HBoxContainerLifeCounter

ScoreLabel 上设置文本属性为 1,并在大小标志下设置水平为填充和扩展。使用 res://assets/Kenney Thick.ttfassets 文件夹添加一个自定义 DynamicFont,字体大小为 48。在自定义颜色部分,将字体颜色设置为 white,字体颜色阴影设置为 black。最后,在自定义常量下,将阴影偏移 X、阴影偏移 Y 和阴影轮廓都设置为 5。你应该看到一个带有黑色轮廓的大白数字 1

对于 LifeCounter,添加一个 TextureRect 并命名为 L1。将 res://assets/heart.png 拖入其纹理中,并将拉伸模式设置为 Keep Aspect Centered。点击节点并按 Ctrl + D 四次,以便得到一排五颗心:

图片

完成后,你的 HUD 应该看起来像这样:

图片

脚本

这是 HUD 的脚本:

extends MarginContainer

onready var life_counter = [$HBoxContainer/LifeCounter/L1,
                            $HBoxContainer/LifeCounter/L2,
                            $HBoxContainer/LifeCounter/L3,
                            $HBoxContainer/LifeCounter/L4,
                            $HBoxContainer/LifeCounter/L5]

func _on_Player_life_changed(value):
    for heart in range(life_counter.size()):
        life_counter[heart].visible = value > heart

func _on_score_changed(value):
    $HBoxContainer/ScoreLabel.text = str(value)

首先,你创建一个包含五个生命指示器的引用数组。然后,在 _on_Player_life_changed() 方法中,当玩家受伤或恢复时会被调用,你通过设置 visiblefalse 来计算显示多少颗心,如果心形数量少于生命值。

_on_score_changed() 类似,当被调用时改变 ScoreLabel 的值。

添加 HUD

打开 Level.tscn(基础关卡场景,不是你的 Level01 场景)并添加一个 CanvasLayer 节点。将 HUD 场景作为其子节点实例化。

点击 Player 节点,将其 life_changed 信号连接到 HUD 的 _on_Player_life_changed() 方法:

图片

接下来,用 Level 节点的 score_changed 信号做同样的事情,将其连接到 HUD 的 _on_score_changed

另一种方法:注意,如果你不想使用场景树来连接信号,或者如果你觉得信号连接窗口令人困惑,你可以在 Level.gd_ready() 函数中添加这两行代码来完成相同的事情:

$Player.connect('life_changed', $CanvasLayer/HUD,  
                '_on_Player_life_changed')
$Player.connect('dead', self, '_on_Player_dead')
connect('score_changed', $CanvasLayer/HUD, '_on_score_changed') 

运行你的关卡,并验证在收集物品时获得分数,在受伤时失去生命值。

标题屏幕

标题屏幕是玩家看到的第一个场景。当玩家死亡时,游戏将返回此场景并允许你重新开始。

场景设置

从一个 Control 节点开始,并将布局设置为全矩形。

添加一个 TextureRect。将其纹理设置为 res://assets/environment/layers/back.png,布局为全矩形,拉伸模式为保持纵横比。

添加另一个 TextureRect,这次使用 res://assets/environment/layers/middle.png 作为纹理,并将拉伸模式设置为平铺。将矩形的宽度拖动到比屏幕宽,并调整它以覆盖屏幕下半部分。

接下来,添加两个 Label 节点(TitleMessage),并使用之前为分数标签使用的相同选项设置它们的自定义字体设置。将它们的文本属性分别设置为 Jungle Jump 和 Press Space to Play。完成时,屏幕应该看起来像这样:

图片

要使标题屏幕更有趣,添加一个AnimationPlayer节点并创建一个新的动画。将其命名为anim并设置为自动播放。在这个动画中,您可以动画化屏幕的各种组件,使它们移动、出现、淡入或您喜欢的任何其他效果。

将标题标签拖动到屏幕顶部的上方并添加一个关键帧。然后,将其拖回(或手动输入位置值)并在大约0.5秒处设置另一个关键帧。您可以自由添加动画其他节点属性的轨道。

例如,这里有一个动画,它将标题下拉,淡入两个纹理,然后显示消息(注意每个轨道修改的属性名称):

图片

主场景

删除您添加到临时Main.tscnPlayer实例和测试StaticBody2D)中的额外节点。现在这个场景将负责加载当前等级。然而,在它能够这样做之前,您需要一个 Autoload 脚本来跟踪游戏状态:例如current_level和其他需要从场景到场景携带的数据。

在脚本编辑器中添加一个新的脚本GameState.gd,并添加以下代码:

extends Node

var num_levels = 2
var current_level = 1

var game_scene = 'res://Main.tscn'
var title_screen = 'res://ui/TitleScreen.tscn'

func restart():
    get_tree().change_scene(title_screen)

func next_level():
    current_level += 1
    if current_level <= num_levels:
        get_tree().reload_current_scene()

注意,您应该将num_levels设置为在levels文件夹中制作的等级数量。确保它们命名一致(Level01.tscnLevel02.tscn等),然后您可以自动加载序列中的下一个。

在项目设置的 Autoload 标签中添加此脚本,并将其添加到Main

extends Node

func _ready():
    # make sure your level numbers are 2 digits ("01", etc.)
    var level_num = str(GameState.current_level).pad_zeros(2)
    var path = 'res://levels/Level%s.tscn' % level_num
    var map = load(path).instance()
    add_child(map)

现在,每当加载Main场景时,它将加载与GameState.current_level对应的等级场景。

标题屏幕需要过渡到游戏场景,因此将此脚本附加到TitleScreen节点:

extends Control

func _input(event):
    if event.is_action_pressed('ui_select'):
        get_tree().change_scene(GameState.game_scene)

您也可以通过将重启函数添加到Level.gd中的方法来在玩家死亡时调用它:

func _on_Player_dead():
    GameState.restart()

等级过渡

您的等级现在需要一种从一级过渡到下一级的方式。在res://assets/environment/layers/props.png精灵图中,有一个可以用于您等级出口的门图像。找到并走进门,玩家将移动到下一级。

门场景

创建一个新的场景,命名为Area2DDoor,并将其保存在items文件夹中。添加一个Sprite,使用res://assets/environment/layers/props.png精灵图,并使用Region设置选择门图像,然后附加一个矩形的CollisionShape2D。这个场景不需要脚本,因为您只是将要使用区域的body_entered信号。

将门放在collectibles层上,并设置其遮罩只扫描player层。

在您的第一个等级中实例化此门场景,并将其放置在玩家可以到达的地方。单击Door节点,将body_entered信号连接到您可以添加此代码的Level.gd脚本:

func _on_Door_body_entered(body):
    GameState.next_level()

运行游戏并尝试撞上门以检查它是否立即转移到下一级。

最后的修饰

现在你已经完成了游戏的结构,你可以考虑添加一些内容,以便你可以添加更多游戏功能、更多视觉效果、额外的敌人或你可能有的其他想法。在本节中,有一些建议的功能——直接添加或根据你的喜好进行调整。

声音效果

与之前的项目一样,你可以添加音效和音乐来提升游戏体验。在res://assets/audio文件夹中,你可以找到用于各种游戏事件(如玩家跳跃、敌人击中和拾取)的多个文件。还有两首音乐文件:标题屏幕的 Intro Theme 和关卡场景的 Grasslands Theme。

将这些添加到游戏中将由你来完成,但这里有一些提示:

  • 确保在导入设置选项卡中,音效的 Loop 设置为 Off,而音乐文件的 Loop 设置为 On。

  • 你可能会发现调整单个声音的音量很有帮助。这可以通过 Volume Db 属性来设置。设置负值将降低声音的音量。

  • 你可以将音乐附加到主Level.tscn,并且该音乐将用于所有关卡(将AudioStreamPlayer设置为自动播放)。

  • 如果你想要为单个关卡设置特定的氛围,你也可以为它们附加单独的音乐。

无限坠落

根据你如何设计你的关卡,玩家可能完全掉出关卡。通常,你希望通过使用太高而无法跳跃的墙壁、坑底的尖刺等方式来设计,使得这种情况不可能发生。但是,如果确实发生了,请将以下代码添加到玩家的_physics_process()方法中:

if position.y > 1000:
    change_state(DEAD)

注意,如果你设计的关卡延伸到了y坐标的1000以下,你需要增加该值以防止意外死亡。

双重跳跃

双重跳跃是平台游戏中的一个流行功能。如果玩家在空中按下跳跃键第二次,他们会获得第二次,通常是较小的向上推力。要实现这个功能,你需要向玩家脚本中添加一些内容。

首先,你需要两个变量来跟踪状态:

var max_jumps = 2
var jump_count = 0

当进入JUMP状态时,重置跳跃次数:

JUMP:
    new_anim = 'jump_up'
    jump_count = 1

最后,在get_input()函数中,如果满足条件则允许跳跃:

if jump and state == JUMP and jump_count < max_jumps:
    new_anim = 'jump_up'
    velocity.y = jump_speed / 1.5
    jump_count += 1

注意,这会使第二次跳跃的速度是正常跳跃的 2/3。你可以根据你的喜好进行调整。

灰尘颗粒

角色脚下的灰尘颗粒是一种低成本的特效,可以为玩家的动作增添许多特色。在本节中,你将为玩家的脚部添加一小股灰尘,每当他们落地时都会释放出来。这为玩家的跳跃增添了重量感和冲击感。

添加一个Particles2D节点,并将其命名为Dust。注意,必须添加一个过程材质。然而,首先,你需要设置Dust节点的属性:

属性
数量 20
生命周期 0.45
单次播放 On
速度比例 2
爆发力 0.7
本地坐标 Off
位置 (-2, 15)
旋转 -90

现在,在处理材质下添加一个新的ParticlesMaterial。点击它,你会看到所有的粒子设置。以下是实现尘埃效果所需的设置:

粒子属性
发射形状 Box
箱体范围 (1, 6, 1)
重力 (0, 0, 0)
初始速度 10
速度随机 1
尺度 5
尺度随机 1

默认粒子颜色为白色,但作为棕褐色调的尘埃效果看起来会更好。它也应该逐渐消失,看起来像是消散了。这可以通过一个ColorRamp来实现。在颜色渐变旁边,点击新建GradientTexture。在GradientTexture属性中,选择一个新的Gradient

Gradient有两种颜色:左侧的起始颜色和右侧的结束颜色。这些颜色由渐变两端的矩形选择。点击右侧的方块可以设置颜色:

将起始颜色设置为棕褐色调,并将结束颜色设置为相同的颜色,但将 alpha 值设置为0(透明)。你可以通过检查检查器中的发射框来测试其外观。因为节点设置为一次性,所以只有一个粒子云团,你必须再次勾选框来发射它们。

随意更改属性,从以下列出的内容中。实验Particles2D设置可以非常有趣,而且你可能会通过调整得到一个非常棒的效果。一旦你对外观满意,将以下内容添加到玩家的_physics_process()代码中:

if state == JUMP and is_on_floor():
    change_state(IDLE)
    $Dust.emitting = true # add this line

运行游戏,每次你的角色落地时,都会出现一小团尘埃。

蹲下状态

蹲下状态在玩家需要通过蹲下躲避敌人或投射物时很有用。精灵图集中包含这个状态的二帧动画:

将一个新的动画称为蹲下添加到玩家的AnimationPlayer中。将其长度设置为0.2并为帧属性添加一个轨道,将值从3变为4。将动画设置为循环。

在玩家的脚本中,将新状态添加到enum和状态转换中:

enum {IDLE, RUN, JUMP, HURT, DEAD, CROUCH}
CROUCH:
    new_anim = 'crouch'

get_input()方法中,你需要处理各种状态转换。当在地面时,向下输入应转换为CROUCH。当处于CROUCH状态时,释放向下输入应转换为IDLE。最后,如果在CROUCH状态并且按下左右键,状态应更改为RUN

var down = Input.is_action_pressed('crouch')

if down and is_on_floor():
    change_state(CROUCH)
if !down and state == CROUCH:
    change_state(IDLE)

你还需要更改这一行:

if state == IDLE and velocity.x != 0:
    change_state(RUN)

变成这样:

if state in [IDLE, CROUCH] and velocity.x != 0:
    change_state(RUN)

就这样!运行游戏并尝试你的新动画状态。

爬梯子

玩家动画还包括爬行动作的帧,并且图块集中包含梯子。目前,梯子图块没有任何作用:在图块集中,它们没有分配任何碰撞形状。这是可以的,因为你不希望玩家与梯子碰撞;你希望能够在上面上下移动。

玩家代码

首先,点击玩家的 AnimationPlayer 并添加一个名为 climb 的新动画。其长度应设置为 0.4 秒,Sprite 的帧值是 0, 1, 0, 2。将动画设置为循环。

现在,转到 Player.gd 并在状态枚举中添加一个新的状态 CLIMB。此外,在顶部声明中添加两个新变量:

export (int) var climb_speed
var is_on_ladder = false

is_on_ladder 将用于判断玩家是否在梯子上。使用这个功能,你可以决定向上箭头是否应该有作用。在检查器中,将爬升速度设置为 50

change_state() 中,为新的状态添加一个条件:

CLIMB:
    new_anim = 'climb'

接下来,在 _get_input() 中,你需要添加 climb 输入动作,并添加代码以确定何时触发新状态。添加以下内容:

var climb = Input.is_action_pressed('climb')

if climb and state != CLIMB and is_on_ladder:
    change_state(CLIMB)
if state == CLIMB:
    if climb:
        velocity.y = -climb_speed
    elif down:
        velocity.y = climb_speed
    else:
        velocity.y = 0
        $AnimationPlayer.play("climb")
if state == CLIMB and not is_on_ladder:
    change_state(IDLE)

这里,你有三个新的条件需要检查。首先,如果玩家不在 CLIMB 状态,但站在梯子上,那么按下向上键应该开始让玩家开始攀爬。接下来,如果玩家正在攀爬,那么上下键应该相应地移动他们,但如果没有按键则停止移动。最后,如果玩家在攀爬时离开梯子,它将离开 CLIMB 状态。

剩下的一个问题是你需要重力在攀爬时停止将玩家向下拉。在 _physics_process() 中的重力代码中添加以下条件:

if state != CLIMB:
    velocity.y += gravity * delta

现在,玩家已经准备好了,你可以在你的关卡地图中添加一些梯子。

关卡代码

在你的地图上某个地方放置几个梯子瓦片,然后向关卡场景添加一个梯子 Area2D。给这个节点一个具有矩形形状的 CollisionShape2D。调整区域大小最好的方法是使用网格吸附。通过菜单打开它,并使用配置吸附...将网格步长设置为 (4, 4)

图片

调整碰撞形状,使其从上到下覆盖梯子的中心部分。如果你使形状与梯子一样宽,那么即使玩家悬挂在侧面,也会被视为正在攀爬。你可能觉得这看起来有点奇怪,所以将形状做得比梯子略小可以防止这种情况。

Ladderbody_enteredbody_exited 信号连接起来,并添加以下代码以使它们设置玩家的梯子变量:

func _on_Ladder_body_entered(body):
    if body.name == "Player":
        body.is_on_ladder = true

func _on_Ladder_body_exited(body):
    if body.name == "Player":
        body.is_on_ladder = false

现在你可以尝试一下。你应该能够走到梯子旁边并上下攀爬。注意,如果你站在梯子的顶部并踏上它,你会掉到下面而不是向下攀爬(尽管在掉落时按下向上键可以抓住梯子)。如果你希望自动过渡到攀爬状态,你可以在 _physics_process() 中添加一个额外的掉落检查。

移动平台

创建一个新的场景,并添加一个KinematicBody2D根节点。添加一个Sprite子节点,并使用res://assets/environment/layers/tileset.png精灵图集作为纹理,启用区域功能以便你可以选择特定的瓦片。你可能希望你的平台比一个瓦片宽,所以你可以根据需要多次复制Sprite。开启网格吸附,以便精灵可以按行对齐:

图片 1

(8, 8)的网格设置对于对齐瓦片很有效。添加一个覆盖图像的矩形CollisionShape2D

图片 2

平台运动可以变得非常复杂(跟随路径、改变速度等),但这个例子将坚持使用在两个物体之间水平来回移动的平台。

这里是平台的脚本:

extends KinematicBody2D

export (Vector2) var velocity

func _physics_process(delta):
    var collision = move_and_collide(velocity * delta)
    if collision:
        velocity = velocity.bounce(collision.normal)

这次,你使用move_and_collide()来移动刚体。这是一个更好的选择,因为平台在与其他墙壁碰撞时不应该滑动。相反,它应该从碰撞体上弹回。只要你的碰撞形状是矩形的(就像TileMap刚体一样),这种方法就会很好用。如果你有一个圆形物体,弹跳可能会使平台以奇怪的方向移动,在这种情况下,你应该使用以下类似的方法来保持运动水平:

func _physics_process(delta):
    var collision = move_and_collide(velocity * delta)
    if collision:
        velocity.x *= -1

在检查器中将速度设置为(50, 0),然后转到你的关卡场景并在你的关卡中的某个位置实例化这些物体之一。确保它在两个物体之间,这样它就可以在它们之间来回移动。

运行场景并尝试在移动平台上跳跃。由于玩家使用了move_and_slide(),如果你站在平台上,他们会自动随着平台移动。

在你的关卡中添加尽可能多的这些物体。它们甚至可以相互弹跳,因此你可以制作覆盖大距离的移动平台链,并需要玩家跳跃的精确时间。

概述

在本章中,你学习了如何使用KinematicBody2D节点创建街机风格的物理效果。你还使用了AnimationPlayer来为角色行为创建各种动画,并广泛地使用了你在早期项目中学到的知识来整合一切。希望到这一点,你已经很好地掌握了场景系统以及 Godot 项目的结构。

记得你在项目设置中最初设置的“拉伸模式”和“方面”属性吗?运行游戏并观察当你调整游戏窗口大小时会发生什么。这些设置对于这种类型的游戏来说是最优的,但尝试将“拉伸模式”改为视口,然后使你的游戏窗口非常宽或高。尝试调整其他设置以查看不同缩放选项的效果。

再次提醒,在继续之前,花几分钟时间玩你的游戏并查看其各种场景和脚本,以回顾你是如何构建它的。回顾本章中你认为特别棘手的任何部分。

在下一章中,你将进入 3D 世界!

第六章:3D 迷你高尔夫

这本书中的前几个项目都是在 2D 空间中设计的。这是故意的,为了在保持项目范围有限的同时介绍 Godot 的各种功能和概念。在本章中,你将进入游戏开发的 3D 方面。对于一些人来说,3D 开发感觉管理起来要困难得多;对于另一些人来说,它则更为直接。无论如何,你确实需要理解一个额外的复杂性层次。

如果你之前从未使用过任何类型的 3D 软件,你可能会发现自己遇到了许多新概念。本章将尽可能多地解释它们,但请记住,在需要更深入理解特定主题时,要参考 Godot 文档。

本章中你将制作的游戏被称为迷你高尔夫。这包括一个小型可定制的球场、一个球和一个瞄准并射击球向洞的方向的界面。

这是本章你将学习的内容:

  • 导航 Godot 的 3D 编辑器

  • 空间节点及其属性

  • 导入 3D 网格并使用 3D 碰撞形状

  • 如何使用 3D 相机,包括静止的和移动的

  • 使用 GridMap 放置你的高尔夫球场的瓷砖

  • 设置照明和环境

  • PBR 渲染和材质简介

但首先,这里是对 Godot 中 3D 的简要介绍。

3D 简介

Godot 的一个优点是它能够处理 2D 和 3D 游戏。虽然你在这本书中之前学到的许多内容在 3D 中同样适用(节点、场景、信号等),但从 2D 到 3D 的转变会带来全新的复杂性和功能。首先,你会发现 3D 编辑器窗口中有一些额外的功能,熟悉如何在 3D 编辑器窗口中导航是个好主意。

在 3D 空间中的定位

当你在编辑器窗口顶部的 3D 按钮上点击时,你会看到 3D 项目视图:

图片

你首先应该注意到的是中心的三条彩色线条。这些是x(红色)、y(绿色)和z(蓝色)轴。它们相交的点就是原点,坐标为(0, 0, 0)

就像你使用Vector2(x, y)来表示二维空间中的一个位置一样,Vector3(x, y, z)描述了沿着这三个轴的三维空间中的一个位置。

在 3D 环境中工作时,会出现的一个问题是不同的应用程序使用不同的方向约定。Godot 使用 Y-Up 方向,因此当查看坐标轴时,如果x指向左/右,那么y就是上/下,而z是前/后。你可能在使用其他流行的 3D 软件时会发现它们使用 Z-Up。了解这一点是好的,因为它在在不同程序之间移动时可能会导致混淆。

另一个需要注意的主要方面是度量单位。在 2D 中,所有内容都是以像素为单位测量的,这在屏幕上绘制时是一个自然的度量基础。然而,当在 3D 空间中工作时,像素并不太有用。两个大小完全相同的对象,根据它们与摄像机的距离不同,将在屏幕上占据不同的区域(关于摄像机的更多信息即将揭晓)。因此,在 3D 空间中,Godot 中的所有对象都使用通用单位进行测量。您可以自由地称这些单位为米、英寸,甚至光年,具体取决于您游戏世界的规模。

Godot 的 3D 编辑器

在开始使用 3D 之前,简要回顾如何在 Godot 的 3D 空间中导航将很有用。相机由鼠标和键盘控制:

  • 鼠标滚轮向上/向下:缩放

  • 中间按钮 + 拖动:围绕当前目标旋转相机

  • Shift + 中间按钮 + 拖动:上下/左右平移相机

  • 右键点击 + 拖动:在当前位置旋转相机

如果您熟悉像Minecraft这样的流行 3D 游戏,您可以按Shift + F切换到 Freelook 模式。在此模式下,您可以使用 WASD 键在场景中飞行,同时用鼠标瞄准。再次按Shift + F退出 Freelook 模式。

您还可以通过点击左上角的[视角]标签来改变相机的视图。在这里,您可以快速定位相机到特定的方向,如俯视图或前视图:

这在结合使用多个视口的大屏幕上尤其有用。点击视图菜单,您可以将屏幕分割成多个空间视图,让您能够同时从各个侧面看到对象。

注意,这些菜单选项中的每一个都有一个与之关联的键盘快捷键。您可以通过点击编辑器 | 编辑器设置 | 3D 来调整 3D 导航和快捷键,以满足您的需求。

当使用多个视口时,每个视口都可以设置为不同的视角,这样您就可以同时从多个方向看到您动作的效果:

添加 3D 对象

是时候添加您的第一个 3D 节点了。就像所有 2D 节点都继承自Node2D,它提供了如positionrotation等属性一样,3D 节点继承自Spatial节点。将一个添加到场景中,您将看到以下内容:

您看到的那个彩色对象不是节点,而是一个 3D gizmo。Gizmos 是允许您在空间中移动和旋转对象的工具。三个环控制旋转,而三个箭头沿着三个轴移动(平移)对象。请注意,环和箭头是按照轴的颜色进行编码的。箭头沿着相应的轴移动对象,而环则围绕特定的轴旋转对象。还有三个小方块可以锁定一个轴,并允许您在单个平面上移动对象。

花几分钟时间进行实验,熟悉小工具。如果你发现自己迷失了方向,请使用撤销。

有时,小工具会碍事。你可以点击模式图标来限制自己只进行一种类型的变换:移动、旋转或缩放:

图片

QWER键是这些按钮的快捷键,允许快速在模式之间切换。

全局空间与局部空间

默认情况下,小工具控件在全局空间中操作。尝试旋转对象。无论你如何转动它,小工具的运动箭头仍然沿着轴指向。现在试试这个:将Spatial节点放回其原始位置和方向(或者删除它并添加一个新的)。围绕一个轴旋转对象,然后点击局部空间模式(T)按钮:

图片

观察小工具箭头发生了什么。现在它们指向的是对象的局部x/y/z轴,而不是世界轴。当你点击并拖动它们时,它们会相对于轴移动对象。在这些两种模式之间切换可以使放置对象精确到想要的位置变得容易得多。

变换

查看你的Spatial节点的检查器。现在你有了平移、旋转度数以及缩放,而不是位置属性。当你移动对象时,观察这些值如何变化。注意,平移表示对象相对于原点的坐标:

图片

你还会注意到一个变换属性,它也会随着你移动和旋转对象而改变。当你改变平移或旋转时,你会注意到 12 个变换量也会相应改变。

变换背后的数学解释超出了本书的范围,但简单来说,变换是一个矩阵,它同时描述了一个对象的平移、旋转和缩放。你之前在本书中的 Space Rocks 游戏中简要使用过 2D 等价物,但这个概念在 3D 中应用得更广泛。

代码中的变换

当通过代码定位 3D 节点时,你可以访问其transformglobal_transform属性,它们是Transform对象。Transform有两个子属性:originbasisorigin表示物体相对于其父物体的原点或全局原点的偏移。basis属性包含三个向量,这些向量定义了一个与物体一起移动的局部坐标系。当你处于局部空间模式时,想想小工具中的三个轴箭头。

你将在本节后面了解更多关于如何使用 3D 变换的信息。

网格

就像Node2D一样,Spatial节点本身没有大小或外观。在 2D 中,你添加了一个 Sprite 来为节点分配纹理。在 3D 中,你需要添加一个网格。网格是形状的数学描述。它由一组称为顶点的点组成。这些顶点通过称为的线连接起来,多个边(至少三个)共同构成一个

图片

例如,一个立方体由八个顶点、十二条边和六个面组成。

如果你曾经使用过 3D 设计软件,这对你来说将非常熟悉。如果你没有,并且你对学习 3D 建模感兴趣,Blender 是一个非常流行的开源工具,用于设计 3D 对象。你可以在互联网上找到许多教程和课程,帮助你开始使用 Blender。

导入网格

无论你使用什么建模软件,你都需要以 Godot 可读的格式导出你的模型。Wavefront (.obj) 和 Collada (.dae) 是最流行的。不幸的是,如果你使用 Blender,它的 Collada 导出器有一些缺陷,使其无法与 Godot 一起使用。为了解决这个问题,Godot 的开发者创建了一个名为Better Collada Exporter的 Blender 插件,你可以从godotengine.org/download下载。

如果你的对象是其他格式,例如 FBX,你需要使用转换工具将它们保存为 OBJ 或 DAE 格式,以便与 Godot 一起使用。

一种名为 GLTF 的新格式越来越受欢迎,并且与 Collada 相比有一些显著的优势。Godot 已经支持它,所以你可以自由地尝试使用任何你找到的这种格式的模型。

原语

如果你没有现成的模型,或者你只需要一个简单的模型,Godot 具有直接创建某些 3D 网格的能力。将MeshInstance节点作为 Spatial 的子节点添加,然后在检查器中点击网格属性:

图片

这些预定义的形状被称为原语,它们代表了一组方便的常用形状。你可以将这些形状用于各种目的,正如你将在本章后面看到的那样。选择“新建立方体网格”,你会在屏幕上看到一个普通的立方体。立方体本身是白色的,但由于 3D 编辑器窗口中的默认环境光,它可能在你屏幕上呈现蓝色。你将在本章后面学习如何处理光照。

多个网格

通常,你会发现一个由许多不同网格组成的对象。一个角色可能有其头部、躯干和四肢的单独网格。如果你有很多这种类型的对象,当引擎尝试渲染这么多网格时,可能会导致性能问题。因此,MultiMeshInstance被设计为提供将许多网格组合成一个单一对象的高性能方法。你可能现在还不需要它,因为在这个项目中它不是必需的,但请记住,它可能是一个以后会派上用场的工具。

相机

尝试运行带有你的立方体网格的场景。它在哪?在 3D 中,不使用Camera你将看不到游戏视口中的任何东西。添加一个相机,并使用相机的操纵杆将其定位并指向立方体,如以下截图所示:

图片

那个粉紫色、金字塔形状的物体被称为相机的 fustrum。它代表相机的视图,可以使其变窄或变宽以影响相机的 视野。fustrum 顶部的三角形箭头是相机的向上方向。

当你移动相机时,你可以使用右上角的预览按钮来检查你的目标。预览将始终显示所选相机可以看到的内容。

就像之前使用的 Camera2D 一样,一个 Camera 必须被设置为当前状态才能使其生效。它的其他属性会影响其 视角:视野、投影和近/远。这些属性的默认值对于这个项目来说很好,但你可以尝试调整它们,看看它们如何影响立方体的视图。完成操作后,使用撤销将一切恢复到默认值。

项目设置

现在你已经学会了如何在 Godot 的 3D 编辑器中导航,你准备好开始 Minigolf 项目了。与其他项目一样,从以下链接下载游戏资源,并将其解压到你的项目文件夹中。解压后的 assets 文件夹包含图像、3D 模型以及其他完成项目所需的资源。你可以从这里下载包含游戏艺术和声音(统称为 assets)的 Zip 文件,github.com/PacktPublishing/Godot-Game-Engine-Projects/releases

这款游戏将使用左鼠标按钮作为输入。输入映射没有为这个定义任何默认动作,所以你需要添加一个。打开项目 | 项目设置,然后转到输入映射选项卡。添加一个名为点击的新动作,然后点击加号添加一个鼠标按钮事件。选择左键:

创建课程

对于第一个场景,添加一个名为 Main 的节点作为场景的根节点。这个场景将包含游戏的主要部分,从课程本身开始。首先添加一个 GridMap 节点来布置课程。

网格地图

GridMapTileMap 节点的 3D 等价物,你在之前的项目中使用过。它允许你使用一组网格(包含在 MeshLibrary 中)并在网格中布置它们,以更快地设计环境。因为它是一个 3D 对象,你可以以任何方向堆叠网格,尽管在这个项目中,你将坚持在同一平面上。

创建网格库

res://assets 文件夹包含了一个为项目预生成的 MeshLibrary,其中包含所有必要的课程部分以及碰撞形状。然而,如果你需要更改它或创建自己的,你会发现这个过程与在 2D 中创建 TileSet 非常相似。

用于创建预生成的 MeshLibrary 的场景也可以在 res://assets 文件夹中找到。它的名字是 course_tiles_edit1.tscn。你可以随意打开它,看看它是如何设置的。

首先创建一个新的场景,以 Spatial 作为其根节点。向此节点添加任意数量的 MeshInstance。您可以在 res://assets/dae 文件夹中找到原始的课程网格,这些网格是从 Blender 导出的。

您给这些节点取的名字将是它们在 MeshLibrary 中的名字。

一旦添加了网格,就需要为它们添加静态碰撞体。创建与给定网格匹配的碰撞形状可能很复杂,但 Godot 有一种自动生成它们的方法。

选择一个网格,您将在编辑器窗口的顶部看到一个 Mesh 菜单:

图片

选择创建三角形静态体和 Godot 将创建一个 StaticBody 并使用网格数据添加一个 CollisionShape

图片

对每个网格对象都这样做,然后选择场景 | 转换到 | 网格库以保存资源。

绘制课程

MeshLibrary (res://assets/course_tiles.tres 或您创建的版本) 拖入检查器中 GridMap 的主题属性。同时,确保单元格/大小属性设置为 (2, 2, 2)

图片

通过从右侧的列表中选择瓦片块并在编辑器窗口中左键单击来绘制。通过按 S 可以围绕 y 轴旋转一个块。要删除瓦片,请使用 Shift + 右键单击。

目前,坚持使用简单的课程;当一切正常工作时,您可以在以后变得花哨。别忘了洞!

图片

现在,是时候看看当游戏运行时这会是什么样子了。向场景中添加一个 Camera。将其向上移动并调整角度,使其向下看课程。记住,您可以使用预览按钮来检查摄像机看到的内容。

运行场景。您会看到一切似乎都非常暗。默认情况下,场景中环境光最少。要更清楚地看到,您需要添加更多光源。

WorldEnvironment

照明是一个独立的复杂主题。决定灯光的位置以及如何设置它们的颜色和强度可以显著影响场景的外观。

Godot 在 3D 中提供了三个照明节点:

  • OmniLight:用于从所有方向发射的光,例如来自灯泡或蜡烛

  • DirectionalLight:来自遥远来源的无限光源,例如阳光

  • SpotLight:来自单个光源的方向性光源,例如手电筒

除了使用单个灯光外,您还可以使用 WorldEnvironment 设置环境光。

在场景中添加一个 WorldEnvironment 节点。在检查器中,选择环境属性中的“新建环境”。一切都会变黑,但别担心,您很快就会解决这个问题:

图片

点击新建环境,您将看到一个大型属性列表。您想要的是环境光。将颜色设置为白色,您会看到场景变得更亮。

请记住,环境光来自所有方向均匀。如果您的场景需要阴影或其他光照效果,您将想要使用 Light 节点之一。您将在本章后面看到光照节点是如何工作的。

完成场景

现在你已经布置好了课程,剩下两项:tee,即球体开始的位置,以及检测球体何时进入洞口的方法。

添加一个名为TeePosition3D节点。就像Position2D一样,此节点用于在空间中标记一个位置。将此节点放置在你想要球体开始的位置。确保将其放置在表面之上,这样球体就不会在地面内部生成。

要检测球体进入洞口,可以使用一个Area节点。此节点与 2D 版本直接对应:它可以在一个身体进入其分配的形状时发出信号。添加一个Area节点并给它一个CollisionShape子节点。

CollisionShape的子节点形状属性中添加一个SphereShape

要调整碰撞球体的大小,使用单个半径调整手柄:

Area放置在洞口下方,并调整碰撞形状的大小,使其与洞底重叠。不要让它延伸到洞口顶部,否则球体在尚未落下时会被计为在洞内

如果你使用透视按钮从不同方向一次查看洞口,可能会更容易定位节点。当你完成定位后,将Area的名称更改为Hole

球体

现在,你准备好制作球体了。由于球体需要物理属性——重力、摩擦力、与墙壁的碰撞以及其他物理属性——“刚体”将是节点选择的最佳选择。创建一个新的场景,并命名为BallRigidBody

RigidBody是你在第三章,“逃离迷宫”中使用的RigidBody2D节点的 3D 等效物。它的行为和属性非常相似,并且你可以使用许多相同的方法与之交互,例如apply_impulse()_integrate_forces()

球的形状需要是一个球体。基本的三维形状,如球体、立方体、圆柱体等,被称为原语。Godot 可以使用MeshInstance节点自动创建原语,因此添加一个作为身体的子节点。在检查器中,在网格属性中选择“新建球体网格”:

默认大小太大,因此单击新的球体网格并设置其大小属性,半径为0.15,高度为0.3

接下来,向Ball添加一个CollisionShape节点并给它一个SphereShape。使用大小手柄(橙色点)调整其大小以适应网格:

测试球体

要测试球体,使用实例按钮将其添加到Main场景中。将其放置在课程上方某处并播放。你应该看到球体落下并落在地面上。你可能发现添加另一个位于课程侧面的Camera节点以获得不同视角很有帮助。设置你想要使用的相机的当前属性。

你还可以通过设置其线性/速度属性来暂时给球一些运动。尝试设置不同的值并播放场景。记住,y轴是向上的,使用过大的值可能会导致球直接穿过墙壁。完成后将其设置回(0, 0, 0)

改善碰撞

当调整速度时,你可能注意到球有时会直接穿过墙壁和/或以奇怪的方式反弹,尤其是如果你选择一个高值。你可以对RigidBody属性进行一些调整,以改善高速下的碰撞行为。

首先,打开连续碰撞检测CCD)。你将在检查器中找到它列出的连续 Cd。使用 CCD 会改变物理引擎计算碰撞的方式。通常,引擎通过首先移动对象,然后测试和解决碰撞来运行。这很快,并且在大多数常见情况下都有效。然而,当使用 CCD 时,引擎将对象沿其路径投影移动,并尝试预测碰撞可能发生的位置。这比默认行为慢,因此效率不高,尤其是在模拟许多对象时,但它要精确得多。由于你游戏中只有一个球,CCD 是一个不错的选择,因为它不会引入任何明显的性能损失,但会大大提高碰撞检测。

球也需要更多的动作,所以将“反弹”设置为0.2,将“重力比例”设置为2

最后,你可能也注意到球停止需要很长时间。将Linear/Damp属性设置为0.5,将Angular/Damp设置为0.1,这样你就不必等待球停止移动那么长时间。

用户界面

现在球已经在轨道上了,你需要一种瞄准和击打球的方法。对于这种类型的游戏,有许多可能的控制方案。对于这个项目,你将使用两步过程:

  1. 瞄准:箭头将出现来回摆动。点击鼠标按钮将瞄准方向设置为箭头的方向。

  2. 射击:力量条将在屏幕上上下移动。点击鼠标将设置力量并发射球。

瞄准箭头

在 3D 中绘制一个物体并不像在 2D 中那么容易。在许多情况下,你将不得不切换到像 Blender 这样的 3D 建模程序来创建你的游戏物体。然而,在这种情况下,Godot 的原生形状已经为你准备好了;要制作一个箭头,你只需要两个网格:一个长而薄的矩形和一个三角棱柱。

通过添加一个带有MeshInstance子节点的Spatial节点来创建一个新的场景。添加一个新的CubeMesh。点击网格属性,并将大小属性设置为(0.5, 0.2, 2)。这是箭头的主体,但它仍然有一个问题。如果你旋转父对象,网格将围绕其中心旋转。相反,你需要箭头围绕其末端旋转,所以将MeshInstance的变换/平移更改为(0, 0, -1)

图片

尝试使用 gizmo 旋转Arrow(根)节点,以确认形状现在偏移正确。

要创建箭头的尖端,添加另一个MeshInstance,这次选择 New PrismMesh。将其大小设置为(1.5, 2, 0.5)。现在你有一个平面的三角形形状。为了将其正确放置在矩形的末端,将网格的 Transform/Translation 更改为(0, 0, -3),并将其 Rotation Degrees 更改为(-90, 0, 0)

使用原语是直接在 Godot 中创建占位符对象的一种快速方法,而无需打开你的 3D 建模软件。

最后,通过将根节点的 Transform/Scale 设置为(0.5, 0.5, 0.5)来缩小整个箭头:

现在你已经完成了一个箭头形状。保存它,然后在Main场景中实例化它。

UI 显示

创建一个新的场景,其中包含一个名为UI的 CanvasLayer。在这个场景中,你将显示力量条以及玩家的得分次数。添加一个MarginContainerVBoxContainer、两个Label属性和一个TextureProgress。按照以下名称命名:

MarginContainerCustom Constants全部设置为20。将Xolonium-Regular.ttf字体添加到两个Label节点中,并将它们的字体大小设置为30。将Shots标签的文本设置为 Shots: 0,将Label的文本设置为 Power。将res://assets中的一个彩色条纹理拖放到PowerBar的 Texture/Progress 中。默认情况下,TextureProgress条从左到右增长,因此对于垂直方向,将填充模式更改为从下到上。

完成的 UI 布局应该看起来像这样:

Main场景中实例化此场景。因为它是一个 CanvasLayer,所以它将被绘制在 3D 相机视图之上。

脚本

在本节中,你将创建使一切协同工作的脚本。游戏流程如下:

  1. 将球放置在起点(Tee)

  2. 角度模式:瞄准球

  3. 力量模式:设置击球力量

  4. 发射球

  5. 重复步骤 2,直到球进入洞中

UI

将以下脚本添加到UI以更新 UI 元素:

extends CanvasLayer

var bar_red = preload("res://assets/bar_red.png")
var bar_green = preload("res://assets/bar_green.png")
var bar_yellow = preload("res://assets/bar_yellow.png")

func update_shots(value):
    $Margin/Container/Shots.text = 'Shots: %s' % value

func update_powerbar(value):
    $Margin/Container/PowerBar.texture_progress = bar_green
    if value > 70:
        $Margin/Container/PowerBar.texture_progress = bar_red
    elif value > 40:
        $Margin/Container/PowerBar.texture_progress = bar_yellow
    $Margin/Container/PowerBar.value = value

这两个函数提供了一种在需要显示新值时更新 UI 元素的方法。正如你在 Space Rocks 游戏中做的那样,根据进度条的大小更改纹理,给力量级别带来很好的高/中/低感觉。

Main

接下来,向Main添加一个脚本,并从以下变量开始:

extends Node

var shots = 0
var state
var power = 0
var power_change = 1
var power_speed = 100
var angle_change = 1
var angle_speed = 1.1
enum {SET_ANGLE, SET_POWER, SHOOT, WIN}

enum列出了游戏可能处于的状态,而power*angle*变量将用于设置它们的相应值并在时间上改变它们。看看以下代码片段:

func _ready():
    $Arrow.hide()
    $Ball.transform.origin = $Tee.transform.origin
    change_state(SET_ANGLE)

开始时,球被放置在Tee的位置,使用两个物体的transform.origin属性。然后,游戏被置于SET_ANGLE状态:

func change_state(new_state):
    state = new_state
    match state:
        SET_ANGLE:
            $Arrow.transform.origin = $Ball.transform.origin
            $Arrow.show()
        SET_POWER:
            pass
        SHOOT:
            $Arrow.hide()
            $Ball.shoot($Arrow.rotation.y, power)
            shots += 1
            $UI.update_shots(shots)
        WIN:
            $Ball.hide()
            $Arrow.hide()

SET_ANGLE状态将箭头放置在球的位置。回想一下,你偏移了箭头,所以它看起来是从球指向外。当旋转箭头时,你围绕y轴旋转它,使其保持平坦(y轴向上)。

此外,请注意,在进入SHOOT状态时,你在Ball上调用shoot()函数。你将在下一节中添加该函数。

下一步是检查用户输入:

func _input(event):
    if event.is_action_pressed('click'):
        match state:
            SET_ANGLE:
                change_state(SET_POWER)
            SET_POWER:
                change_state(SHOOT)

游戏的唯一输入是点击左鼠标按钮。根据你处于什么状态,点击它将过渡到下一个状态:

func _process(delta):
    match state:
        SET_ANGLE:
            animate_angle(delta)
        SET_POWER:
            animate_power_bar(delta)
        SHOOT:
            pass

_process()中,你根据状态确定要动画化什么。现在,它只是调用当前正在设置的属性动画的函数:

func animate_power_bar(delta):
    power += power_speed * power_change * delta
    if power >= 100:
        power_change = -1
    if power <= 0:
        power_change = 1
    $UI.update_powerbar(power)

func animate_angle(delta):
    $Arrow.rotation.y += angle_speed * angle_change * delta
    if $Arrow.rotation.y > PI/2:
        angle_change = -1
    if $Arrow.rotation.y < -PI/2:
        angle_change = 1

这两个函数都很相似。它们在两个极端之间逐渐改变一个值,当达到极限时反转方向。请注意,箭头在±90 度的弧上动画。

在球脚本中,需要两个函数。首先,必须对球施加一个脉冲以使其发射。其次,当球停止移动时,需要通知Main场景,以便玩家可以再次射击:

extends RigidBody

signal stopped

func shoot(angle, power):
    var force = Vector3(0, 0, -1).rotated(Vector3(0, 1, 0), angle)
    apply_impulse(Vector3(), force * power / 5)

func _integrate_forces(state):
    if state.linear_velocity.length() < 0.1:
        emit_signal("stopped")
        state.linear_velocity = Vector3()

正如你在《太空岩石》游戏中看到的,你可以在_integrate_forces()中使用物理状态安全地停止球,如果速度变得太慢。记住,由于浮点数精度,速度可能不会自行减慢到0。球可能看起来已经停止,但它的速度实际上可能是0.0000001。而不是等待它达到0,你可以使球停止,如果其速度低于0.1

要检测球是否掉入洞中,请点击Main中的Area并连接其body_entered信号:

func _on_Hole_body_entered(body):
    print("Win!")
    change_state(WIN)

切换到WIN状态将防止球的stopped信号允许再次射击。

测试它

尝试运行游戏。你可能想确保这部分有一个非常简单的课程,直线射击到洞口。你应该看到箭头在球的位置旋转。当你点击鼠标按钮时,箭头停止,力量条开始上下移动。当你第二次点击时,球被发射。

如果这些步骤中的任何一个不起作用,不要继续前进,而是停止并返回尝试找出你遗漏了什么。

一切正常后,你会注意到一些需要改进的地方。首先,当球停止移动时,箭头可能不会指向你想要的方向。原因是起始角度始终是0,这指向z轴,然后箭头从那里挥动±90 度。在下一节中,你将有两个改进瞄准的方法。

改善瞄准 – 选项 1

通过使箭头的 180 度挥动始终指向洞口,可以提高目标。

Main脚本中添加一个名为hole_dir的变量。在瞄准开始时,这将通过以下函数设置为指向洞口的角:

func set_start_angle():
    var hole_pos = Vector2($Hole.transform.origin.z, $Hole.transform.origin.x)
    var ball_pos = Vector2($Ball.transform.origin.z, $Ball.transform.origin.x)
    hole_dir = (ball_pos - hole_pos).angle()
    $Arrow.rotation.y = hole_dir

记住,球的位置是其中心,所以它略微高于表面,而洞的中心则稍微低于表面。因此,直接指向它们之间的箭头会指向地面的向下角度。为了防止这种情况并保持箭头水平,你可以只使用transform.originxz值来生成一个Vector2

现在初始箭头方向是指向洞的,因此你可以改变动画,将±90 度添加到该角度:

func animate_angle(delta):
    $Arrow.rotation.y += angle_speed * angle_change * delta
    if $Arrow.rotation.y > hole_dir + PI/2:
        angle_change = -1
    if $Arrow.rotation.y < hole_dir - PI/2:
        angle_change = 1

最后,将SET_ANGLE状态更改为调用函数:

SET_ANGLE:
    $Arrow.transform.origin = $Ball.transform.origin
    $Arrow.show()
    set_start_angle()

再次尝试游戏。现在球应该总是指向洞的大致方向。这更好,但你仍然不能指向你喜欢的任何方向。为此,你可以尝试瞄准选项 2。

改进瞄准 – 选项 2

之前的方法是可以接受的,但还有一种可能性。不是让箭头来回弹跳,你可以通过移动鼠标左右瞄准。这个选项的好处是,你不受 180 度运动的限制。

为了实现这一点,你可以利用特定的输入事件:InputEventMouseMotion。当鼠标移动时,此事件发生,并返回一个relative属性,表示鼠标在上一帧中移动的距离。你可以使用这个值来旋转箭头一个小量。

首先,通过从_process()中移除SET_ANGLE部分来禁用箭头动画。接下来,将以下代码添加到_input()中:

func _input(event):
    if event is InputEventMouseMotion:
        if state == SET_ANGLE:
            $Arrow.rotation.y -= event.relative.x / 150

这设置了箭头的旋转,当你将鼠标在屏幕上左右移动时。除以150确保移动不会太快,并且如果你将鼠标从屏幕的一侧移动到另一侧,你可以移动完整的 360 度。根据你鼠标的灵敏度,你可以调整这个值以适应你的偏好。

摄像头改进

另一个问题,尤其是如果你有一个相对较大的课程,那就是如果你的摄像头放置在展示发球区附近,可能无法很好地显示课程的其他部分,甚至根本无法显示。这可能会在球位于某些位置时使瞄准变得具有挑战性。

在本节中,你将学习两种不同的方法来解决此问题。一种方法涉及创建多个摄像头并激活离球位置最近的那个。另一种解决方案是创建一个环绕摄像头,它跟随球并允许玩家控制从任何角度查看课程。

多个摄像头

添加第二个Camera节点并将其放置在洞附近或课程的对端,例如:

向这个第二个相机添加一个Area子节点。命名为Camera2Area,然后添加一个CollisionShape。你也可以使用球形形状,但在这个例子中,选择一个BoxShape。请注意,因为你已经旋转了相机,所以盒子也旋转了。你可以通过将CollisionShape的旋转设置为相反的值来反转这一点,或者你可以让它保持旋转。无论如何,调整盒子的大小和位置以覆盖你想要相机负责的赛道部分:

图片 3

现在,将区域的body_entered信号连接到主脚本。当球进入区域时,将发出信号,你可以更改活动相机:

func _on_Cam2Area_body_entered(body):
    $Camera2.current = true

再次玩游戏并击打球向新的相机区域。确认当球进入区域时相机视图发生变化。对于大型场地,你可以添加尽可能多的相机并将它们设置为激活不同部分的赛道。

这种方法的缺点是相机仍然是静态的。除非你非常小心地将它们放置在正确的位置,否则从赛道上的某些位置瞄准球可能仍然不舒服。

旋转相机

在许多 3D 游戏中,玩家可以控制一个可以按需旋转和移动的相机。通常,控制方案使用鼠标和键盘的组合。第一步将是添加一些新的输入动作:

图片 1

WASD 键将被用来通过左右和上下移动来旋转相机。鼠标滚轮将控制缩放。

创建陀螺仪

相机移动需要有一些限制。一方面,它应该始终保持水平,而不是倾斜。尝试这样做:拿一个相机,围绕 x 轴(红色环)旋转一小部分,然后围绕z轴(蓝色环)旋转一小部分。现在,反转x旋转并点击预览按钮。你是否看到相机现在倾斜了?

解决这个问题的方法是将相机放置在陀螺仪上——一种设计用来在运动中保持物体水平状态的装置。你可以使用两个Spatial节点来创建一个陀螺仪,它们将分别控制相机的左右和上下移动。

首先,请确保从场景中移除任何其他Camera节点。如果你尝试了上一节中的多相机设置并且不想删除它们,你可以将它们的Current值设置为Off,并断开任何Area信号。

添加一个新的名为GimbalOutSpatial节点并将其放置在赛道中心附近。确保不要旋转它。给它一个名为GimbalInSpatial子节点,然后向该节点添加一个Camera。将相机的变换/平移设置为(0, 0, 10)

图片 2

这是陀螺仪的工作原理:外部的空间允许仅在y轴上旋转,而内部的一个x轴上旋转。你可以亲自尝试,但请确保切换到本地空间模式(参见3D 简介部分)。记住,只移动外部陀螺仪节点的绿色环和内部节点的红色环。不要改变相机设置。实验完成后,将所有旋转重置为0

要在游戏中控制这种运动,请将脚本附加到GimbalOut上并添加以下内容:


extends Spatial

var cam_speed = PI/2
var zoom_speed = 0.1
var zoom = 0.5

func _input(event):
    if event.is_action_pressed('cam_zoom_in'):
        zoom -= zoom_speed
    if event.is_action_pressed('cam_zoom_out'):
        zoom += zoom_speed

func _process(delta):
    zoom = clamp(zoom, 0.1, 2)
    scale = Vector3(1, 1, 1) * zoom
    if Input.is_action_pressed('cam_left'):
        rotate_y(-cam_speed * delta)
    if Input.is_action_pressed('cam_right'):
        rotate_y(cam_speed * delta)
    if Input.is_action_pressed('cam_up'):
        $GimbalIn.rotate_x(-cam_speed * delta)
    if Input.is_action_pressed('cam_down'):
        $GimbalIn.rotate_x(cam_speed * delta)
    $GimbalIn.rotation.x = clamp($GimbalIn.rotation.x, -PI/2, -0.2)

如你所见,左右动作仅在y轴上旋转GimbalOut,而上下动作在x轴上旋转GimbalIn。整个陀螺仪系统的scale属性用于处理缩放。还需要使用clamp()设置一些限制。旋转限制将上下移动保持在-0.2(几乎与地面水平)到-90度(直视下方)之间,而缩放限制则防止你离得太近或太远。

运行游戏并测试相机控制。你应该能够使用鼠标滚轮在所有四个方向上平移和缩放。然而,陀螺仪的位置仍然是静态的,因此你可能从某些角度难以正确看到球。

追踪相机

对相机的一个最终改进是让它跟随球。现在你有一个稳定的、可旋转的相机,如果陀螺仪设置为跟随球的位置,它将工作得很好。将以下行添加到Main场景的_process()函数中:

$GimbalOut.transform.origin = $Ball.transform.origin

注意,你不应该将陀螺仪的变换设置为球的变换,否则它也会随着球的滚动而旋转

现在尝试游戏并观察相机如何追踪球的运动,同时仍然能够旋转和缩放。

视觉效果

球和其他场景中网格的外观已经被故意设计得非常简单。你可以将这个扁平的白色球想象成一个空白画布,准备好按照你的意愿塑造和塑形。将图形应用到 3D 模型上可能是一个非常复杂的过程,尤其是如果你不熟悉它。首先,一些词汇:

  • 纹理:纹理是平面的、二维图像,它们被包裹在 3D 对象周围,以赋予它们更有趣的外观。想象一下包裹一个礼物:平面的纸张被折叠在包裹周围,以适应其形状。纹理可以是非常简单的,也可以非常复杂,这取决于它们设计要应用到哪种形状上。一个简单的例子可能是一小块可以重复在大型墙面物体上的砖块图案。

  • 着色器:虽然纹理决定了对象表面绘制的内容,但着色器决定了绘制的方式。想象一下同样的砖墙。如果它湿了会是什么样子?网格和纹理仍然是相同的,但光线从其反射的方式会有很大的不同。这就是着色器的功能:在不实际改变对象的情况下改变其外观。着色器通常用一种专门的编程语言编写,并且可以使用大量的高级数学,这些细节超出了本书的范围。对于许多效果,编写自己的着色器是不可避免的。然而,Godot 提供了一种创建着色器的替代方法,允许在不深入研究着色器代码的情况下对对象进行大量定制:ShaderMaterial

  • 材质:Godot 使用一种称为基于物理的渲染PBR)的计算机图形模型。PBR 的目标是以更准确地模拟现实世界中光线作用的方式渲染物体的表面。这些效果通过Material属性应用于网格。材质基本上是纹理和着色器的容器。而不是单独应用它们,它们被包含在材质中,然后添加到对象上。材质的属性决定了纹理和着色器效果如何应用。使用 Godot 内置的材质属性,您可以模拟各种现实世界(或风格化)的物理材质,如石头、布料或金属。如果内置属性不足以满足您的需求,您可以编写自己的着色器代码以添加更多效果。

您可以使用SpatialMaterial将 PBR 材质添加到网格中。

SpatialMaterials

点击球体的MeshInstance,然后在材质下选择新建SpatialMaterial,然后点击新材质。您将看到大量的参数,远超过本书所能涵盖的范围。本节将重点介绍一些使球体看起来更吸引人的最有用参数。鼓励您访问docs.godotengine.org/en/3.0/tutorials/3d/spatial_material.html以获取所有SpatialMaterial设置的完整解释。为了改善球体的外观,尝试对这些参数进行实验:

  • Albedo:此属性设置材质的基本颜色。更改此属性可以使球体呈现您喜欢的任何颜色。如果您正在处理需要应用纹理的对象,您也可以在此处添加它。

  • 金属和粗糙度:这些参数控制表面如何反射。两者都可以设置为 01 之间的值。金属值控制 光泽度;值越高,反射的光越多。粗糙度值对反射应用一定程度的模糊。通过调整这两个属性,你可以模拟各种材料。以下是如何 粗糙度金属 属性影响物体外观的指南。请记住,照明和其他因素也会改变表面外观。了解光和反射如何与表面属性相互作用是学习设计有效 3D 对象的重要组成部分:

  • 法线图:法线映射是一种 3D 图形技术,用于 模拟 表面上的凹凸。在网格本身中建模这些会导致组成对象的三角形数量或面数大幅增加,从而降低性能。相反,使用一个 2D 纹理来映射这些小表面特征会产生光和影的图案。然后,照明引擎使用这些信息来改变照明,就像那些细节实际上存在一样。一个正确构建的法线图可以为原本看起来平淡无奇的对象添加大量细节。

这个球体是一个很好的法线映射应用的例子,因为真实的高尔夫球在其表面有数百个凹坑,但球体原语是一个光滑的表面。使用常规纹理可以添加斑点,但它们看起来会显得扁平且涂鸦在上面。模拟这些凹坑的法线图看起来像这样:

它看起来并不起眼,但红色和蓝色的图案包含了告诉引擎在那个点表面应该朝哪个方向的信息,因此光线应该从哪个方向反射出来。注意顶部和底部的拉伸——这是因为这张图片是为了包裹成球形形状而制作的。

启用法线图属性,并将 res://assets/ball_normal_map.png 拖入 纹理 字段。最初将 Albedo 颜色设置为白色,这样你可以最好地看到效果。调整 Depth 参数以增加或减少效果强度。负值会使凹坑看起来是凹进去的;在 -1.0-1.5 之间的值是一个不错的选择:

花些时间尝试这些设置,找到你喜欢的组合。别忘了在游戏中也试试,因为 WorldEnvironment 的环境光照会影响最终结果。

环境选项

当你添加了 WorldEnvironment,你唯一更改的参数是 环境光照 颜色。在本节中,你将了解一些你可以调整以改善视觉效果的其他属性:

  • 背景:此参数允许你指定世界的背景看起来像什么。默认值是 Clear Color,即你目前看到的纯灰色。将模式更改为 Sky,并在 Sky 属性中,选择新的程序化天空。请注意,天空不仅仅是背景外观——物体将反射和吸收其环境光。观察当改变Energy参数时球的外观如何变化。此设置可用于营造白天或夜晚天空的印象,甚至外星行星的印象。

  • 屏幕空间环境光遮蔽SSAO):当启用时,此参数与任何环境光一起工作,在角落产生阴影。现在你有两个环境光来源:背景(天空)和环境光设置。启用 SSAO 后,你会立即看到改进,使课程的墙壁看起来不那么虚假和塑料。请随意尝试调整各种 SSAO 属性,但请记住,微小的变化可以产生很大的差异。以小增量调整属性,并在更改它们之前观察效果。

  • DOF 远模糊景深为距离相机一定距离以上的物体添加模糊效果。尝试调整距离属性以查看效果。

有关环境效果的高级使用信息,请参阅docs.godotengine.org/en/3.0/tutorials/3d/environment_and_post_processing.html

灯光

DirectionalLight添加到场景中。此类光线模拟无限数量的平行光线,因此常用于表示阳光或另一个照亮整个区域的光源。节点在场景中的位置无关紧要,只有其方向,因此你可以将其放置在任何你喜欢的地方。使用 gizmo 定位它,使其以角度击中课程,然后切换 Shadow/Enabled 为开启,以便你会看到从墙壁和其他物体投射出的阴影:

图片

有许多属性可用于调整和改变阴影的外观,无论是在所有Light节点都存在的阴影部分,还是在特定于DirectionalLight方向性阴影部分。默认值将适用于大多数通用情况,但你可能需要调整的一个属性是最大距离。降低此值将改善阴影外观,但仅当相机距离小于给定距离时。如果你的相机将主要靠近物体,你可以降低此值。要查看效果,尝试将其设置为10并缩放,然后将其设置为1000进行相同的操作。

方向性光线甚至可以用来模拟昼夜循环。如果你将脚本附加到光线并围绕一个轴缓慢旋转它,你会看到阴影的变化,就像太阳在升起和落下一样。

摘要

本章带你进入了 3D 图形的世界。Godot 的一个巨大优势是,在 2D 和 3D 中使用了相同的工具和工作流程。你关于创建场景、实例化和使用信号的过程中学到的所有内容都以相同的方式工作。例如,你为 2D 游戏构建的带有控制节点的界面可以放入 3D 游戏中,并且会以同样的方式工作。

在本章中,你学习了如何在 3D 编辑器中导航,使用工具箱来查看和放置节点。你了解了网格和如何使用 Godot 的原生形状快速创建自己的对象。你使用了 GridMap 来布置你的迷你高尔夫球场。你学习了如何使用摄像机、照明和世界环境来设计你的游戏在屏幕上的显示效果。最后,你体验了通过 Godot 的 SpatialMaterial 资源使用 PBR 渲染。

恭喜你,你已经走到了终点!但有了这五个项目,你成为游戏开发者的旅程才刚刚开始。随着你对 Godot 功能的熟练掌握,你将能够制作出你所能想象到的任何游戏。

第七章:其他主题

恭喜!在这本书中你构建的项目让你开始走上成为 Godot 专家的道路。然而,你只是刚刚触及了 Godot 可能性的表面。随着你技能的提高,以及项目规模的扩大,你需要知道如何找到解决问题的方案,如何分发你的游戏以便他人可以玩,甚至如何扩展引擎本身。

在本章中,你将了解以下主题:

  • 如何有效地使用 Godot 内置的文档

  • 将项目导出以在其他平台上运行

  • 在 Godot 中使用其他编程语言

  • 如何使用 Godot 的资产库安装插件

  • 成为 Godot 贡献者

  • 社区资源

使用 Godot 的文档

学习 Godot 的 API 最初可能会感觉令人压倒。你该如何学习所有不同的节点,以及每个节点包含的属性和方法呢?幸运的是,Godot 内置的文档可以帮助你。养成经常使用它的习惯:在学习过程中,它将帮助你找到所需内容;而且,当你熟悉环境后,快速查阅方法或属性也是一个很好的方式。

当你在编辑器的“脚本”选项卡中时,你会在右上角看到以下按钮:

“在线文档”按钮将在你的浏览器中打开文档网站。如果你有多个显示器设置,保持 API 参考在一边以便快速查阅,当你正在 Godot 中工作时,这会非常有用。

另外两个按钮允许你在 Godot 编辑器中直接查看文档。“类”按钮允许你浏览可用的节点和对象类型,而“搜索帮助”按钮则允许你搜索任何方法或属性名称。这两个搜索都是“智能”的,这意味着你可以输入单词的一部分,随着你输入,结果会逐渐缩小。请看以下截图:

当你找到所需的属性或方法时,点击“打开”,该节点的文档引用将出现。

阅读 API 文档

当你找到你想要的节点文档时,你会看到它遵循一个常见的格式,顶部是节点的名称,然后是几个信息子节,如下面的截图所示:

文档顶部有一个名为“Inherits”的列表,它显示了特定节点从Object(Godot 的基础对象类)开始的所有类继承链。例如,一个 Area2D 节点具有以下继承链:

CollisionObject2D < Node2D < CanvasItem < Node < Object

这让你可以快速查看此类对象可能具有的其他属性。你可以点击任何节点名称以跳转到该节点的文档。

您还可以查看哪些节点类型(如果有)继承自特定节点,以及节点的一般描述。下面,您可以查看节点的成员变量和方法。大多数方法和类型名称是链接,因此您可以点击任何项目以了解更多信息。

在您工作的过程中,养成定期查阅 API 文档的习惯。您会发现您将很快开始更深入地理解一切是如何协同工作的。

导出项目

最终,您的项目将达到您希望与世界分享的阶段。导出您的项目意味着将其转换为没有 Godot 编辑器的人可以运行的一个包。您可以为许多流行的平台导出您的项目。

在撰写本文时,Godot 支持以下目标平台:

  • Windows 通用

  • Windows 桌面

  • macOS

  • Linux

  • Android (移动端)

  • iOS (移动端)

  • HTML5 (网页)

导出项目的具体方法取决于您要针对的平台。例如,要导出 iOS,您必须在安装了 Xcode 的 macOS 计算机上运行。

每个平台都是独特的,由于硬件限制、屏幕尺寸或其他因素,您的游戏的一些功能可能在某些平台上无法工作。例如,如果您想将“Coin Dash”游戏(来自第一章,简介)导出为 Android 平台,您的玩家将无法移动,因为键盘控制将不起作用!对于该平台,您需要在游戏代码中包含触摸屏控制(关于这一点稍后会有更多介绍)。

您甚至可能发现需要为不同的平台在项目设置中设置不同的值。您可以通过选择设置并点击“为...覆盖”来完成此操作。这将创建一个针对该平台的新设置。

例如,如果您想启用 HiDPI 支持,但不允许 Android 使用,您可以为此设置创建一个覆盖:

图片

每个平台都是独特的,在配置项目以导出时需要考虑许多因素。请查阅官方文档以获取有关导出到您所需平台的最新说明。

获取导出模板

导出模板是针对每个目标平台编译的 Godot 版本,但不包括编辑器。您的项目将与目标平台的模板结合以创建一个独立的应用程序。

要开始,您必须下载导出模板。从编辑器菜单中点击“管理导出模板”:

图片

在此窗口中,您可以点击下载以获取导出模板:

图片

您也可以从 Godot 网站godotengine.org/download下载模板。如果您选择这样做,请使用“从文件安装”按钮来完成安装。

模板的版本必须与您使用的 Godot 版本相匹配。如果您升级到 Godot 的新版本,请确保您也下载了相应的模板,否则您的导出项目可能无法正常工作。

导出预设

当您准备好导出项目时,点击“项目”|“导出”:

在此窗口中,您可以通过点击“添加...”并从列表中选择平台来为每个平台创建“预设”。您可以为每个平台创建尽可能多的预设。例如,您可能希望为项目创建调试和发布版本。

每个平台都有自己的设置和选项,太多无法在此描述。默认值通常很好,但在分发项目之前,您应该彻底测试它们。

在“资源”标签中,您可以自定义要导出的项目部分。例如,您可以选择仅导出选定的场景或从项目中排除某些源文件:

“补丁”标签允许您为之前导出的项目创建更新。

最后,“功能”标签会显示(在“选项”标签中配置的)平台的功能摘要。这些功能可以确定哪些属性由项目设置中的“覆盖”值自定义:

导出

窗口底部有两个导出按钮。第一个按钮“导出 PCK/Zip”将仅创建项目数据的 PCK 或打包版本。这不包括可执行文件,因此游戏不能独立运行。此方法适用于您需要为游戏提供附加组件或 DLC(可下载内容)的情况。

第二个按钮“导出项目”将创建游戏的可执行版本,例如 Windows 的 .exe 或 Android 的 .apk

点击“保存”,您将拥有一个可玩的游戏版本。

示例 – Android 平台的 Coin Dash

如果您拥有安卓设备,您可以按照此示例将 Coin Dash 游戏导出为移动平台。对于其他平台,请参阅 Godot 的文档,链接为 docs.godotengine.org/en/latest/getting_started/workflow/export

移动设备具有各种各样的功能。始终参考前述链接中的官方文档,以获取有关您平台的信息以及可能适用于您的设备的任何限制。在大多数情况下,Godot 的默认设置将适用,但移动开发有时更像是一门艺术而非科学,您可能需要进行一些实验并寻找帮助,以便让一切正常工作。

修改游戏

因为本章编写的游戏使用键盘控制,所以如果不做些修改,你将无法在移动设备上玩游戏。幸运的是,Godot 支持触摸屏输入。首先,打开项目设置,在“显示/窗口”部分,确保“方向”设置为纵向,并开启“模拟触摸屏”。这将允许你通过将鼠标点击视为触摸事件来在计算机上测试程序:

接下来,你需要更改玩家控制。不再使用四个方向输入,玩家将移动到触摸事件的位置。按照以下方式更改玩家脚本:

var target = Vector2()

func _input(event):
    if event is InputEventScreenTouch and event.pressed:
        target = event.position

func _process(delta):
    velocity = (target - position).normalized() * speed
    if (target - position).length() > 5:
        position += velocity * delta
    else:
        velocity = Vector2()

    if velocity.length() > 0:
        $AnimatedSprite.animation = "run"
        $AnimatedSprite.flip_h = velocity.x < 0
    else:
        $AnimatedSprite.animation = "idle"

尝试一下,确保鼠标点击会导致玩家移动。如果一切正常,你就可以为安卓开发设置你的计算机了。

准备你的系统

为了将你的项目导出到安卓,你需要从developer.android.com/studio/下载安卓软件开发工具包SDK)和从www.oracle.com/technetwork/java/javase/downloads/index.html下载Java 开发工具包JDK)

当你第一次运行 Android Studio 时,点击“配置”|“SDK 管理器”,并确保安装 Android SDK Platform-Tools:

这将安装adb命令行工具,Godot 使用它来与你的设备通信。

安装开发工具后,通过运行以下命令创建一个调试密钥库:

keytool -keyalg RSA -genkeypair -alias androiddebugkey -keypass android -keystore debug.keystore -storepass android -dname "CN=Android Debug,O=Android,C=US" -validity 9999

在 Godot 中,点击“编辑器”|“编辑器设置”,找到“导出/安卓”部分,并设置系统上应用程序的路径。请注意,你只需做一次,因为编辑器设置与项目设置是独立的:

导出

你现在可以导出了。点击“项目”|“导出”,并为安卓添加一个预设(参见上一节)。点击“导出项目”按钮,你将得到一个可以安装在你设备上的安卓包工具包APK)。你可以使用图形工具或通过命令行使用adb来完成此操作:

adb install dodge.apk

注意,如果你的系统支持,连接一个兼容的安卓设备将导致一键部署按钮在 Godot 编辑器中显示:

点击此按钮将导出项目并在你的设备上一步安装。你的设备可能需要处于开发者模式才能完成此操作:请查阅你的设备文档以获取详细信息。

着色器

着色器是一个设计在 GPU 上运行的程序,它改变了物体在屏幕上显示的方式。着色器在 2D 和 3D 开发中被广泛使用,以创建各种视觉效果。它们被称为着色器,因为它们最初用于着色和光照效果,但如今它们被用于各种视觉效果。因为它们在 GPU 中并行运行,所以它们非常快,但也带来了一些限制。

本节是对着色器概念的简要介绍。要深入了解,请参阅thebookofshaders.com/和 Godot 的着色器文档docs.godotengine.org/en/latest/tutorials/shading/shading_language.html

在 Godot 3.0 中,着色器是用与 GLSL ES 3.0 非常相似的语言编写的。如果你熟悉 C 语言,你会发现语法非常相似。如果你不熟悉,一开始可能会觉得有些奇怪。请参阅本节末尾的链接,以获取更多学习资源。

Godot 中的着色器分为三种类型:空间(用于 3D 渲染)、画布项(用于 2D)和粒子(用于渲染粒子效果)。你的着色器第一行必须声明你正在编写哪种类型。

在确定着色器类型后,你还有三个选择来决定你想要影响的渲染阶段:片段、顶点以及/或光线。片段着色器用于设置每个受影响像素的颜色。顶点着色器用于修改形状或网格的顶点(因此通常在 3D 应用程序中使用得更多)。最后,光线着色器用于改变对象处理光线的方式。

在选择你的着色器类型后,你将编写将在每个受影响的项目上同时运行的代码。这就是着色器的真正威力所在。例如,当使用片段着色器时,代码将在对象的每个像素上同时运行。这与使用传统语言时你可能习惯的过程非常不同,在传统语言中,你会逐个遍历每个像素。这种顺序代码的速度不足以处理现代游戏需要的巨大像素数量。

考虑一个以相对较低的分辨率 480 x 720 运行的游戏。屏幕上的像素总数接近 350,000。在代码中对这些像素的任何操作必须在不到 1/60 秒内完成,以避免延迟——当你考虑到还需要为每一帧运行的其他代码:游戏逻辑、动画、网络和所有其他内容时,这个时间会更短。这就是为什么 GPU 如此重要的原因,尤其是对于可能为每一帧处理数百万像素的高端游戏。

创建着色器

为了演示一些着色器效果,创建一个带有Sprite节点的场景并选择你喜欢的任何纹理。这个演示将使用 Coin Dash 中的仙人掌图像:

着色器可以添加到任何由CanvasItem派生的节点中——在这个例子中,通过其Material属性添加到Sprite中。在这个属性中,选择“新建着色器材料”并点击新创建的资源。第一个属性是“着色器”,在这里你可以选择“新建着色器”。当你这样做时,编辑器窗口底部会出现一个着色器面板。

这是你将编写着色器代码的地方:

一个空的着色器看起来如下所示:

shader_type canvas_item; // choose spatial, canvas_item, or particles

void fragment(){
    // code in this function runs on every pixel
}

void vertex() {
    // code in this function runs on each vertex
}

void light() {
    // code in this function affects light processing
}

在本例中,你将编写一个 2D 片段着色器,因此你不需要包含其他两个函数。

着色器函数包含许多内置函数,这些函数可以是输入值或输出值。例如,TEXTURE输入内置函数包含对象纹理的像素数据,而COLOR输出内置函数用于设置计算结果。记住,片段着色器的作用是影响每个处理像素的颜色。

当在TEXTURE属性中使用着色器时,例如,坐标是在一个归一化(即范围从01)的坐标空间中测量的。这个坐标空间被称为UV,以区别于x/y坐标空间:

图片 2

因此,坐标向量中的所有值都将介于01之间。

作为一个非常小的例子,这个第一个着色器将把仙人掌图像的像素全部变为单色。为了让你选择颜色,你可以使用一个uniform变量。

常量允许你从外部将数据传递到着色器中。声明一个uniform变量将使其在检查器中显示(类似于 GDScript 中的export工作方式)并允许你通过代码设置它。

将以下代码输入到着色器面板中:

shader_type canvas_item;

uniform vec4 fill_color:hint_color;

void fragment(){
    COLOR.rgb = fill_color.rgb;
}

你应该会立即看到图像发生变化:整个图像变成了黑色。要选择不同的颜色,点击检查器中的材质,你会在着色器参数下看到你的uniform变量。

图片 1

然而,你还没有完成。图像现在变成了一个彩色矩形,但你只想改变仙人掌的颜色,而不是其周围的透明像素。在设置COLOR.rgb之后添加一行:

COLOR.a = texture(TEXTURE, UV).a;

这最后一行使着色器输出每个像素,其 alpha(透明度)值与原始纹理中的像素相同。现在仙人掌周围的空白区域保持透明,alpha 值为0

以下代码显示了一个更进一步的例子。在这个着色器中,你通过将每个像素的颜色设置为周围像素的平均值来创建模糊效果:

shader_type canvas_item;

uniform float radius = 10.0;

void fragment(){
    vec4 new_color = texture(TEXTURE, UV);
    vec2 pixel_size = TEXTURE_PIXEL_SIZE; // size of the texture in pixels

    new_color += texture(TEXTURE, UV + vec2(0, -radius) * pixel_size);
    new_color += texture(TEXTURE, UV + vec2(0, radius) * pixel_size);
    new_color += texture(TEXTURE, UV + vec2(-radius, 0) * pixel_size);
    new_color += texture(TEXTURE, UV + vec2(radius, 0) * pixel_size);

    COLOR = new_color / 5.0;
}

注意,由于你把五个颜色值加在一起(原始像素的,加上围绕它移动的四个),你需要除以5.0来得到平均值。你使radius越大,图像看起来就越“模糊”:

图片 3

学习更多

着色器能够实现令人惊叹的范围的效果。在 Godot 的着色器语言中进行实验是学习基础的好方法,但互联网上也有大量资源可以帮助你学习更多。在学习着色器时,你可以使用不特定于 Godot 的资源,并且你不太可能遇到在 Godot 中使用它们的问题。这个概念在所有类型的图形应用程序中都是相同的。

要了解着色器有多强大,请访问www.shadertoy.com/

使用其他语言

本书中的项目都是使用 GDScript 编写的。GDScript 具有许多优势,使其成为构建游戏的最佳选择。它与 Godot 的 API 集成非常紧密,其 Python 风格的语法使其适用于快速开发,同时也非常适合初学者。

然而,这并非唯一的选择。Godot 还支持两种其他“官方”脚本语言,并提供使用各种其他语言集成代码的工具。

C#

在 2018 年初 Godot 3.0 版本发布时,首次添加了对 C# 作为脚本语言的支持。C# 在游戏开发中非常流行,Godot 版本基于 Mono 5.2 .NET 框架。由于其广泛的使用,有许多学习 C# 的资源,以及大量用于实现各种游戏相关功能的现有代码。

在撰写本文时,当前 Godot 版本是 3.0.2。在这个版本中,C# 支持应被视为初步的;它是功能性的,但尚未经过全面测试。一些功能和功能,如导出项目,尚不支持。

如果你想尝试 C# 实现,你首先需要确保已经安装了 Mono,你可以从www.mono-project.com/download/获取。你还必须下载包含 C# 支持的 Godot 版本,你可以在godotengine.org/download找到它,其中标注为“Mono 版本”。

你可能还想使用外部编辑器——例如 Visual Studio Code 或 MonoDevelop——它提供的调试和语言功能比 Godot 内置编辑器更强大。你可以在“编辑器设置”下的“Mono”部分设置此选项。

要将 C# 脚本附加到节点,请从“附加节点脚本”对话框中选择语言:

图片

通常情况下,使用 C# 脚本与 GDScript 的使用方式非常相似。主要区别在于 API 函数的命名方式改为遵循 C# 标准,即使用 PascalCase 而不是 GDScript 的标准 snake_case

下面是一个使用 C# 的 KinematicBody2D 运动的示例:

using Godot;
using System;

public class Movement : KinematicBody2D
{
    [Export] public int speed = 200;

    Vector2 velocity = new Vector2();

    public void GetInput()
    {
        velocity = new Vector2();
        if (Input.IsActionPressed("right"))
        {
            velocity.x += 1;
        }
        if (Input.IsActionPressed("left"))
        {
            velocity.x -= 1;
        }
        if (Input.IsActionPressed("down"))
        {
            velocity.y += 1;
        }
        if (Input.IsActionPressed("up"))
        {
            velocity.y -= 1;
        }
        velocity = velocity.Normalized() * speed;
    }

    public override void _PhysicsProcess(float delta)
    {
        GetInput();
        MoveAndSlide(velocity);
    }
}

有关使用 C# 的更多详细信息,请参阅docs.godotengine.org/en/latest/getting_started/scripting/文档中的脚本部分。

VisualScript

Visual scripting 的目的是提供一种使用拖放视觉隐喻作为替代脚本方法,而不是编写代码。要创建脚本,你需要拖动代表函数和数据的节点(不要与 Godot 的节点混淆),并通过绘制线条将它们连接起来。运行你的脚本意味着沿着节点中的线条路径进行。这种展示方式的目标是为非程序员提供更直观的程序流程可视化方式,例如艺术家或动画师,他们需要在项目上进行协作。

实际上,这个目标还没有以实际的方式实现。Godot 的 VisualScript 也是在 3.0 版本中首次添加的,作为一个功能,它目前还不够成熟,不能在实际项目中使用。就像 C#一样,它应该被考虑在测试中,如果你对此感兴趣,你的测试和反馈将对 Godot 团队改进其功能非常有价值。

VisualScript 的一个潜在优势是将其用作脚本的第二层。你可以在 GDScript 中创建一个对象的核心行为,然后游戏设计师可以使用 VisualScript,在视觉节点中调用这些脚本的功能。

以下截图是一个 VisualScript 项目的示例。在这里,你可以看到 Coin Dash 中玩家移动代码的一部分:

Coin Dash 中的玩家移动代码

本地代码 – GDNative

有许多编程语言可供选择。每种语言都有其优点和缺点,以及一些更喜欢使用它而不是其他选项的粉丝。虽然直接在 Godot 中支持每种语言都没有意义,但在某些情况下,GDScript 可能不再足以解决特定问题。也许你想使用现有的外部库,或者你正在做一些计算密集型的工作——比如 AI 或程序化世界生成——这些对于 GDScript 来说并不合适。

由于 GDScript 是一种解释型语言,它以灵活性为代价换取性能。这意味着对于一些处理密集型的代码,它可能运行得非常慢,无法接受。在这种情况下,通过运行用编译语言编写的本地代码可以获得最高的性能。在这种情况下,你可以将那段代码移动到 GDNative 库中。

GDNative 是一个 C API,外部库可以使用它来与 Godot 接口。这些外部库可以是你的或任何你可能需要的现有第三方库。

在 GDScript 中,你可以使用GDNativeGDNativeLibrary类来加载和调用这些库中的函数。以下代码是调用已保存为GDNativeLibrary资源文件的库的示例:

extends Node

func _ready():
    var lib = GDNative.new()
    lib.library = load("res://somelib.tres")
    lib.initialize()

    // call functions in the library
    var result = lib.call_native("call_type", "some_function", arguments_array)

    lib.terminate()

而这个库可能看起来像这样(用 C 编写):

#include <gdnative.h>

void GDN_EXPORT godot_gdnative_init(godot_gdnative_init_options *p_options) {
    // initialization code
}

void GDN_EXPORT godot_gdnative_terminate(godot_gdnative_terminate_options *p_options) {
    // termination code
}

void GDN_EXPORT godot_nativescript_init(void *p_handle) {

}

godot_variant GDN_EXPORT some_function(void *args) {
    // Do something
}

请记住,编写这样的代码肯定比坚持使用纯 GDScript 要复杂得多。在本地语言中,你需要处理对象的构造函数和析构函数的调用,并手动管理与 Godot 的Variant类的交互。你应该只在性能真正成为问题时才使用 GDNative,即使如此,也只有当功能确实需要使用它时才使用。

如果这个部分对你来说完全不知所云,请不要担心。大多数 Godot 开发者永远不会需要深入研究这一方面的开发。即使你发现自己需要更高性能的代码,你可能只需要查看资产库,以发现有人已经为你创建了一个库。你可以在下一节中了解关于资产库的信息。

语言绑定

GDNative 的另一个好处是它允许其他语言的倡导者创建 API 绑定,以实现这些语言的脚本化。

在撰写本文时,有几个项目可以使用 GDNative,允许您使用其他语言进行脚本编写。这些包括 C、C++、Python、Nim、D、Go 以及其他语言。尽管这些额外的语言绑定在撰写本文时仍然相对较新,但每个语言都有专门的开发团队在致力于它们。如果您对使用特定语言与 Godot 一起使用感兴趣,通过谷歌搜索“godot + <语言名称>”将帮助您找到可用的资源。

资产库

在编辑器窗口的顶部,在“工作区”部分,您会找到一个标有“AssetLib”的按钮:

图片 2

点击此按钮将带您进入 Godot 的资产库。这是一个由 Godot 社区贡献的插件、工具和实用程序的集合,您可能会在项目中找到它们很有用。例如,如果您搜索“状态”,您会看到库中有一个名为有限状态机FSM)的工具。您可以点击其名称获取更多信息,如果您决定尝试它,可以点击“安装”将其下载到res://addons/文件夹中,如果该文件夹不存在,将会创建:

图片 3

然后,您需要通过打开项目设置并选择插件选项卡来启用插件:

图片 1

插件现在可以使用了。请务必阅读插件作者的说明,以了解其工作原理。

为 Godot 做出贡献

Godot 是一个开源、社区驱动的项目。构建、测试、编写文档以及支持 Godot 的其他所有工作主要由充满热情的个人贡献他们的时间和技能来完成。对于大多数贡献者来说,这是一项充满爱心的劳动,他们为帮助构建人们喜欢使用的优质产品而感到自豪。

为了让 Godot 继续成长和改进,社区总是需要更多成员站出来做出贡献。无论您的技能水平如何或您能投入多少时间,都有很多方式可以帮助。

为引擎做出贡献

你可以直接以两种主要方式为 Godot 的开发做出贡献。如果你访问github.com/godotengine/godot,你可以看到 Godot 的源代码,以及了解正在进行的具体工作。点击“克隆”或“下载”按钮,你将获得最新的源代码,并可以测试最新的功能。你需要构建引擎,但不要感到害怕:Godot 是你能找到的最容易编译的开源项目之一。有关说明,请参阅docs.godotengine.org/en/latest/development/compiling/

如果你无法实际贡献 C++代码,请转到“问题”标签页,在那里你可以报告或阅读有关错误和改进建议的信息。总是需要有人确认错误报告、测试修复并提供对新功能的意见。

编写文档

Godot 的官方文档的质量取决于其社区的贡献。从小到纠正一个错别字,大到编写整个教程,所有级别的帮助都非常受欢迎。官方文档的家园是github.com/godotengine/godot-docs

希望到现在为止,你已经花了一些时间浏览官方文档,并对可用的内容有所了解。如果你发现有什么错误或遗漏,请在上述 GitHub 链接处提交一个 issue。如果你熟悉使用 GitHub,甚至可以直接提交一个 pull request。但请确保首先阅读所有指南,以确保你的贡献会被接受。指南可以在docs.godotengine.org/en/latest/community/contributing/找到。

如果你说的不是英语,翻译也非常需要,并且会受到 Godot 的非英语用户的高度赞赏。有关如何在你的语言中做出贡献的信息,请参阅hosted.weblate.org/projects/godot-engine/godot-docs/

捐赠

Godot 是一个非营利项目,用户的捐赠在很大程度上有助于支付托管费用和开发资源,例如硬件。财务捐助还允许项目支付核心开发者的工资,使他们能够全职或部分时间致力于引擎的开发工作。

向 Godot 贡献的最简单方式是通过 Patreon 页面,网址为www.patreon.com/godotengine

获取帮助 - 社区资源

Godot 的在线社区是其优势之一。由于其开源性质,有各种各样的人一起工作,以改进引擎、编写文档并互相帮助解决问题。

你可以在godotengine.org/community找到社区资源的完整列表。

这些链接可能会随时间变化,但以下是你应该了解的主要社区资源。

GitHub

github.com/godotengine/

Godot 的 GitHub 仓库是开发者工作的地方。如果你需要为个人使用编译引擎的定制版本,你可以在这里找到 Godot 的源代码。

如果你发现引擎本身有任何问题——比如某些功能不工作、文档中的错别字等——这就是你应该报告的地方。

Godot 问答

godotengine.org/qa/

这是 Godot 的官方帮助网站。你可以在这里发布问题供社区回答,以及搜索不断增长的先前回答的问题数据库。如果你恰好看到你知道答案的问题,你也可以提供帮助。

Discord / 论坛

discord.gg/zH7NUgz

godotdevelopers.org/

虽然不是官方的,但这些是两个非常活跃的 Godot 用户社区,你可以在这里寻求帮助,找到问题的答案,并与他人讨论你的项目。

摘要

在本章中,你了解了一些额外的主题,这些主题将帮助你继续提升你的 Godot 技能。除了本书中探索的功能外,Godot 还拥有许多其他功能。当你开始着手自己的项目时,你需要知道去哪里寻找信息,以及去哪里寻求帮助。

你还了解了一些更高级的主题,例如与其他编程语言一起工作以及使用着色器来增强你的游戏视觉效果。

此外,由于 Godot 是由其社区构建的,你学习了如何参与其中,并成为使其成为其类型中增长最快的项目之一的团队的一部分。

posted @ 2025-10-07 17:59  绝不原创的飞龙  阅读(86)  评论(0)    收藏  举报