Godot-引擎游戏开发项目-全-

Godot 引擎游戏开发项目(全)

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

译者:飞龙

协议: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. 选择 SUPPORT 标签。

  3. 点击代码下载和勘误表。

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

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

书籍的代码包也托管在 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 应用程序。如果有的话,您还可以将其拖到您的 ProgramsApplications 文件夹中。双击应用程序以启动它,您将看到 Godot 的项目管理器窗口。

替代安装方法

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

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

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

  • Homebrew (macOS)

  • Scoop (Windows)

  • Snap (Linux)

Godot UI 概览

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

项目管理器

当您打开 Godot 时,您首先会看到的是项目管理器窗口:

在此窗口中,您可以查看您现有的 Godot 项目列表。您可以选择一个现有项目,点击 Run 来玩游戏或点击 Edit 在 Godot 编辑器中工作(参看以下截图)。您还可以通过点击 New Project 创建一个新项目:

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

注意警告信息——在 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 并创建一个新的项目,确保使用 创建文件夹 按钮来确保此项目的文件将与其他项目分开。你可以在此处下载游戏的艺术和声音(统称为 assets)的 Zip 文件,github.com/PacktPublishing/Godot-Game-Engine-Projects/releases

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

在这个项目中,你将制作三个独立的场景:PlayerCoinHUD,它们将被组合到游戏的 Main 场景中。在一个更大的项目中,创建单独的文件夹来存放每个场景的资源和脚本可能是有用的,但在这个相对较小的游戏中,你可以在根文件夹中保存你的场景和脚本,该文件夹被称为 res://resresource 的缩写)。你项目中的所有资源都将位于 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 轴朝下:

图片

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

向量

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

图片

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

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

像素渲染

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

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

图片

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

第一部分 – 玩家场景

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

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

创建场景

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

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

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

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

精灵动画

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

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

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

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

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

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

碰撞形状

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

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

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

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

你可能已经注意到碰撞形状没有在精灵上居中。这是因为精灵本身在垂直方向上没有居中。我们可以通过向AnimatedSprite添加一个小偏移量来修复这个问题。点击节点,然后在检查器中查找偏移属性。将其设置为(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 节点并将速度属性设置为 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() 中调用此函数,然后通过结果速度更改玩家的 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像素/秒)乘以delta,你将得到正好10像素的移动。然而,如果delta增加到0.3,则对象将被移动18像素。总的来说,移动速度保持一致,且与帧率无关。

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

选择动画

现在玩家可以移动了,你需要根据玩家是移动还是静止来改变AnimatedSprite正在播放的动画。run动画的美术面向右侧,这意味着在向左移动时应该水平翻转(使用翻转 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中设置“播放”为“开启”,以便动画能够播放。

开始和结束玩家的移动

游戏开始时,主场景需要通知玩家游戏已经开始。如下添加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 的 信号 功能来实现这一点。信号是节点发送消息的方式,其他节点可以检测并做出反应。许多节点都有内置的信号,例如在身体碰撞时发出警报,或者在按钮被按下时。您还可以定义用于您自己目的的自定义信号。

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

将以下内容添加到脚本顶部(在 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()

第二部分 – 硬币场景

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

节点设置

点击“场景”|“新建场景”并添加以下节点。不要忘记将子节点设置为未选中,就像您在 Player 场景中所做的那样:

  • Area2D (命名为 Coin)

  • AnimatedSprite

  • CollisionShape2D

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

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

使用组

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

图片

脚本

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

extends Area2D

func pickup():
    queue_free()

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

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

第三部分 – 主场景

Main 场景是连接游戏所有部件的纽带。它将管理玩家、硬币、计时器和游戏的其他部分。

节点设置

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

图片

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

  • TextureRect(命名为 Background)——用于背景图像

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

  • Position2D(命名为 PlayerStart)——用于标记 Player 的起始位置

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

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

PlayerStart 节点的位置设置为 (240, 350)。

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

主脚本

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

extends Node

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

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

当你点击 Main 时,CoinPlaytime 属性将现在出现在检查器中。从文件系统面板拖动 Coin.tscnCoin 属性中。将 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场景都将启动。

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

检查剩余硬币

主脚本需要检测玩家是否已经捡起所有硬币。由于所有硬币都是CoinCointainer的子节点,你可以使用此节点上的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 编辑器就是使用这些元素构建的。UI 元素的基本节点是从Control扩展的,并在节点列表中以绿色图标显示。要创建你的 UI,你将使用各种Control节点来定位、格式化和显示信息。以下是完成后的HUD的外观:

锚点和边距

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

消息标签

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

选择视图 | 显示辅助工具以显示将帮助您看到锚点位置的销钉,然后点击布局菜单并选择 HCenter Wide:

图片

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

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

图片

现在,点击 DynamicFont 并可以调整字体设置。从 FileSystem 面板,拖动 Kenney Bold.ttf 字体并将其放入 Font Dataproperty 中。将 Size 设置为 48,如图下所示:

图片

分数和时间显示

HUD 的顶部将显示玩家的分数和时钟剩余时间。这两个都将使用 Label 节点,并安排在游戏屏幕的相对两侧。您不会单独定位它们,而是会使用 Container 节点来管理它们的位置。

容器

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

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

要管理分数和时间标签,向 HUD 中添加一个 MarginContainer 节点。使用布局菜单设置锚点为 Top Wide。在 Custom Constants 部分,将 Margin Right、Margin Top 和 Margin Left 设置为 10。这将添加一些填充,以便文本不会紧贴屏幕边缘。

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

通过 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()

这个函数会暂停游戏,并遍历硬币,移除任何剩余的硬币,同时调用HUDshow_game_over()函数。

最后,StartButton需要激活new_game()函数。点击HUD实例并选择其new_game()信号。在信号连接对话框中,点击将函数设置为关闭,并在方法节点字段中输入new_game。这将连接信号到现有函数而不是创建一个新的函数。查看以下截图:

_ready() 函数中删除 new_game() 并将这两行添加到 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 确保在动画过程中玩家触摸硬币时不会发出 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 中找到)拖动到每个节点的相应流属性中。

要播放声音,你可以在其上调用 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 中,将硬币的图像更改为提升物品,你可以在 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

Coin 场景中添加一个 Timer 节点,并将以下代码添加到 _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 引擎的基础知识。你设置了项目并创建了多个场景,与精灵和动画工作,捕捉用户输入,使用 信号 与事件通信,并使用 Control 节点创建用户界面。在这里学到的技能是你在任何 Godot 项目中都会用到的关键技能。

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

此外,请随意尝试游戏并改变一些东西。了解游戏不同部分如何工作的最佳方法之一就是改变它们并观察会发生什么。

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

第三章:逃离迷宫

在上一章中,你学习了 Godot 的节点系统是如何工作的,这让你能够用更小的构建块构建复杂的场景,每个构建块都为你的游戏对象提供不同的功能。当你开始构建更大、更复杂的项目时,这个过程将继续。然而,有时你会在多个不同的对象中重复相同的节点和/或代码,这个项目将介绍一些减少重复代码数量的技术。

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

图片

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

  • 继承

  • 基于网格的移动

  • 精灵动画

  • 使用 TileMaps 进行关卡设计

  • 场景之间的转换

项目设置

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

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

点击“输入映射”选项卡,通过在“动作:”框中输入名称并点击“添加”来添加四个新的输入动作(左、右、上、下)。然后,对于每个新动作,点击+按钮添加一个键动作并选择相应的箭头键。你也可以添加 WASD 控制,如果需要的话:

图片

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

点击“常规”选项卡,找到“层名称/2D 物理”部分。将前四个层命名为以下内容:

图片

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

接下来,在“显示/窗口”部分,将“模式”设置为视口,将“纵横比”设置为保持。这将使你能够在保持显示比例不变的情况下调整游戏窗口的大小。参考以下截图:

图片

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

项目组织

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

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

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

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

继承

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

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

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

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

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

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

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

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

角色场景

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

添加以下子节点:

  • Sprite

  • CollisionShape2D

  • Tween(命名为MoveTween)

  • AnimationPlayer

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

你将用于玩家和敌人的 spritesheets 正好按照这种模式排列,每一行包含一个移动方向的动画帧:

截图

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

截图

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

因为所有角色都是绘制到相同的比例,我们可以确信相同的碰撞形状将适用于所有角色。如果你的艺术作品中不是这样,你可以跳过在这里设置碰撞形状,稍后为继承的各个场景进行配置。

动画

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

截图

对于每个动画,将Length设置为1Step设置为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:

角色脚本

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的子节点添加,并检查其当前属性是否为开启。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()

瓦片集

为了使用 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。要选择单个瓷砖,将区域/启用属性设置为开启,并点击编辑器窗口底部的纹理区域以打开面板。将吸附模式设置为网格吸附,并将步长设置为 64px,在 xy 方向上都一样。现在,当你点击并拖动纹理时,它只会允许你选择 64 x 64 的纹理区域:

图片

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

点击使用吸附按钮(看起来像磁铁)然后通过点击旁边的三个点打开吸附菜单:

图片

选择配置吸附...并将网格步长设置为 64 by 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,这意味着一个空瓷砖)。

添加更多级别

如果您想创建另一个级别,只需复制这个场景树并将其相同的脚本附加到它上。这样做最简单的方法是使用Scene | Save As将级别保存为Level2.tscn。然后,您可以使用一些现有的瓷砖或绘制整个新的级别布局。

随意地创建任意多级,确保将它们全部保存在levels文件夹中。在下一节中,您将看到如何将它们链接起来,以便每个级别都将引导到下一个级别。如果编号错误,不用担心;您可以将它们按任何顺序排列。

游戏流程

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

  • 开始和游戏结束屏幕

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

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

图片

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

开始和结束屏幕

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

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

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

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

Timer将在时间到后把游戏送回StartScreen

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

全局变量

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

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

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

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

全局脚本

首先,在脚本窗口中点击“文件 | 新建”来创建一个新的脚本。确保它从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布局设置为 Top Wide,并将其四个边距属性(在自定义常量下找到)都设置为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)

运行游戏并检查你的高分是否在击败它时增加,并在退出并再次开始游戏时保持持久。

最后的修饰

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

死亡动画

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

首先,选择PlayerAnimationPlayer节点并点击新建动画按钮:。将新动画命名为die

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

请记住,虽然度数通常用于检查器属性,但在编写代码时,大多数 Godot 函数期望角度以弧度为单位进行测量。

现在,对Scale属性做同样的事情。在开始时添加一个关键帧(为(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。你可以保留其他设置不变。

玩家飞船

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

身体设置和物理

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

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

CollisionShape2D 的形状属性中添加一个 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)

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

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

引用 RigidBody2D 文档:

"你不应该在每一帧或甚至非常频繁地更改 RigidBody2D 的位置或线性速度。如果你需要直接影响物体的状态,请使用 _integrate_forces,它允许你直接访问物理状态。"

并且在 _integrate_forces() 的描述中:

"(它)允许你读取并安全地修改对象的模拟状态。如果你需要直接改变物体的位置或其他物理属性,请使用此方法代替 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 作为 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信号时,你传递子弹本身及其起始位置和方向。然后,通过can_shoot标志禁用射击并启动GunTimer。为了允许枪再次射击,连接GunTimertimeout信号:

func _on_GunTimer_timeout():
    can_shoot = true

现在,创建你的Main场景。添加一个名为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 脚本将处理生成新的岩石,包括在关卡开始时以及在大岩石爆炸后出现的较小岩石。一个大的岩石将有一个 size 值为 3,并分裂成大小为 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生成新的、更小的岩石

爆炸场景

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

  • 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 节点可见。右键单击实例化的爆炸,选择可编辑子项,然后选择 AnimationPlayer 并连接其 animation_finished 信号。确保在连接到节点部分选择 Rock。动画的结束意味着可以安全地删除岩石:

func _on_AnimationPlayer_animation_finished( name ):
    queue_free()

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

图片

生成较小的岩石

Rock 正在发出信号,但需要连接到 Main。你不能使用节点标签页来连接它,因为 Rock 实例是在代码中创建的。信号也可以在代码中连接。将以下行添加到 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 提供了一系列控制节点来协助这个过程。学习如何使用各种控制节点将有助于减轻创建游戏 UI 的痛苦。

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

  • 开始按钮

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

  • 得分

  • 生命值计数器

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

图片

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

布局

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节点,然后按两次 Duplicate (Ctrl + D)来创建L2L3(它们将被自动命名)。在游戏过程中,HUD将显示/隐藏这三个纹理,以指示用户剩余的生命值。

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

作为HUD节点的子节点,添加一个TextureButton(命名为StartButton),一个Label(命名为MessageLabel),和一个Timer(命名为MessageTimer)。

res://assets文件夹中,有两个StartButton的纹理,一个是正常状态(play_button.png),另一个是鼠标悬停时显示的(play_button_h.png)。将它们分别拖到Textures/NormalTextures/Hover属性,然后在布局菜单中选择居中。

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

最后,将MessageTimer的“一次性”属性设置为开启,并将等待时间设置为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

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

接下来,添加一个处理Game Over状态的函数:

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”是关闭的,并点击连接,如图所示:

游戏结束

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

Player中添加一个Explosion实例,以及一个名为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)

现在,转到Main场景,并将玩家的dead信号连接到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作为其纹理。将 Animation/HFrames 设置为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()

开始游戏,你应该会看到一个飞碟出现,它将沿着你的其中一条路径飞行。

敌人射击和碰撞

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

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

图片

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

图片

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()

目前,子弹对玩家不会造成任何伤害。你将在下一节中为玩家添加护盾,因此你可以同时添加它。

保存场景并将其拖动到Enemy上的子弹属性。

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 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的情况下,你不想让声音循环,所以需要更改这些文件的导入设置。为此,在 FileSystem 窗口中选择文件,然后点击位于编辑器窗口右侧场景标签旁边的导入标签。取消循环旁边的框,然后点击重新导入。为这三个声音都这样做。参考以下截图:

要播放声音,需要通过AudioStreamPlayer节点加载。将两个这样的节点添加到Player场景中,分别命名为LaserSoundEngineSound。将相应的声音拖入每个节点的 Inspector 中的 Stream 属性。要在射击时播放声音,将以下行添加到Player.gd中的shoot()

$LaserSound.play()

玩游戏并尝试射击。如果你觉得声音有点响,你可以调整音量 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拖入。

$ExplodeSound.play()添加到_on_Rock_exploded()的第一行,并将$LevelupSound.play()添加到new_level()中。

要开始/停止音乐,将$Music.play()添加到new_game(),并将$Music.stop()添加到game_over()中。

敌人也需要一个ExplodeSound和一个ShootSound。你可以使用与玩家相同的爆炸声,但有一个enemy_laser.wav声音用于射击。

粒子

玩家飞船的推力是使用粒子的完美用途,从引擎中创建一条流动的火焰。将一个Particles2D节点添加到Player场景中,并命名为Exhaust。你可能想在执行这部分时放大飞船图像。

当首次创建时,Particles2D节点有一个警告:未分配处理粒子的材质。粒子将不会发射,直到你在检查器中分配一个Process Material。有两种类型的材质:ShaderMaterialParticlesMaterialShaderMaterial允许你使用类似 GLSL 的语言编写着色器代码,而ParticlesMaterial在检查器中配置。在Particles Material旁边点击向下箭头并选择新建ParticlesMaterial

你会看到一串白色圆点从玩家飞船的中心向下流动。你现在的挑战是将这些变成尾气火焰。

在配置粒子时,有非常多的属性可供选择,尤其是在ParticlesMaterial下。在开始之前,设置Particles2D的这些属性:

  • 数量:25

  • 变换/位置:(-28, 0)

  • 变换/旋转:180

  • 可见性/显示在父项之后:开启

现在,点击ParticlesMaterial。这里你可以找到影响粒子行为的大多数属性。从发射形状开始——将其更改为 Box。这将揭示 Box Extents,应设置为(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

注意,你使用的效果纹理在黑色背景上是白色的。这张图片需要更改其混合模式。为此,在粒子节点上,找到 Material 属性(它在 CanvasItem 部分)。选择 New 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纹理拖入TextureRect纹理属性。这将是一个表示条显示内容的图标。

ShieldBar有三个纹理属性:下、上和进度。进度是作为条值显示的纹理。将res://assets/barHorizontal_green_mid 200.png拖入此属性。其他两个纹理属性允许你通过设置图像来自定义外观,该图像将被绘制在进度纹理下方或上方。将res://assets/glassPanel_200.png拖入纹理属性。

范围部分,你可以设置条的数值属性。最小值和最大值应设置为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。在这个阶段,这些节点配置应该开始对你来说变得熟悉。

在继续之前,再次查看项目。运行它。确保你理解每个场景在做什么,并阅读脚本以回顾一切是如何连接在一起的。

在下一章中,你将学习关于运动学体,并使用它们来创建一个侧滚动平台游戏。

第五章:丛林跳跃(平台游戏)

在本章中,您将构建一个经典的平台风格游戏,遵循超级马里奥兄弟的传统。平台游戏是一个非常受欢迎的游戏类型,了解它们的工作原理可以帮助您制作各种不同的游戏风格。平台游戏中的物理可能具有欺骗性的复杂性,您将看到 Godot 的KinematicBody2D物理节点具有帮助您实现所需的角色控制器功能的特性。请查看以下截图:

图片

在这个项目中,您将了解:

  • 使用KinematicBody2D物理节点

  • 将动画和用户输入结合以产生复杂的角色行为

  • 使用 ParallaxLayers 创建无限滚动的背景

  • 组织您的项目并规划扩展

项目设置

创建一个新的项目。在您从以下链接下载资源之前,您需要为游戏艺术准备导入设置。本项目使用的艺术资源采用像素艺术风格,这意味着它们在没有过滤时看起来最好,这是 Godot 为纹理的默认设置。“过滤”是一种通过平滑图像像素的方法。它可以改善某些艺术作品的外观,但不能改善基于像素的图像:

图片

每次都要为每张图片禁用这个功能是不方便的,因此 Godot 允许您自定义默认的导入设置。在 FileSystem 窗口中点击icon.png文件,然后点击右侧场景标签旁边的导入标签。此窗口允许您更改所选文件的导入设置。取消选择“过滤器”属性,然后点击预设并选择将“纹理”设置为默认。这样,所有图像都将导入时禁用过滤器。请参考以下截图:

图片

如果您已经导入图像,它们的导入设置不会自动更新。更改默认设置后,您必须重新导入任何现有图像。您可以在 FileSystem 窗口中选择多个文件,然后点击重新导入按钮,一次性应用设置到多个文件。

现在,您可以从以下链接下载游戏资源,并将其解压缩到您的项目文件夹中。Godot 将使用新的默认设置导入所有图像,github.com/PacktPublishing/Godot-Game-Engine-Projects/releases

接下来,打开“项目”|“项目设置”,在渲染/质量下,将“使用像素捕捉”设置为“开启”。这将确保所有图像都将正确对齐——这在您设计游戏关卡时将非常重要。

当你打开设置窗口时,转到显示/窗口部分,将拉伸/模式改为 2d 并将纵横比改为 expand。这些设置将允许用户在保持图像质量的同时调整游戏窗口的大小。一旦项目完成,你将能够看到此设置的效果。

接下来,设置碰撞层名称,以便更方便地设置不同类型对象之间的碰撞。转到层名称/2d 物理,并将前四个层命名为如下:

最后,在项目 | 项目设置下的输入映射选项卡中添加以下动作以供玩家控制:

动作名称 按键
向右 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):

图片

Sprite

将 Sprite 节点添加到 Player 中。从 FileSystem 选项卡拖动 res://assets/player_sheet.png 文件并将其放入 Sprite 的 Texture 属性中。玩家动画以精灵图集的形式保存:

图片

你将使用 AnimationPlayer 来处理动画,因此在 Sprite 的动画属性中,将 Vframes 设置为 1,将 Hframes 设置为 19。将 Frame 设置为 7 以开始,因为这个帧显示了角色静止不动(它是 idle 动画的第一个帧):

图片

碰撞形状

与其他物理体一样,KinematicBody2D 需要一个形状来定义其碰撞边界。添加一个 CollisionShape2D 对象,并在其中创建一个新的 RectangleShape2D 对象。在调整矩形大小时,您希望它达到图像的底部,但不要那么宽。一般来说,使碰撞形状比图像略小,在播放时会有更好的 感觉,避免击中看起来不会导致碰撞的东西的体验。

您还需要稍微偏移形状以使其适合。将位置设置为 (0, 5) 效果很好。完成时,它应该看起来大约是这样的:

图片

形状

一些开发者更喜欢胶囊形状而不是矩形形状用于横版滚动角色。胶囊是一种两端圆润的药片形状碰撞体:

图片

然而,尽管这个形状可能看起来能更好地 覆盖 精灵,但在实现平台式移动时可能会带来困难。例如,当站在平台边缘太近时,由于底部的圆润,角色可能会滑落,这对玩家来说可能非常令人沮丧。

在某些情况下,根据角色的复杂性和与其他对象的交互,您可能希望向同一对象添加多个形状。您可能有一个在角色脚下的形状来检测地面碰撞,另一个在其身体上检测伤害(有时称为受伤框),还有一个覆盖玩家前方的形状来检测与墙壁的接触。

建议您坚持使用如图所示的前一个屏幕截图中的 RectangleShape2D,对于此角色。然而,一旦您完成项目,您应该尝试将玩家的碰撞形状更改为 CapsuleShape2D 并观察产生的行为。如果您更喜欢它,请随意使用它。

动画

AnimationPlayer 节点添加到 Player 场景中。您将使用此节点来更改 Sprite 上的帧属性以显示角色的动画。首先创建一个新的动画名为 idle

图片

Length 设置为 0.4 秒,并保持 Step0.1 秒。将 Sprite 的帧更改为 7,然后点击帧属性旁边的添加关键帧按钮以创建一个新的动画轨道,然后再次点击它,注意它会自动增加帧属性:

图片

持续按住它,直到您有帧 710。最后,点击启用/禁用循环按钮以启用循环,然后按播放查看您的动画。您的动画设置应该看起来像这样:

图片

现在您需要为其他动画重复此过程。以下表格列出了设置列表:

名称 长度 帧数 循环
空闲 0.4 7, 8, 9 ,10 开启
run 0.5 13, 14, 15, 16, 17, 18 开启
hurt 0.2 5, 6 开启
jump_up 0.1 11 关闭
jump_down 0.1 12 关闭

完成场景树

Camera2D 添加到 Player 场景中。这个节点会在玩家在关卡中移动时保持游戏窗口在玩家中心。你也可以用它来放大玩家,因为像素艺术相对较小。记住,由于你在导入设置中关闭了过滤,当放大时,玩家的纹理将保持像素化和块状。

要启用相机,点击当前属性设置为 开启,然后将缩放属性设置为 (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)

玩家受伤有两种可能的方式:在环境中撞到 spike 对象,或者被敌人击中。在任何一种情况下,都可以调用以下函数:

func hurt():
    if state != HURT:
        change_state(HURT)

这是对玩家友好:如果他们已经受伤,他们就不能再次受伤(至少在 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 精灵图集拖放到 Sprite 的纹理中。将 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字典中,你有一个物品类型及其对应纹理位置的列表。注意,你可以通过在文件系统窗口中右键单击文件并选择复制路径来快速粘贴这些文件路径:

图片

接下来,你将有一个init()函数,该函数将textureposition设置为给定的值。关卡脚本将使用此函数来生成你添加到关卡地图中的所有可收集物品。

最后,你需要对象检测它何时被拾起。点击Area2D并连接其body_entered信号。将以下代码添加到创建的函数中:

func _on_Collectible_body_entered(body):
    emit_signal('pickup')
    queue_free()

发出信号将允许游戏脚本适当地对物品拾取做出反应。它可以增加分数、提高玩家的速度,或者产生你希望物品产生的任何其他效果。

设计关卡

如果没有跳跃,那就不是平台游戏。对于大多数读者来说,这部分将占用最多的时间。一旦你开始设计关卡,你会发现布置所有部件、创建挑战性跳跃、秘密路径和危险遭遇非常有趣。

首先,你将创建一个通用的Level场景,其中包含所有级别共有的节点和代码。然后你可以创建任意数量的级别场景,这些场景将继承自这个主级别。

瓦片集配置

在项目开始时下载的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 并将其 Cell/Size 设置为 (16, 16),然后将其复制三次(按 Ctrl + D 来复制一个节点)。这些将成为你的关卡层,持有不同的瓦片和布局信息。以下是对四个 TileMap 实例的命名和将相应的 TileSet 拖放到每个的 Tile Set 属性中的说明。参考以下表格:

TileMap Tile Set
World tiles_world.tres
Objects tiles_items.tres
拾取物 tiles_items.tres
Danger tiles_spikes.tres

在你工作在地图上时,按下你的 TileMap 节点的锁定按钮是个好主意,以防止意外移动它们。

接下来,添加一个名为 PlayerPlayer 场景和一个名为 PlayerSpawnPosition2D。点击 Player 上的隐藏按钮——你将在关卡脚本中使用 show() 来使玩家在开始时出现。你的场景树现在应该看起来像这样:

将脚本附加到 Level 节点上:

extends Node2D

onready var pickups = $Pickups

func _ready():
    pickups.hide()
    $Player.start($PlayerSpawn.position)

之后,你将扫描 Pickups 地图以在指定位置生成可收集物品。这个地图层本身不应该被看到,但与其在场景树中将它设置为隐藏,这很容易在运行游戏前忘记,你可以在 _ready() 中确保它在游戏过程中始终隐藏。这样做可以缓存结果,因为会有许多对节点的引用。(记住,$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 将每个瓦片的值设置为 id,该 id 引用 TileSet 中的单个瓦片对象。然后,您可以使用 tile_set.tile_get_name() 查询 TileSet 以获取瓦片的名称。

_ready() 中添加 spawn_pickups() 并在脚本顶部添加以下内容:

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 图像拖动到纹理中。重要提示——取消选中 Sprite 的 Centered 属性旁边的框。

背景图像有点小,因此将 Sprite 的缩放设置为 (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 的On选项。按重新导入。现在,纹理可以被重复以填充屏幕(并且透视系统将在之后重复它):

图片的原始大小是176x368,需要水平重复。在Sprite属性中,点击启用区域。接下来,将矩形属性设置为(0, 0, 880, 368)(880 是 176 的 5 倍,所以你现在应该看到五次图片的重复)。移动ParallaxLayer,使图像重叠在海洋/云图像的下半部分:

ParallaxLayer的移动/缩放设置为(0.6, 1),镜像设置为(880, 0)。使用更高的缩放因子意味着这个层将比其后的云层滚动得更快,从而产生令人满意的深度效果,如以下截图所示:

一旦你确定一切正常工作,尝试调整两个层的缩放值,看看它如何变化。例如,尝试在中间层使用(1.2, 1)的值,以获得不同的视觉效果。

你的主场景树现在应该看起来像这样:

危险物体

危险地图层旨在存放那些如果被触碰会对玩家造成伤害的尖刺物体。尝试在你的地图上放置几个这样的物体,以便你可以轻松测试碰撞。请注意,由于 TileMaps 的工作方式,与该层上的任何任何瓷砖发生碰撞都会对玩家造成伤害!

关于滑动碰撞

当使用move_and_slide()移动KinematicBody2D时,它可能在给定帧中与多个对象发生碰撞。例如,当撞到角落时,角色可能会同时撞到墙和地板。你可以使用get_slide_count()方法来找出发生了多少次碰撞,然后使用get_slide_collision()获取每次碰撞的信息。

对于 Player,你想要检测当与 Danger 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 的新文件夹中。如果你决定为游戏添加更多敌人类型,你都可以在这里保存。

将身体的碰撞层设置为 enemies,其碰撞掩码设置为 environmentplayerenemies。将敌人分组也很有用,因此点击节点标签,并将身体添加到名为 enemies 的组中。

res://assets/opossum.png 精灵图集添加到精灵的纹理中。将 Vframes 设置为 1,Hframes 设置为 6。添加一个矩形碰撞形状,覆盖图像的大部分(但不是全部),确保碰撞形状的底部与图像脚部的底部对齐:

图片

AnimationPlayer 添加一个新的动画,名为 walk。将长度设置为 0.6 秒,步长设置为 0.1 秒。开启循环和自动播放。

walk 动画将有两个轨道:一个设置纹理属性,一个改变帧属性。点击 Texture 旁边的添加关键帧按钮一次以添加第一个轨道,然后点击 Frame 旁边的按钮并重复,直到你有帧 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图像拖到 Sprite 的纹理中。和之前一样,将所有从05Frame值都设置为关键帧。按播放键查看死亡动画的运行。

将以下代码添加到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文件夹中。将布局设置为顶部宽。在检查器的Custom Constants部分,设置以下值:

  • 右侧边距:50

  • 顶部边距:20

  • 左侧边距:50

  • 底部边距:20

添加一个HBoxContainer。此节点将包含所有 UI 元素并保持它们对齐。它将有两个子节点:

  • 标签: 得分标签

  • HBoxContainer: 生命计数器

ScoreLabel上,将文本属性设置为1,在大小标志下,将水平设置为填充和扩展。从assets文件夹中添加一个自定义的DynamicFont,使用res://assets/Kenney Thick.ttf,字体大小为48。在自定义颜色部分,将字体颜色设置为白色,字体颜色阴影设置为黑色。最后,在自定义常量下,将阴影偏移 X、阴影偏移 Y 和阴影轮廓都设置为5。你应该看到一个带有黑色轮廓的大白色 1。

对于LifeCounter,添加一个TextureRect并将其命名为L1。将res://assets/heart.png拖入其纹理中,并将拉伸模式设置为保持纵横比居中。点击节点并按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()中,当玩家受伤或治愈时将被调用,你通过将心形数量设置为小于生命值的false来计算要显示的心形数量。

_on_score_changed()类似,在调用时更改ScoreLabel的值。

添加 HUD

打开Level.tscn(基础关卡场景,不是你的Level01场景)并添加一个CanvasLayer节点。将HUD场景作为此CanvasLayer的子节点实例化。

点击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 和按空格键开始游戏。完成时,屏幕应如下所示:

为了使标题屏幕更有趣,添加一个AnimationPlayer节点并创建一个新的动画。将其命名为anim并设置为自动播放。在这个动画中,你可以动画化屏幕的各种组件,使它们移动、出现、淡入或任何你喜欢的效果。

将标题标签拖动到屏幕顶部的上方并添加一个关键帧。然后,将其拖回(或手动输入位置值)并在大约0.5秒处设置另一个关键帧。你可以自由地添加动画其他节点属性的轨迹。

例如,这里有一个动画,将标题向下移动,淡入两个纹理,然后使消息出现(注意每个轨迹修改的属性名称):

主场景

删除你添加到临时Main.tscnPlayer实例和测试StaticBody2D)中的额外节点。现在,这个场景将负责加载当前级别。然而,在它能够这样做之前,你需要一个自动加载脚本来跟踪游戏状态:例如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等等),然后你可以自动加载序列中的下一个。

在项目设置的自动加载选项卡中添加此脚本,并将此脚本添加到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精灵图集中,有一个你可以用于你级别出口的门图像。找到并走进门会导致玩家移动到下一级。

门场景

items文件夹中创建一个新的场景,命名为Door,并保存。添加一个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 设置为关闭,而音乐文件的 Loop 设置为开启。

  • 你可能发现调整单个声音的音量很有帮助。这可以通过 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属性中,选择一个新的Gradient

梯度有两种颜色:左侧的起始颜色和右侧的结束颜色。这些颜色由梯度两端的矩形选择。点击右侧的方块可以设置颜色:

将起始颜色设置为浅棕色,并将结束颜色设置为相同的颜色,但将 alpha 值设置为0(透明)。你可以通过检查检查器中的“发射”框来测试其外观。因为节点设置为单次发射,所以只有一个粒子团,你必须再次检查框来发射它们。

随意调整此处列出的属性。实验Particles2D设置可以非常有趣,而且你可能会通过调整得到一个非常棒的效果。一旦你对外观满意,将以下内容添加到玩家的_physics_process()代码中:

if state == JUMP and is_on_floor():
    change_state(IDLE)
    $Dust.emitting = true # add this line

运行游戏,每次你的角色落地时,都会出现一小股尘埃。

蹲伏状态

蹲伏状态在玩家需要通过蹲下躲避敌人或投射物时很有用。精灵图包含该状态的二帧动画:

将一个新的动画命名为 crouch 并添加到玩家的AnimationPlayer中。将其长度设置为0.2并为Frame属性添加一个轨道,将值从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

现在,玩家已经准备好了,你可以在关卡地图中添加一些梯子。

关卡代码

在你的地图上某个地方放置一些梯子瓦片,然后向关卡场景添加一个 Ladder Area2D。给这个节点一个具有矩形形状的 CollisionShape2D。最佳的方式是使用网格吸附。通过菜单打开它,并使用 Configure Snap... 将网格步长设置为 (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。打开网格吸附,以便精灵可以按行对齐:

图片

(8, 8) 的网格设置对于对齐瓦片非常有效。添加一个覆盖图像的矩形 CollisionShape2D

图片

平台运动可以变得非常复杂(跟随路径、改变速度等),但这个例子将坚持使用在两个对象之间来回移动的水平平台。

这是平台的脚本:

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 迷你高尔夫

这本书中之前的 projekty 都是设计在 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 空间中工作时,像素并不太有用。两个大小完全相同的对象,根据它们与相机的距离不同,将在屏幕上占据不同的区域(关于相机的更多信息即将揭晓)。因此,在 Godot 的 3D 空间中,所有对象都使用通用单位进行度量。您可以自由地称这些单位为米、英寸,甚至光年,具体取决于您游戏世界的规模。

Godot 的 3D 编辑器

在开始使用 3D 之前,简要回顾一下如何在 Godot 的 3D 空间中导航将很有用。相机由鼠标和键盘控制:

  • 鼠标滚轮上下:缩放进/出

  • 中间按钮 + 拖动:围绕当前目标旋转相机

  • Shift + 中间按钮 + 拖动:上下/左右平移相机

  • 右键点击 + 拖动:在当前位置旋转相机

如果您熟悉流行的 3D 游戏,如 Minecraft,您可以按 Shift + F 切换到 Freelook 模式。在此模式下,您可以使用 WASD 键在场景中 飞行,同时用鼠标瞄准。再次按 Shift + F 退出 Freelook 模式。

您还可以通过点击左上角的 [视角] 标签来改变相机的视图。在这里,您可以快速将相机定位到特定的方向,如俯视图或前视图:

这在结合使用多个视口时,尤其是在大屏幕上特别有用。点击视图菜单,可以将屏幕分割成多个空间视图,让您能够同时从各个侧面看到对象。

注意,这些菜单选项中的每一个都关联了一个键盘快捷键。您可以通过点击编辑器 | 编辑器设置 | 3D 来调整 3D 导航和快捷键,以满足您的喜好。

当使用多个视口时,每个视口都可以设置为不同的视角,这样您就可以同时从多个方向看到您动作的效果:

添加 3D 对象

是时候添加您的第一个 3D 节点了。就像所有 2D 节点都继承自 Node2D,它提供了如 positionrotation 等属性一样,3D 节点继承自 Spatial 节点。将一个添加到场景中,您将看到以下内容:

你看到的那个五彩斑斓的对象不是节点,而是一个 3D 工具。工具是允许你在空间中移动和旋转对象的工具。三个环控制旋转,而三个箭头沿着三个轴移动(平移)对象。注意,环和箭头是按照轴的颜色进行编码的。箭头沿着相应的轴移动对象,而环则围绕特定的轴旋转对象。还有三个小方块可以锁定一个轴,并允许你在单个平面上移动对象。

花几分钟时间实验并熟悉工具。如果你发现自己迷路了,请使用撤销。

有时候,工具会碍事。你可以点击模式图标来限制自己只进行一种变换:移动、旋转或缩放:

图片

QWER键是这些按钮的快捷键,允许快速在模式之间切换。

全局空间与本地空间

默认情况下,工具控制操作在全局空间中。尝试旋转对象。无论你怎么转,工具的移动箭头仍然沿着轴指向。现在尝试这样做:将Spatial节点放回其原始位置和方向(或者删除它并添加一个新的)。围绕一个轴旋转对象,然后点击本地空间模式(T)按钮:

图片

观察一下工具箭头的位置。现在它们指向的是对象的本地xyz轴,而不是世界轴。当你点击并拖动它们时,它们会相对于轴移动对象。在这两种模式之间切换可以使你更容易将对象放置到你想要的位置。

变换

查看你的Spatial节点的检查器。现在你有了平移、旋转度数以及缩放,而不是位置属性。当你移动对象时,观察这些值是如何变化的。请注意,平移表示对象相对于原点的坐标:

图片

你还会注意到一个变换属性,当你移动和旋转对象时,它也会改变。当你改变平移或旋转时,你会注意到 12 个变换量也会随之改变。

变换背后的数学解释超出了本书的范围,但简而言之,变换是一个矩阵,它同时描述了一个对象的平移、旋转和缩放。你之前在这本书的“太空岩石”游戏中简要使用过二维等价物,但这个概念在三维中应用得更广泛。

代码中的变换

当通过代码定位 3D 节点时,您可以访问其transformglobal_transform属性,它们是Transform对象。Transform有两个子属性:originbasisorigin表示身体相对于其父级原点或全局原点的偏移。basis属性包含三个定义与对象一起移动的局部坐标系的向量。当您处于局部空间模式时,想想 gizmo 中的三个轴箭头。

您将在本节后面了解更多关于如何使用 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你将看不到游戏视口中的任何内容。添加一个,并使用相机的 gizmo 将其定位并指向立方体,如以下截图所示:

图片

粉紫色、金字塔形状的对象被称为相机的fustrum。它代表相机的视角,可以变窄或变宽以影响相机的视野。fustrum 顶部的三角形箭头是相机的向上方向。

当你移动相机时,你可以使用右上角的预览按钮来检查你的瞄准。预览将始终显示所选相机可以看到的内容。

与你之前使用的Camera2D一样,一个Camera必须被设置为当前状态才能激活。它的其他属性会影响其视角:视野、投影和近/远。这些属性的默认值对于这个项目来说很好,但你可以尝试调整它们,看看它们如何影响立方体的视图。完成操作后,使用撤销将一切恢复到默认值。

项目设置

现在你已经学会了如何在 Godot 的 3D 编辑器中导航,你就可以开始 Minigolf 项目了。与其他项目一样,从以下链接下载游戏资源,并将其解压到你的项目文件夹中。解压后的assets文件夹包含图像、3D 模型和其他完成项目所需的资源。你可以从这里下载包含游戏艺术和声音(统称为assets)的 Zip 文件,github.com/PacktPublishing/Godot-Game-Engine-Projects/releases

这个游戏将使用左鼠标按钮作为输入。输入映射没有为此定义任何默认动作,因此你需要添加一个。打开项目 | 项目设置并转到输入映射选项卡。添加一个名为点击的新动作,然后点击加号添加一个鼠标按钮事件。选择左键:

图片

创建课程

对于第一个场景,添加一个名为Main的节点作为场景的根节点。这个场景将包含游戏的主要部分,从课程本身开始。首先添加一个GridMap节点来布置课程。

网格地图

GridMap 是你在早期项目中使用的 TileMap 节点的 3D 等价物。它允许你使用一组网格(包含在 MeshLibrary 中)并在网格中布局它们,以更快地设计环境。因为它是一个 3D 对象,所以你可以以任何方向堆叠网格,尽管在这个项目中,你将坚持在同一平面上。

制作网格库

res://assets 文件夹包含了一个为项目预生成的 MeshLibrary,其中包含了所有必要的课程部分以及碰撞形状。然而,如果你需要更改它或创建自己的,你会发现这个过程与在 2D 中创建 TileSet 非常相似。

用于创建预生成 MeshLibrary 的场景也可以在 res://assets 文件夹中找到。其名称为 course_tiles_edit1.tscn。请随意打开它并查看其设置方式。

首先,创建一个新的场景,以 Spatial 作为其根节点。向此节点添加任意数量的 MeshInstance。你可以从 res://assets/dae 文件夹中找到原始的课程网格,这些网格是从 Blender 导出的。

你给这些节点取的名字将是它们在 MeshLibrary 中的名字。

一旦添加了网格,它们需要添加静态碰撞体。创建与给定网格匹配的碰撞形状可能很复杂,但 Godot 有一种自动生成它们的方法。

选择一个网格,你会在编辑器窗口顶部看到一个 Mesh 菜单:

图片

选择创建 Trimesh 静态体,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的名称更改为“洞”。

现在,你准备好制作球了。由于球需要物理特性——重力、摩擦、与墙壁的碰撞以及其他物理属性——RigidBody将是节点选择的最佳选择。创建一个新的场景,并命名为BallRigidBody

RigidBody是你在第三章,“逃离迷宫”中使用的RigidBody2D节点的 3D 等价物。它的行为和属性非常相似,你使用许多相同的方法与之交互,例如apply_impulse()_integrate_forces()

球的形状需要是一个球体。基本 3D 形状,如球体、立方体、圆柱体等,被称为原语。Godot 可以使用MeshInstance节点自动创建原语,所以添加一个作为身体的子节点。在检查器中,在网格属性中选择新建球体网格:

图片

默认大小太大,所以点击新的球体网格,并设置其大小属性,半径为0.15,高度为0.3

图片

接下来,将一个CollisionShape节点添加到Ball上,并给它一个SphereShape。使用大小手柄(橙色点)调整大小以适应网格:

图片

测试球

要测试球,使用实例按钮将其添加到Main场景中。将其放置在赛道上方,然后播放。你应该看到球落下并落在地面上。你可能发现添加另一个位于赛道一侧的Camera节点以获得不同的视角很有帮助。设置你想要使用的相机的当前属性。

你还可以通过设置其线性/速度属性临时给球一些运动。尝试设置不同的值并播放场景。记住,y轴是向上的,使用太大的值可能会导致球直接穿过墙壁。完成后将其设置回(0, 0, 0)

改进碰撞

你可能已经注意到,在调整速度时,球有时会直接穿过墙壁,或者弹跳异常,尤其是如果你选择了一个高值。你可以对RigidBody属性进行一些调整,以改善高速下的碰撞行为。

首先,打开连续碰撞检测CCD)。你会在检查器中找到它列出的连续 Cd。使用 CCD 会改变物理引擎计算碰撞的方式。通常,引擎通过首先移动对象,然后测试和解决碰撞来运行。这很快,并且在大多数常见情况下都有效。当使用 CCD 时,引擎会沿着对象的路径预测其移动,并尝试预测碰撞可能发生的位置。这比默认行为慢,因此效率不高,尤其是在模拟许多对象时,但它要准确得多。由于你游戏中只有一个球,所以 CCD 是一个好选项,因为它不会引入任何明显的性能损失,但会大大提高碰撞检测。

球也需要一点额外的动作,所以将弹跳设置为0.2,并将重力比例设置为2

最后,你可能也注意到球停止需要很长时间。将线性/阻尼属性设置为0.5,并将角/阻尼设置为0.1,这样你就不必等待球停止移动那么长时间。

UI

现在球体已经在赛道上,你需要一种瞄准和击打球体的方法。对于这种类型的游戏,有许多可能的控制方案。对于这个项目,你将使用两步过程:

  1. 瞄准:一个箭头将出现来回摆动。点击鼠标按钮将瞄准方向设置为箭头。

  2. 射击:能量条将在屏幕上上下移动。点击鼠标将设置能量并发射球体。

瞄准箭头

在 3D 中绘制对象不像在 2D 中那么容易。在许多情况下,你将不得不切换到 3D 建模程序,如 Blender,来创建你的游戏对象。然而,在这种情况下,Godot 的原语为你提供了支持;要制作箭头,你只需要两个网格:一个长而薄的矩形和一个三角棱柱。

通过添加一个具有 MeshInstance 子节点的 Spatial 节点来开始一个新的场景。添加一个新的 CubeMesh。单击 Mesh 属性,并将 Size 属性设置为 (0.5, 0.2, 2)。这是箭头的身体,但它仍然有一个问题。如果你旋转父节点,网格将围绕其中心旋转。相反,你需要箭头围绕其末端旋转,因此将 MeshInstance 的 Transform/Translation 更改为 (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。按照以下名称命名:

图片

MarginContainer 的自定义常量全部设置为 20。将 Xolonium-Regular.ttf 字体添加到两个 Label 节点,并将它们的字体大小设置为 30。将 Shots 标签的文本设置为“得分:0”,将 Label 的文本设置为“能量”。将 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)

在开始时,使用两个身体的 transform.origin 属性将球放置在 Tee 的位置。然后,游戏被置于 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()

正如你在 Space Rocks 游戏中所见,你可以在 _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 度。根据你鼠标的灵敏度,你可以调整这个值以适应你的偏好。

相机改进

另一个问题,特别是如果你有一个相对较大的场地,如果你的摄像头放置在显示发球区附近,它可能无法很好地显示场地的其他部分,甚至根本不显示。这可能会在球位于某些位置时使瞄准变得具有挑战性。

在本节中,你将学习两种不同的方法来解决这个问题。一种涉及创建多个摄像头,并激活离球位置最近的那个。另一种解决方案是创建一个旋转摄像头,它跟随球,玩家可以控制它从任何角度查看场地。

多个摄像头

添加第二个“摄像头”节点,并将其放置在洞附近或课程的对端,例如:

图片

将一个“区域”子节点添加到这个第二个摄像头。命名为Camera2Area,然后添加一个“碰撞形状”。你可以使用球形形状,但在这个例子中,选择一个BoxShape。请注意,因为你已经旋转了摄像头,盒子也跟着旋转了。你可以通过将“碰撞形状”的旋转设置为相反的值来反转这一点,或者你可以让它保持旋转。无论如何,调整盒子的大小和位置,使其覆盖你想要摄像头负责的课程部分:

图片

现在,将区域的body_entered信号连接到主脚本。当球进入区域时,将发出信号,你可以更改活动摄像头:

func _on_Cam2Area_body_entered(body):
    $Camera2.current = true

再次玩游戏并将球击向新的摄像头区域。确认当球进入区域时,摄像头视图发生变化。对于大型场地,你可以添加尽可能多的摄像头,并将它们设置为激活场地的不同部分。

这种方法的缺点是摄像头仍然是静态的。除非你非常小心地将它们放置在正确的位置,否则从场地的某些位置瞄准球仍然可能不太舒适。

旋转摄像头

在许多 3D 游戏中,玩家可以控制一个可以旋转和移动的摄像头。通常,控制方案使用鼠标和键盘的组合。第一步将是添加一些新的输入动作:

图片

WASD 键将用于通过移动摄像头左右和上下来旋转摄像头。鼠标滚轮将控制缩放。

创建稳定器

摄像头移动需要有一定的限制。一方面,它应该始终保持水平,而不是倾斜。尝试这样做:拿一个摄像头,围绕 x(红色环)旋转一小部分,然后围绕z(蓝色环)旋转一小部分。现在,反转x旋转并点击预览按钮。你是否看到摄像头现在倾斜了?

解决这个问题的方法是放置一个稳定器——一个设计用于在移动过程中保持物体水平的装置。你可以使用两个Spatial节点来创建稳定器,分别控制摄像头的左右和上下移动。

首先,确保从场景中移除任何其他 Camera 节点。如果你尝试了上一节中的多摄像头设置并且不想删除它们,可以将它们的 Current 值设置为 Off 并断开任何 Area 信号。

添加一个新的 Spatial 节点,命名为 GimbalOut,并将其放置在赛道中心附近。确保不要旋转它。给它一个名为 GimbalInSpatial 子节点,然后在该节点上添加一个 Camera。将摄像头的变换/平移设置为 (0, 0, 10)

这是陀螺仪的工作原理:外空间允许仅在 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 模型上可能是一个非常复杂的过程,尤其是如果你不熟悉它的话。首先,一些词汇:

  • 纹理: 纹理是平面的、2D 图像,被 包裹 在 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颜色设置为白色,以便最好地看到效果。调整深度参数以增加或减少效果强度。负值会使凹坑看起来是凹进去的;在-1.0-1.5之间的值是一个不错的选择:

图片

花些时间尝试这些设置,找到你喜欢的组合。别忘了在游戏中也试试,因为 WorldEnvironment 的环境光照将影响最终结果。

环境选项

当你添加了 WorldEnvironment 时,你唯一更改的参数是环境光颜色。在本节中,你将了解一些其他你可以调整以改善视觉效果的性质:

  • 背景: 此参数允许您指定世界的背景看起来像什么。默认值是“清色”,即您目前看到的纯灰色。将模式更改为“天空”,然后在“天空属性”中选择“新程序天空”。请注意,天空不仅仅是背景外观——物体将反射和吸收其环境光。观察当您更改能量参数时球的外观如何变化。此设置可用于营造白天或夜晚天空的印象,甚至可以营造外星行星的印象。

  • 屏幕空间环境遮挡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

点击“保存”,您将拥有一个可玩的游戏版本。

示例 - 安卓平台的 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"

尝试一下,确保鼠标点击会导致玩家移动。如果一切正常,您就可以为 Android 开发设置计算机了。

准备您的系统

为了将您的项目导出为 Android 版本,您需要从developer.android.com/studio/下载 Android 软件开发工具包 (SDK),以及从www.oracle.com/technetwork/java/javase/downloads/index.html下载Java 开发工具包 (JDK)。

当您第一次运行 Android Studio 时,点击“配置 | SDK 管理器”,并确保安装 Android SDK 平台工具:

这将安装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 中,点击“编辑 | 编辑设置”,找到“导出/Android”部分,并设置系统上应用程序的路径。请注意,您只需做一次,因为编辑器设置与项目设置是独立的:

导出

您现在可以导出了。点击“项目 | 导出”,并为 Android 添加一个预设(参见上一节)。点击导出项目按钮,您将得到一个可以安装到您设备上的Android 包工具包 (APK)。您可以使用图形工具或通过命令行使用adb来完成此操作:

adb install dodge.apk

注意,如果您的系统支持,连接兼容的 Android 设备将导致一键部署按钮在 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派生的节点中——在这个例子中,通过其材质属性添加到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坐标空间:

因此,坐标向量中的所有值都将介于01之间。

作为一个非常小的示例,这个第一个着色器将把仙人掌图像的像素都变成单一颜色。为了让你可以选择颜色,你可以使用一个uniform变量。

Uniforms 允许你从外部将数据传递到着色器中。声明一个uniform变量将使其出现在检查器中(类似于 GDScript 中的export工作方式),并允许你通过代码设置它。

在着色器面板中输入以下代码:

shader_type canvas_item;

uniform vec4 fill_color:hint_color;

void fragment(){
    COLOR.rgb = fill_color.rgb;
}

你应该会立即看到图像的变化:整个图像变成了黑色。要选择不同的颜色,请在检查器中点击材质,你会在着色器参数下看到你的uniform变量。

然而,你还没有完成。图片只是变成了一个彩色矩形,但你只想改变仙人掌的颜色,而不是其周围的透明像素。在设置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越大,图像看起来就越“模糊”:

学习更多

着色器能够实现令人惊叹的范围的效果。在 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 函数的命名改为 PascalCase 以符合 C# 标准,而不是 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 的按钮:

图片

点击此按钮将带你去 Godot 的资产库。这是一个由 Godot 社区贡献的插件、工具和实用程序的集合,你可能会在项目中找到它们很有用。例如,如果你搜索State,你会看到库中有一个名为有限状态机FSM)的工具。你可以点击其名称获取更多信息,如果你决定要尝试它,点击安装将其下载到res://addons/文件夹中,如果该文件夹不存在,将会创建:

图片

然后,你需要通过打开项目设置并选择插件选项卡来启用插件:

图片

插件现在可以使用了。请务必阅读插件作者的说明,以了解其工作原理。

为 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 链接处打开一个问题。如果你熟悉使用 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 的源代码。

如果你发现引擎本身有任何问题——比如某些功能不工作、文档中的错别字等等——你应该在这里报告这些问题。

Godot 问答

godotengine.org/qa/

这是 Godot 的官方帮助网站。你可以在这里发布问题供社区回答,以及搜索不断增长的先前回答的问题数据库。如果你恰好看到一个问题你知道答案,你也可以提供帮助。

Discord / 论坛

discord.gg/zH7NUgz

godotdevelopers.org/

虽然不是官方的,但这些是两个非常活跃的 Godot 用户社区,你可以在这里获得帮助,找到问题的答案,并与他人讨论你的项目。

摘要

在本章中,你了解了一些额外的主题,这些主题将帮助你继续提升你的 Godot 技能。除了本书中探索的功能外,Godot 还有许多其他功能。当你开始独立工作项目时,你需要知道去哪里寻找信息和去哪里寻求帮助。

你还了解了一些更高级的主题,例如使用其他编程语言以及使用着色器来增强你的游戏视觉效果。

此外,由于 Godot 是由其社区构建的,你学习了如何参与其中并成为使其成为其类型中增长最快的项目之一的团队的一部分。

posted @ 2025-10-07 17:59  绝不原创的飞龙  阅读(180)  评论(0)    收藏  举报