Godot4-游戏开发项目第二版-全-

Godot4 游戏开发项目第二版(全)

原文:zh.annas-archive.org/md5/1c4ab12fac2648c1de4cd3b35a7e3832

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

本书采用基于项目的学习方法,介绍如何使用 Godot。它包括五个项目以及额外的资源,这些资源将帮助开发者对如何使用 Godot 构建游戏有一个坚实的理解。

本书面向的对象

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

本书涵盖的内容

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

第一章Godot 4.0 简介,介绍了游戏引擎的一般概念以及 Godot 的具体内容,包括如何下载 Godot 以及如何有效地使用本书。

第二章金币冲刺 – 构建你的第一个 2D 游戏,是一个小型 2D 游戏,演示了如何创建场景和与 Godot 的节点系统协同工作。你将学习如何在 Godot 编辑器中导航并使用 GDScript 编写你的第一个脚本。

第三章太空岩石:使用物理构建 2D 街机经典,演示了如何使用物理体创建类似小行星风格的太空游戏。

第四章丛林跳跃 – 在 2D 平台游戏中奔跑和跳跃,涉及类似超级马里奥兄弟的侧滚动平台游戏。你将了解运动学体、动画状态和利用瓦片图进行关卡设计。

第五章3D 迷你高尔夫:通过构建迷你高尔夫球场深入 3D,将前面的概念扩展到三维。你将处理网格、光照和相机控制。

第六章无限飞行者,继续探索 3D 开发,包括动态内容、过程生成以及更多 3D 技术。

第七章下一步和额外资源,涵盖了在掌握五个游戏项目中的材料后,可以探索的更多主题。在这里查找链接和技巧,以进一步扩展你的游戏开发技能。

要充分利用本书

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

Godot 可以在运行 Windows、MacOS 或 Linux 操作系统的任何相对现代的 PC 上运行。

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一个包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/lY2hq

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“使用 Godot 4,您有另一个选项:直接将.blend文件导入到您的 Godot 项目中。”

代码块设置如下:

shader_type canvas_item;
void fragment() {
	// Place fragment code here.
}

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“第一个属性是着色器,您可以选择新建着色器。当您这样做时,会出现一个创建着色器面板。”

小贴士或重要注意事项

看起来像这样。

联系我们

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

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

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

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

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

分享您的想法

一旦您阅读了《Godot 4 游戏开发项目》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈

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

下载此书的免费 PDF 副本

感谢您购买此书!

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

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

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

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

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限

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

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

packt.link/free-ebook/9781804610404

  1. 提交您的购买证明

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

第一章:Godot 4.0 简介

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

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

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

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

  • 开始的一般建议

  • 什么是游戏引擎?

  • 什么是 Godot?

  • 下载 Godot

  • Godot UI 概述

  • 节点和场景

  • Godot 中的脚本编写

一般建议

本节包含了一些基于作者作为教师和讲师经验的读者一般建议。在阅读本书时,请记住这些提示,特别是如果你对编程非常陌生。

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

这里有很多内容需要吸收。如果你一开始不理解,不要感到气馁。目标不是一夜之间成为游戏开发专家——那是不可能的。就像任何其他技能——比如木工或乐器——一样,需要多年的实践和学习才能达到熟练。重复是学习复杂主题的关键;你与 Godot 的功能工作得越多,它们就会开始显得越熟悉、越容易。在你读完之后,尝试重复阅读前面的章节。你会惊讶于与第一次阅读相比,你理解了多少。

如果你正在以电子书的形式阅读,请抵制复制粘贴代码的诱惑。自己输入代码会让你的大脑更加活跃。这就像在讲座中做笔记一样,即使你永远不会再看笔记,它也能帮助你更好地学习。如果你打字速度慢,这也有助于你提高打字速度。总之:你是一名程序员,所以习惯于输入代码!

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

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

灵感是很好的,但如果你还没有完成基本项目,就把它们写下来并留到以后。不要让自己被一个接一个的“酷点子”所分心。开发者称之为功能蔓延,意味着一个永远不会停止增长的功能列表,这是一个导致许多项目未完成的陷阱。不要成为它的受害者。

最后,别忘了时不时地休息一下。你不应该试图在短短几次阅读中就完成整本书,或者甚至是一个项目。在每个新概念之后,尤其是在每个章节之后,在你深入下一个概念之前,给自己一些时间来吸收新信息。你会发现,你不仅能够记住更多的信息,而且可能会更加享受这个过程。

学习有效的秘诀

获取这些项目最大效益和提升技能的秘诀在于:在每章结束时,一旦你完成了游戏项目,立即删除它并重新开始。这次,尝试在不看书的情况下重新创建它。如果你卡住了,只需查看章节中的那部分内容,然后再次合上书本。如果你真的很有信心,尝试给游戏添加你自己的特色——改变一些游戏玩法或添加新的转折。

如果你对每个游戏都这样做多次,你会惊讶地发现你检查书本的频率会降低。如果你能在没有帮助的情况下独立完成这本书中的项目,那么你肯定已经准备好扩展你的思路并承担你自己的原创概念了。

在阅读以下部分时,请记住这些提示。在下一节中,你将了解什么是游戏引擎以及为什么游戏开发者可能想要选择使用它。

什么是游戏引擎?

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

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

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

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

这里是一些典型游戏引擎将提供的主要功能:

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

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

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

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

除了这些,还有工具可以帮助处理网络、简化图像和声音管理、动画、调试等功能。通常,游戏引擎会包括从其他工具导入内容的能力,例如用于创建动画或 3D 模型的工具。

使用游戏引擎可以让开发者专注于构建他们的游戏,而不是创建使其工作的底层框架。对于小型或独立开发者来说,这可能意味着在开发一年后发布游戏而不是三年,甚至根本无法发布。

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

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

什么是 Godot?

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

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

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

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

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

现在你已经了解了 Godot 是什么以及它如何帮助你构建游戏,是时候开始行动了。在下一节中,你将了解如何下载 Godot 并将其设置在你的电脑上使用。

下载 Godot

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

在下载页面,你还将看到一个标准版本和一个.NET 版本。.NET 版本是专门为与 C#编程语言一起使用而构建的。除非你计划使用 C#与 Godot 一起使用,否则不要下载这个版本。本书中的项目不使用 C#。

图 1.1:Godot 下载页面

图 1.1:Godot 下载页面

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

其他安装方法

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

  • Steam:如果你在 Steam 上有账户,可以通过 Steam 桌面应用程序安装 Godot。在 Steam 商店中搜索 Godot,并按照说明进行安装。你可以从 Steam 应用程序启动 Godot:

图 1.2:Steam 上的 Godot 引擎

图 1.2:Steam 上的 Godot 引擎

  • Itch.io:你还可以从流行的 itch.io 网站下载 Godot。Itch 是一个独立游戏开发者和创作者的市场。搜索 Godot,并从提供的链接下载。

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

    • Homebrew(macOS)

    • Scoop(Windows)

    • Snap(Linux)

安卓和网页版本

你还将看到适用于在 Android 和网页浏览器上运行的 Godot 版本。在撰写本文时,这些版本被列为“实验性”,可能不稳定或功能不完整。建议你使用 Godot 的桌面版本,尤其是在学习期间。

恭喜,您已成功将 Godot 安装到您的计算机上。在下一节中,您将看到 Godot 编辑器界面的概述——您在编辑器中工作时将使用的各种窗口和按钮的目的。

Godot UI 概览

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

项目管理器

项目管理器窗口是您打开 Godot 后看到的第一个窗口:

图 1.3:项目管理器

图 1.3:项目管理器

首次打开 Godot

第一次打开 Godot 时,您还没有任何项目。您会看到一个弹出窗口询问您是否想要在资源库中探索官方示例项目。选择取消,您将看到项目管理器,如图中所示。

在此窗口中,您可以看到您现有的 Godot 项目列表。您可以选择一个现有项目,点击运行来玩游戏或编辑在 Godot 编辑器中工作。您还可以通过点击新建项目来创建新项目:

图 1.4:新项目设置

图 1.4:新项目设置

在这里,你可以为项目命名并创建一个文件夹来存储它。注意警告信息——Godot 项目在计算机上作为独立的文件夹存储。项目使用的所有文件都必须位于此文件夹中。这使得共享 Godot 项目变得方便,因为你只需要压缩项目文件夹,并且可以确信另一个 Godot 用户能够打开它,而不会缺少任何必要的数据。

渲染器

在创建新项目时,您还可以选择渲染器。这三个选项代表了在需要现代桌面 GPU 的高级、高性能图形和与移动和较老桌面等不太强大的平台兼容性之间的平衡。如果您需要,您可以在以后更改此选项,所以将其保留为默认设置是可以的。如果您以后决定为移动平台构建游戏,Godot 文档提供了大量关于性能和渲染选项的信息。参见第七章以获取链接和更多信息。

选择文件名

当您为新项目命名时,有一些简单的规则您应该尝试遵循,这可能会在将来为您节省一些麻烦。为您的项目起一个描述性的名字——巫师战斗竞技场游戏 #2是一个更好的项目名称。在未来,您将永远无法记住哪个是游戏编号二,所以尽可能描述得详细。

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

一旦你创建了项目文件夹,test_project

控制台窗口

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

编辑器窗口

下面的图是 Godot 主编辑窗口的截图。当你使用 Godot 构建项目时,你将在这里花费大部分时间。编辑器界面被分为几个部分,每个部分提供不同的功能。每个部分的特定术语将在 图 1.5 之后描述:

图 1.5:Godot 编辑器窗口

图 1.5:Godot 编辑器窗口

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

在窗口的顶部中央是一个你可以切换的 工作空间 列表,当你在游戏的不同部分工作时,你可以在这之间切换。你可以切换到 2D3D 模式,以及 脚本 模式,在那里你将编辑你的游戏代码。AssetLib 是你可以下载由 Godot 社区贡献的插件和示例项目的地方。参见 第七章 了解有关使用资产库的更多信息。

图 1.6 展示了你当前工作空间使用的 工具栏。这里的图标将根据你正在处理的对象类型而变化:

图 1.6:工具栏图标

图 1.6:工具栏图标

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

图 1.7:游戏测试按钮

图 1.7:游戏测试按钮

在左侧和右侧是你可以用来查看和选择游戏项目并设置其属性的 标签。在左侧坞的底部,你会找到 res:// 路径,这是项目的根文件夹。例如,文件路径可能看起来像这样:res://player/player.tscn。这指的是 player 文件夹中的一个文件:

图 1.8:FileSystem 选项卡

图 1.8:FileSystem 选项卡

在左侧工具栏的顶部是场景标签,它显示了你在视图中正在工作的当前场景(关于场景的更多内容请参阅图 1.9后的内容):

图 1.9:场景标签

图 1.9:场景标签

在右侧,你会找到一个标记为检查器的框,在那里你可以查看和调整游戏对象的属性。

随着你在这本书中处理游戏项目,你将了解这些项目的功能,并熟悉导航编辑器界面。

在阅读本节之后,你应该对 Godot 编辑器窗口的布局以及你在本书中将要看到的元素名称感到舒适。你离完成这个介绍并开始制作游戏又近了一步。不过,你注意到图 1.9中的那些项目了吗?那些被称为节点,你将在下一节中了解到它们的所有内容。

了解节点和场景

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

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

图 1.10:以树状结构排列的节点

图 1.10:以树状结构排列的节点

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

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

节点是强大的工具,理解它们是有效构建 Godot 中游戏对象的关键。然而,仅凭节点本身,它们能做的有限。游戏逻辑——即你的游戏中的对象将遵循的规则——还需要你来提供。在下一节中,你可以通过使用 Godot 的脚本语言编写代码来了解如何实现这一点。

Godot 中的脚本编写

Godot 为节点脚本提供了两种官方语言:GDScriptC#。GDScript 是专用内置语言,提供与引擎最紧密的集成,并且使用起来最简单。对于已经熟悉或精通 C# 的人来说,您可以下载支持该语言版本的版本。

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

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

关于 GDScript

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

本书假设您已经具备至少一些编程经验。如果您以前从未编码过,您可能会觉得这有点困难。学习游戏引擎本身就是一项艰巨的任务;同时学习编码意味着您已经接受了重大挑战。如果您发现自己在这本书的代码中遇到困难,您可能会发现通过在 Python 或 JavaScript 等语言中进行入门编程课程的学习,可以帮助您掌握基础知识。

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

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

extends Sprite2D
var speed = 200
func _ready():
    position = Vector2(100, 100)
func_process(delta):
    position.x += speed * delta

如果您之前使用过其他高级语言,如 Python,这看起来会非常熟悉,但如果您觉得这段代码现在还不太明白,请不要担心。在接下来的章节中,您将编写大量的代码,这些代码将伴随着所有工作原理的解释。

摘要

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

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

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

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

第二章:Coin Dash – 构建你的第一个 2D 游戏

这个第一个项目将指导您制作您的第一个 Godot 引擎游戏。您将学习 Godot 编辑器的工作方式,如何构建项目结构,以及如何使用 Godot 最常用的节点构建一个小型 2D 游戏。

为什么从 2D 开始?

简而言之,3D 游戏比 2D 游戏复杂得多。然而,您需要了解的许多底层游戏引擎功能是相同的。您应该坚持使用 2D,直到您对 Godot 的工作流程有很好的理解。到那时,转向 3D 将感觉容易得多。您将在本书的后续章节中有机会在 3D 中工作。

即使您不是游戏开发的完全新手,也不要跳过本章。虽然您可能已经理解了许多概念,但这个项目将介绍 Godot 的功能和设计范式——您在前进过程中需要了解的事情。

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

图 2.1:完成的游戏

图 2.1:完成的游戏

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

  • 设置新项目

  • 创建角色动画

  • 移动角色

  • 使用Area2D检测对象接触

  • 使用Control节点显示信息

  • 使用信号在游戏对象之间进行通信

技术要求

从以下链接下载游戏资源:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Downloads,并将它们解压缩到您的新项目文件夹中。

您也可以在 GitHub 上找到本章的完整代码:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Chapter02%20-%20Coin%20Dash

设置项目

启动 Godot,然后在项目管理器中点击+ 新建 项目按钮。

您首先需要创建一个项目文件夹。在项目名称框中输入Coin Dash,然后点击创建文件夹。为您的项目创建一个文件夹对于将所有项目文件与您计算机上的其他任何项目分开非常重要。接下来,您可以点击创建 & 编辑以在 Godot 编辑器中打开新项目。

图 2.2:新项目窗口

图 2.2:新项目窗口

在这个项目中,您将创建三个独立的场景——玩家角色、金币和一个显示得分和计时的显示界面——所有这些都将组合到游戏的“主”场景中(见第一章)。在一个更大的项目中,创建单独的文件夹来组织每个场景的资产和脚本可能很有用,但在这个相对较小的游戏中,您可以将所有场景和脚本保存在根文件夹中,该文件夹被称为res://(res 是资源的缩写)。您项目中的所有资源都将位于res://文件夹的相对位置。您可以在icon.svg中看到项目文件,这是 Godot 的图标。

您可以在此处下载游戏的艺术和声音(统称为资产)的 ZIP 文件:github.com/PacktPublishing/Godot-Engine-Game-Development-Projects-Second-Edition/tree/main/Downloads。将此文件解压到您创建的新项目文件夹中。

图 2.3:文件系统选项卡

图 2.3:文件系统选项卡

例如,金币的图像位于res://assets/coin/

由于这款游戏将以竖屏模式(高度大于宽度)运行,我们首先需要设置游戏窗口。

从顶部菜单中选择项目 -> 项目设置。设置窗口看起来像这样:

图 2.4:项目设置窗口

图 2.4:项目设置窗口

查找前文图示中的480720。在此部分,在拉伸选项下,将模式设置为canvas_items,将纵横比设置为保持。这将确保如果用户调整游戏窗口大小,所有内容都将适当缩放,而不会拉伸或变形。您还可以在大小下的可调整大小框中取消勾选,以防止窗口被调整大小。

恭喜!您已设置好新项目,并准备好开始制作您的第一个游戏。在这个游戏中,您将创建在 2D 空间中移动的对象,因此了解如何使用 2D 坐标定位和移动对象非常重要。在下一节中,您将了解这是如何工作的以及如何将其应用于您的游戏。

向量和 2D 坐标系

本节简要概述了 2D 坐标系和向量数学在游戏开发中的应用。向量数学是游戏开发中的基本工具,如果您需要对该主题有更广泛的理解,请参阅可汗学院的线性代数系列(www.khanacademy.org/math/linear-algebra)。

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

在二维空间中,Godot 遵循计算机图形学中常见的做法,将 x 轴朝右,将 y 轴朝下:

图 2.5:二维坐标系

图 2.5:二维坐标系

这不是我的数学老师教给我的!

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

向量

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

图 2.6:二维向量

图 2.6:二维向量

这支箭是一个 向量。它代表了许多有用的信息,包括点的位置、其距离或 长度 (m),以及其相对于 x 轴的 角度 (θ)。更具体地说,这种类型的向量被称为 位置向量——即描述空间中位置的向量。向量还可以表示运动、加速度或任何具有大小和方向的量。

在 Godot 中,向量有广泛的应用,你将在本书的每个项目中都会用到它们。

现在,你应该已经了解了二维坐标空间的工作原理以及向量如何帮助定位和移动对象。在下一节中,你将创建玩家对象并使用这些知识来控制其移动。

第一部分——玩家场景

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

你的玩家场景需要完成以下事情:

  • 显示你的角色及其动画

  • 通过移动角色来响应用户输入

  • 检测与其他游戏对象(如金币或障碍物)的碰撞

创建场景

首先点击 Area2D。然后,点击节点的名称并将其更改为 Player。点击 场景 -> 保存场景 (Ctrl + S) 来保存场景。

图 2.7:添加节点

图 2.7:添加节点

现在查看 player.tscn 文件。每次你在 Godot 中保存场景时,它都会使用 .tscn 扩展名——这是 Godot 场景的文件格式。"t" 在名称中的含义是 "text",因为这些是文本文件。如果你好奇,可以自由地在外部文本编辑器中查看它,但你不应该手动编辑它;否则,你可能会不小心损坏文件。

现在你已经创建了场景的 Area2D 节点,因为它是一个 2D 节点,所以它可以在 2D 空间中移动,并且可以检测与其他节点的重叠,因此我们可以检测到硬币和其他游戏对象。在设计游戏对象时,选择用于特定游戏对象的节点是你的第一个重要决定。

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

图 2.8:切换节点分组

图 2.8:切换节点分组

工具提示说 使选定的节点子节点不可选择,这是好的——它将有助于避免错误。点击按钮,你会在玩家节点名称旁边看到相同的图标:

图 2.9:节点分组图标

图 2.9:节点分组图标

在创建新场景时始终这样做是个好主意。如果一个对象的子节点发生偏移或缩放,可能会导致意外的错误并且难以调试。

精灵动画

使用 Area2D,你可以检测其他对象是否与玩家重叠或碰撞,但 Area2D 本身没有外观。你还需要一个可以显示图像的节点。由于角色有动画,请选择玩家节点并添加一个 AnimatedSprite2D 节点。此节点将处理玩家的外观和动画。注意节点旁边有一个警告符号。AnimatedSprite2D 需要一个 SpriteFrames 资源,其中包含它可以显示的动画。要创建一个,找到 Inspector 窗口中的 Frames 属性并点击 以查看下拉菜单。选择 New SpriteFrames

图 2.10:添加 SpriteFrames 资源

图 2.10:添加 SpriteFrames 资源

接下来,在相同的位置,点击那里出现的 SpriteFrames 标签以在屏幕底部打开一个新面板:

图 2.11:SpriteFrames 面板

图 2.11:SpriteFrames 面板

在左侧是动画列表。点击 default 并将其重命名为 run。然后,点击 idle 和第三个名为 hurt

res://assets/player/ 文件夹中,并将它们拖动到相应的动画中:

图 2.12:设置玩家动画

图 2.12:设置玩家动画

每个新的动画都有一个默认的每秒5帧的速度设置。这有点太慢了,所以选择每个动画并将速度设置为8

要查看动画的实际效果,请点击播放按钮 ()。您的动画将出现在检查器窗口中动画属性的下拉菜单中。选择一个来查看其效果:

图 2.13:动画属性

图 2.13:动画属性

您还可以选择一个默认播放的动画。选择idle动画并点击加载时自动播放按钮。

图 2.14:设置动画自动播放

图 2.14:设置动画自动播放

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

玩家图像有点小,所以将AnimatedSprite2D的缩放设置为(2, 2)以增加其大小。您可以在检查器窗口的变换部分找到此属性。

图 2.15:设置缩放属性

图 2.15:设置缩放属性

碰撞形状

当使用Area2D或其他碰撞对象时,你需要告诉 Godot 对象的具体形状。其碰撞形状定义了它所占据的区域,并用于检测重叠和/或碰撞。形状由各种Shape2D类型定义,包括矩形、圆形和多边形。在游戏开发中,这有时被称为击打框

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

CollisionShape2D作为Player节点的子节点添加(确保不要将其作为AnimatedSprite2D的子节点添加)。在检查器窗口中,找到形状属性并点击以选择新矩形形状 2D

图 2.16:添加碰撞形状

图 2.16:添加碰撞形状

拖动橙色手柄以调整形状的大小以覆盖精灵。提示 - 如果您在拖动手柄时按住Alt键,形状将对称地调整大小。您可能已经注意到碰撞形状不是在精灵上居中的。这是因为精灵图像本身在垂直方向上并不居中。您可以通过向AnimatedSprite2D添加一个小偏移量来修复这个问题。选择节点并查找(``0, -5)

图 2.17:调整碰撞形状大小

图 2.17:调整碰撞形状大小

完成后,您的Player场景应该看起来像这样:

图 2.18:玩家节点设置

图 2.18:玩家节点设置

编写玩家脚本

现在,你准备好向玩家添加一些代码了。将脚本附加到节点允许你添加节点本身不提供的额外功能。选择 Player 节点并点击 新建 脚本 按钮:

图 2.19:新建脚本按钮

图 2.19:新建脚本按钮

附加节点脚本 窗口中,你可以保持默认设置不变。如果你记得保存场景,脚本将自动命名为与场景名称匹配。点击 创建,你将被带到脚本窗口。你的脚本将包含一些默认的注释和提示。

每个脚本的第一个行描述了它附加到的节点类型。就在那之后,你可以开始定义你的变量:

extends Area2D
@export var speed = 350
var velocity = Vector2.ZERO
var screensize = Vector2(480, 720)

使用 @export 注解在 speed 变量上允许你在 Player 节点中设置其值,你将看到你在脚本中编写的 350 速度值。

图 2.20:检查器窗口中导出的变量

图 2.20:检查器窗口中导出的变量

对于其他变量,velocity 将包含角色的移动速度和方向,而 screensize 将帮助设置角色移动的限制。稍后,你将从游戏的主场景自动设置此值,但就目前而言,手动设置它将允许你测试一切是否正常工作。

移动玩家

接下来,你将使用 _process() 函数来定义玩家将执行的操作。_process() 函数在每一帧都会被调用,因此你可以用它来更新你期望经常更改的游戏元素。在每一帧中,你需要玩家执行以下三件事:

  • 检查键盘输入

  • 沿给定方向移动

  • 播放适当的动画

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

你可以使用 Input.is_action_pressed() 来检测是否按下了输入动作,如果按键被按下则返回 true,如果没有则返回 false。通过组合所有四个键的状态,你可以得到运动的结果方向。

你可以通过使用多个 if 语句分别检查所有四个键来完成此操作,但由于这是一个如此常见的需求,Godot 提供了一个有用的函数 Input.get_vector(),它会为你处理这些操作——你只需要告诉它要使用哪四个输入。注意输入动作的列表顺序;get_vector() 期望它们按照这个顺序。此函数的结果是一个 方向向量——一个指向八个可能方向之一的向量,这些方向由按下的输入产生:

func _process(delta):
    velocity = Input.get_vector("ui_left", "ui_right",
        "ui_up", "ui_down")
    position += velocity * speed * delta

之后,你将有一个指示移动方向的 velocity 向量,所以下一步将是使用该速度实际更新玩家的 position

在右上角点击 运行当前场景 (F6),并检查你是否可以使用所有四个箭头键移动玩家。

你可能会注意到玩家继续从屏幕的一侧跑出去。你可以使用 clamp() 函数将玩家的 position 限制在最小和最大值之间,防止他们离开屏幕。在上一行之后立即添加这两行:

    position.x = clamp(position.x, 0, screensize.x)
    position.y = clamp(position.y, 0, screensize.y)

关于 delta

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

游戏引擎试图以每秒 60 帧的速率运行。然而,这可能会因为 Godot 或你电脑上同时运行的其他程序导致的计算机减速而改变。如果帧率不稳定,那么它将影响你游戏中对象的移动。例如,考虑一个你想要每帧移动 10 像素的对象。如果一切运行顺利,这意味着对象在一秒内移动 600 像素。然而,如果其中一些帧耗时较长,那么那一秒可能只有 50 帧,因此对象只移动了 500 像素。

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

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

选择动画

现在玩家可以移动了,你需要根据玩家是移动还是静止来更改 AnimatedSprite2D 正在播放的动画。run 动画的美术面向右侧,这意味着它需要水平翻转(在移动代码之后使用 _process() 函数):

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

获取节点

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

注意,此代码采取了一些捷径。flip_h是一个布尔属性,这意味着它可以设置为truefalse。布尔值也是比较的结果,例如<。正因为如此,你可以直接将属性设置为比较的结果。

再次播放场景,并检查每种情况下动画是否正确。

开始和结束玩家的移动

主场景需要通知玩家游戏何时开始和结束。为此,向玩家添加一个start()函数,该函数将设置玩家的起始位置和动画:

func start():
    set_process(true)
    position = screensize / 2
    $AnimatedSprite2D.animation = "idle"

此外,添加一个die()函数,当玩家撞到障碍物或用完时间时调用:

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

使用set_process(false)告诉 Godot 停止每帧调用_process()函数。由于移动代码在该函数中,因此当游戏结束时,你将无法移动。

准备碰撞

玩家应该能够检测到它撞到硬币或障碍物,但你还没有制作这些对象。没关系,因为你可以使用 Godot 的信号功能来实现这一点。信号是节点发送消息的方式,其他节点可以检测并响应。许多节点都有内置的信号,用于在事件发生时提醒你,例如身体碰撞或按钮被按下。你也可以为你的目的定义自定义信号。

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

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

signal pickup
signal hurt

这些行声明了玩家将向Area2D本身发出的自定义信号。选择Player节点,然后点击Inspector标签旁边的Node标签,以查看玩家可以发出的信号列表:

图 2.21:节点的信号列表

图 2.21:节点的信号列表

也要在那里记录你的自定义信号。由于其他对象也将是Area2D节点,你将想要使用area_entered信号。选择它,并在你的脚本中点击_on_area_entered()

在连接信号时,你不仅可以让 Godot 为你创建函数,还可以指定你想要使用的现有函数的名称。如果你不想让 Godot 为你创建函数,请关闭Make Function开关。

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

func _on_area_entered(area):
    if area.is_in_group("coins"):
        area.pickup()
        pickup.emit()
    if area.is_in_group("obstacles"):
        hurt.emit()
        die()

当另一个区域对象与玩家重叠时,此函数将被调用,并且重叠的区域将通过area参数传递。硬币对象将有一个pickup()函数,该函数定义了捡起硬币时硬币会做什么(例如播放动画或声音)。当你创建硬币和障碍物时,你需要将它们分配到适当的,以便它们可以被正确检测。

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

extends Area2D
signal pickup
signal hurt
@export var speed = 350
var velocity = Vector2.ZERO
var screensize = Vector2(480, 720)
func _process(delta):
    # Get a vector representing the player's input
    # Then move and clamp the position inside the screen
    velocity = Input.get_vector("ui_left", "ui_right",
        "ui_up", "ui_down")
    position += velocity * speed * delta
    position.x = clamp(position.x, 0, screensize.x)
    position.y = clamp(position.y, 0, screensize.y)
    # Choose which animation to play
    if velocity.length() > 0:
        $AnimatedSprite2D.animation = "run"
    else:
        $AnimatedSprite2D.animation = "idle"
    if velocity.x != 0:
        $AnimatedSprite2D.flip_h = velocity.x < 0
func start():
    # This function resets the player for a new game
    set_process(true)
    position = screensize / 2
    $AnimatedSprite2D.animation = "idle"
func die():
    # We call this function when the player dies
    $AnimatedSprite2D.animation = "hurt"
    set_process(false)
func _on_area_entered(area):
    # When we hit an object, decide what to do
    if area.is_in_group("coins"):
        area.pickup()
        pickup.emit()
    if area.is_in_group("obstacles"):
        hurt.emit()
        die()

你已经完成了玩家对象的设置,并且已经测试了移动和动画是否正常工作。在继续下一步之前,请回顾玩家场景设置和脚本,并确保你理解你所做的一切以及为什么这样做。在下一节中,你将为玩家创建一些可收集的对象。

第二部分 – 硬币场景

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

节点设置

点击 Player 场景:

  • Area2D(命名为 Coin):

    • AnimatedSprite2D

    • CollisionShape2D

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

按照玩家场景中的方式设置 AnimatedSprite2D。这次,你只有一个动画 – 一种使硬币看起来动态且有趣的光泽/闪耀效果。添加所有帧并将动画速度设置为 12 FPS。图像也稍微有点大,所以将 AnimatedSprite2D 设置为 (0.4, 0.4)。在 CollisionShape2D 中,使用 CircleShape2D 并将其调整大小以覆盖硬币图像。

使用组

组为节点提供了一种标记系统,允许你识别相似的节点。一个节点可以属于任意数量的组。为了让玩家脚本正确检测到硬币,你需要确保所有硬币都将位于一个名为 Coin 的组中,点击框中的 coins 并点击 添加

图 2.22:组选项卡

图 2.22:组选项卡

硬币脚本

你的下一步是为 Coin 节点添加一个脚本。选择节点并点击新脚本按钮,就像你对 Player 节点所做的那样。如果你取消选择 模板 选项,你将得到一个没有注释或建议的空脚本。硬币的代码比玩家的代码要短得多:

extends Area2D
var screensize = Vector2.ZERO
func pickup():
    queue_free()

请记住,pickup() 函数是由玩家脚本调用的。它定义了收集硬币时硬币将执行的操作。queue_free() 是 Godot 用于删除节点的函数。它安全地从树中删除节点并从内存中删除它及其所有子节点。稍后,你将在这里添加视觉和音频效果,但现在,让硬币消失就足够了。

删除节点

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

你现在已经完成了这个游戏所需的两个对象中的第二个。硬币对象可以随机放置在屏幕上,并且它可以检测玩家何时触摸它,因此可以被收集。拼图的剩余部分是如何将所有这些组合在一起。在下一节中,你将创建第三个场景以随机生成硬币并允许玩家与之交互。

第三部分 – 主场景

Main 场景是将游戏的所有部分联系在一起的关键。它将管理玩家、硬币、时钟以及游戏的所有其他部分。

节点设置

创建一个新的场景并添加一个名为 MainNode。最简单的节点类型是 Node —— 它本身几乎不做任何事情,但你会将其用作所有游戏对象的父节点,并添加一个脚本,使其具有你需要的功能。保存场景。

通过点击 player.tscn 将玩家添加为 Main 的子节点:

图 2.23:实例化场景

图 2.23:实例化场景

将以下节点作为 Main 的子节点添加:

  • 一个名为 BackgroundTextureRect 节点——用于背景图像

  • 一个名为 GameTimerTimer 节点——用于倒计时计时器

确保将 Background 作为第一个子节点,通过在节点列表中将它拖到玩家上方。节点按照在树中显示的顺序绘制,所以如果 Background 是第一个,那么确保它在玩家后面绘制。通过将 assets 文件夹中的 grass.png 图像拖动到编辑器窗口顶部的布局按钮上的 Texture 属性来添加图像到 Background 节点。将 Stretch Mode 改为 Tile,然后通过点击编辑器窗口顶部的布局按钮将大小设置为 Full Rect

图 2.24:布局选项

图 2.24:布局选项

主脚本

将脚本添加到 Main 节点,并添加以下变量:

extends Node
@export var coin_scene : PackedScene
@export var playtime = 30
var level = 1
var score = 0
var time_left = 0
var screensize = Vector2.ZERO
var playing = false

Main 节点。从 FileSystem 面板拖动 coin.tscn 并将其放入 Coin Scene 属性。

初始化

首先,添加 _ready() 函数:

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

Godot 在每个节点被添加时自动调用 _ready()。这是一个放置你希望在节点生命周期开始时执行的代码的好地方。

注意,你正在使用 $ 语法通过名称引用 Player 节点,这使得你可以找到游戏屏幕的大小并设置玩家的 screensize 变量。hide() 使节点不可见,所以在游戏开始之前你不会看到玩家。

开始新游戏

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

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

除了设置变量的起始值外,此函数还调用你之前编写的玩家的 start() 函数。启动 GameTimer 将开始倒计时游戏剩余时间。

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

func spawn_coins():
    for i in level + 4:
        var c = coin_scene.instantiate()
        add_child(c)
        c.screensize = screensize
        c.position = Vector2(randi_range(0, screensize.x),
            randi_range(0, screensize.y))

在这个函数中,你创建多个 Coin 对象并将它们作为 Main 的子对象添加(这次是通过代码,而不是手动点击 add_child()。最后,你使用 screensize 变量选择金币的随机位置,这样它们就不会出现在屏幕之外。你将在每个级别的开始时调用这个函数,每次生成更多的金币。

最终,你希望当玩家点击 _ready() 函数末尾的 new_game() 并点击 main.tscn 时调用 new_game()。现在,每次你玩这个项目时,Main 场景都将启动。

到这个时候,你应该在屏幕上看到你的玩家和五个金币出现。当玩家触摸一个金币时,它会消失。

测试完成后,从 _ready() 函数中移除 new_game()

检查剩余金币

main 脚本需要检测玩家是否已经捡起所有金币。由于所有金币都在 coins 组中,你可以检查组的大小以查看剩余多少。由于需要持续检查,将其放在 _process() 函数中:

func _process(delta):
    if playing and
    get_tree().get_nodes_in_group("coins").size() == 0:
        level += 1
        time_left += 5
        spawn_coins()

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

这完成了主要场景。在这个步骤中,你学到的最重要的东西是如何使用 instantiate() 动态在代码中创建新对象。这是在构建许多类型的游戏系统时你会反复使用的东西。在上一个步骤中,你将创建一个额外的场景来处理显示游戏信息,例如玩家的得分和剩余时间。

第四部分 – 用户界面

游戏需要的最后一个元素是 用户界面UI)。这将在游戏过程中显示玩家需要看到的信息,通常被称为 抬头显示HUD),因为信息以叠加的形式出现在游戏视图之上。你还将使用这个场景在游戏结束后显示一个开始按钮。

你的 HUD 将显示以下信息:

  • 得分

  • 剩余时间

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

  • 一个开始按钮

节点设置

创建一个新的场景并添加一个名为 HUDCanvasLayer 节点。一个 CanvasLayer 节点创建一个新的绘图层,这将允许你在游戏的其他部分之上绘制你的 UI 元素,这样它就不会被游戏对象,如玩家或金币,覆盖。

Godot 提供了各种 UI 元素,可以用来创建从指示器,如生命值条,到复杂界面,如存货界面。实际上,你用来制作这个游戏的 Godot 编辑器就是使用 Godot UI 元素构建的。UI 的基本节点都扩展自 Control,并在节点列表中显示为绿色图标。为了创建你的 UI,你将使用各种 Control 节点来定位、格式化和显示信息。以下是完成后的 HUD 看起来的样子:

图 2.25:HUD 布局

图 2.25:HUD 布局

消息标签

将一个Label节点添加到场景中,并将其名称更改为Message。当游戏结束时,此标签将显示游戏的标题以及游戏结束。此标签应位于游戏屏幕中央。您可以使用鼠标拖动它,或在检查器窗口中直接设置值,但使用布局菜单中提供的快捷键最简单,这将为您设置值。

从布局菜单中选择HCenter Wide

图 2.26:消息定位

图 2.26:消息定位

标签现在横跨屏幕宽度并垂直居中。文本属性设置标签显示的文本。将其设置为Coin Dash!,并将水平对齐垂直对齐都设置为居中

Label节点的默认字体非常小且不吸引人,所以下一步是分配一个自定义字体。在标签设置属性中,选择新标签设置然后点击它以展开。

Kenney Bold.ttf字体文件中拖动它到字体属性,并将大小设置为48。您还可以通过添加阴影来改善外观——尝试以下截图中的设置,或尝试您自己的设置:

图 2.27:字体设置

图 2.27:字体设置

分数和时间显示

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

容器

Godot 的Container节点会自动安排其子Control节点(包括其他容器)的位置和大小。您可以使用它们在元素周围添加填充、使它们居中,或按行和列排列。每种类型的Container都有特殊的属性来控制它们如何排列其子节点。

请记住,容器会自动排列其子节点。如果您尝试移动或调整容器节点内的Control的大小,编辑器会发出警告。您可以手动排列控件使用容器排列控件,但不能同时进行。

分数和时间显示

要管理分数和时间标签,请将一个MarginContainer节点添加到HUD中。使用布局菜单设置锚点为10。这将添加一些填充,使文本不会紧贴屏幕边缘。

由于分数和时间标签将使用与Message相同的字体设置,您可以通过复制它来节省时间。选择Message并按Ctrl + D两次以创建两个副本标签。将它们都拖动并放到MarginContainer中,使它们成为其子节点。将一个子节点命名为Score,另一个命名为Time,并设置Score但不要设置Time

通过 GDScript 更新 UI

将脚本添加到 HUD 节点。此脚本将在需要更改属性时更新 UI 元素,例如,每当收集到一个硬币时更新 Score 文本。请参阅以下代码:

extends CanvasLayer
signal start_game
func update_score(value):
    $MarginContainer/Score.text = str(value)
func update_timer(value):
    $MarginContainer/Time.text = str(value)

Main 场景的脚本将调用这两个函数以在值发生变化时更新显示。对于 Message 标签,您还需要一个计时器,以便在短时间内消失。

HUD 下添加一个 Timer 节点,并设置 2 秒和 One Shot开启。这确保了当启动计时器时,它只会运行一次,而不是重复。添加以下代码:

func show_message(text):
    $Message.text = text
    $Message.show()
    $Timer.start()

在此函数中,您将显示消息并启动计时器。要隐藏消息,连接 Timertimeout 信号(记住,它将自动创建新函数):

func _on_timer_timeout():
    $Message.hide()

使用按钮

HUD 中添加一个 Button 节点,并将其名称更改为 StartButton。此按钮将在游戏开始前显示,点击后将隐藏自身并向 Main 场景发送信号以开始游戏。设置 Message

在布局菜单中,选择 Center Bottom 以将按钮居中显示在屏幕底部。

当按钮被按下时,它会发出一个信号。在 StartButton 中连接 pressed 信号:

func _on_start_button_pressed():
    $StartButton.hide()
    $Message.hide()
    start_game.emit()

游戏结束

您的 UI 脚本的最终任务是响应游戏结束:

func show_game_over():
    show_message("Game Over")
    await $Timer.timeout
    $StartButton.show()
    $Message.text = "Coin Dash!"
    $Message.show()

在此函数中,您需要 show_message("Game Over") 执行的操作。然而,一旦消息消失,您希望显示开始按钮和游戏标题。await 命令暂停函数的执行,直到给定的节点(Timer)发出给定的信号(timeout)。一旦接收到信号,函数将继续,一切将恢复到初始状态,以便您可以再次游戏。

将 HUD 添加到 Main

下一个任务是设置 MainHUD 之间的通信。将 HUD 的实例添加到 Main 中。在 Main 中,连接 GameTimertimeout 信号,并添加以下内容,以便每次 GameTimer 超时(每秒)时,剩余时间都会减少:

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

接下来,选择 Main 中的 Player 实例,并连接其 pickuphurt 信号:

func _on_player_hurt():
    game_over()
func _on_player_pickup():
    score += 1
    $HUD.update_score(score)

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

func game_over():
    playing = false
    $GameTimer.stop()
    get_tree().call_group("coins", "queue_free")
    $HUD.show_game_over()
    $Player.die()

此函数停止游戏并使用 call_group() 通过对每个剩余的硬币调用 queue_free() 来移除所有剩余的硬币。

最后,按下 StartButton 需要激活 Mainnew_game() 函数。选择 HUD 的实例,并连接其 start_game 信号:

func _on_hud_start_game():
    new_game()

确保您已从 Main_ready() 函数中删除 new_game()(记住,那只是为了测试),并将这两行添加到 new_game() 中:

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

现在,你可以玩游戏了!确认所有部分都按预期工作——得分、倒计时、游戏结束和重新开始等。如果你发现某个部分没有正常工作,请返回并检查你创建它的步骤,以及可能将其连接到游戏其他部分的步骤。一个常见的错误是忘记连接你在游戏不同部分使用的许多信号之一。

一旦你玩过游戏并确认一切正常工作,你就可以继续到下一部分,在那里你可以添加一些额外的功能来完善游戏体验。

第五部分 - 收尾

恭喜你创建了一个完整、可工作的游戏!在本节中,你将向游戏中添加一些额外的东西,使其更加有趣。游戏开发者使用术语juice来描述使游戏感觉好玩的事物。juice 可以包括声音、视觉效果或任何其他增加玩家享受的东西,而无需改变游戏玩法本身。

视觉效果

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

什么是 tween?

tween是一种使用特定的数学函数在时间上逐渐改变某个值的方法。例如,你可能选择一个稳定改变值的函数,或者一个开始缓慢但逐渐加速的函数。tweening 有时也被称为easing。你可以在 https://easings.net/看到许多 tweening 函数的动画示例。

在 Godot 中使用 tween 时,你可以将其分配给改变一个或多个节点的属性。在这种情况下,你将增加硬币的缩放,并使用Modulate属性使其淡出。一旦 tween 完成其工作,硬币将被删除。

然而,有一个问题。如果我们不立即移除硬币,那么玩家可能再次移动到硬币上——触发area_entered信号第二次,并注册为第二次拾取。为了防止这种情况,你可以禁用碰撞形状,这样硬币就不能触发任何进一步的碰撞。

你新的pickup()函数应该看起来像这样:

func pickup():
    $CollisionShape2d.set_deferred("disabled", true)
    var tw = create_tween().set_parallel().
        set_trans(Tween.TRANS_QUAD)
    tw.tween_property(self, "scale", scale * 3, 0.3)
    tw.tween_property(self, "modulate:a", 0.0, 0.3)
    await tw.finished
    queue_free()

这需要很多新的代码,所以让我们来分解一下:

首先,CollisionShape2Ddisabled属性需要设置为true。然而,如果你直接尝试设置它,Godot 会抱怨。在碰撞正在处理时,不允许更改物理属性;你必须等待当前帧的结束。这就是set_deferred()的作用。

接下来,create_tween()创建一个 tween 对象,set_parallel()表示任何后续的 tween 都应该同时发生,而不是一个接一个地发生,set_trans()将过渡函数设置为“二次”曲线。

之后是两行设置属性缓动的代码。tween_property()函数接受四个参数——要影响的对象(self)、要更改的属性、结束值和持续时间(以秒为单位)。

现在,当你运行游戏时,你应该看到硬币在被拾取时播放效果。

声音

声音是游戏设计中重要但常被忽视的部分。良好的声音设计可以在非常小的努力下为你的游戏增添大量活力。声音可以给玩家提供反馈,将他们与角色情感上联系起来,甚至可以是游戏玩法的一部分(“你听到背后有脚步声”)。

对于这个游戏,你将添加三个声音效果。在Main场景中,添加三个AudioStreamPlayer节点,并分别命名为CoinSoundLevelSoundEndSound。将每个声音从res://assets/audio/文件夹拖放到相应节点的Stream属性中。

要播放声音,你需要在节点上调用play()函数。将以下每一行添加到适当的时间以播放声音:

  • _on_player_pickup()中调用$CoinSound.play()

  • game_over()中调用$EndSound.play()

  • spawn_coins()中调用$LevelSound.play()(但不要在循环内!)

加速道具

有很多种对象可以为玩家提供小的优势或加速道具。在本节中,你将添加一个加速道具项目,当收集时会给玩家一小段时间奖励。它将偶尔短暂出现,然后消失。

新场景将与你已经创建的Coin场景非常相似,因此点击你的Coin场景,选择powerup.tscn。将根节点的名称更改为Powerup,并通过点击分离脚本按钮——<****IMG>移除脚本。

通过点击垃圾桶按钮在coins组中删除,并添加一个名为powerups的新组。

AnimatedSprite2D中,将硬币的图像更改为加速道具,你可以在res://assets/pow/文件夹中找到它。

点击添加新脚本,并从coin.gd脚本中复制代码。

接下来,添加一个名为LifetimeTimer节点。这将限制对象在屏幕上停留的时间。将其2timeout信号都设置为 2,以便在时间周期结束时移除加速道具:

func _on_lifetime_timout():
    queue_free()

现在,转到你的Main场景,并添加另一个名为PowerupTimerTimer节点。在audio文件夹中设置其Powerup.wav声音,你可以通过另一个AudioStreamPlayer添加。连接timeout信号,并添加以下代码以生成加速道具:

func _on_powerup_timer_timeout():
    var p = powerup_scene.instantiate()
    add_child(p)
    p.screensize = screensize
    p.position = Vector2(randi_range(0, screensize.x),
        randi_range(0, screensize.y))

Powerup场景需要与一个变量链接,就像你与Coin场景所做的那样,因此在main.gd顶部添加以下行,然后将powerup.tscn拖放到新属性中:

@export var powerup_scene : PackedScene

加速道具应该随机出现,因此每次开始新关卡时都需要设置PowerupTimer的等待时间。在用spawn_coins()生成新硬币后,将以下代码添加到_process()函数中:

现在,你将看到道具出现;最后一步是给玩家收集它们的能力。目前,玩家脚本假设它遇到的是硬币或障碍物。将 player.gd 中的代码更改以检查被击中的对象类型:

func _on_area_entered(area):
    if area.is_in_group("coins"):
        area.pickup()
        pickup.emit("coin")
    if area.is_in_group("powerups"):
        area.pickup()
        pickup.emit("powerup")
    if area.is_in_group("obstacles"):
        hurt.emit()
        die()

注意,现在你使用额外的参数来发射 pickup 信号,该参数命名了对象的类型。main.gd 中的相应函数现在必须更改以接受该参数并决定采取什么行动:

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

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

尝试运行游戏并收集道具(记住,它不会出现在第 1 关)。确保播放声音,计时器增加五秒。

硬币动画

当你创建硬币时,你使用了 AnimatedSprite2D,但它还没有播放。硬币动画显示一个“闪烁”效果,在硬币的表面上移动。如果所有硬币同时显示这个效果,看起来会太规律,所以每个硬币的动画都需要一个小的随机延迟。

首先,点击 AnimatedSprite2D,然后点击 SpriteFrames 资源。确保 动画循环 设置为 关闭速度 设置为 12 FPS

图 2.28:动画设置

图 2.28:动画设置

Timer 节点添加到 Coin 场景中,然后将其添加到硬币的脚本中:

func _ready():
    $Timer.start(randf_range(3, 8))

然后,连接 Timertimeout 信号并添加以下内容:

func _on_timer_timeout():
    $AnimatedSprite2d.frame = 0
    $AnimatedSprite2d.play()

尝试运行游戏并观察硬币的动画。这需要非常小的努力就能产生一个很好的视觉效果,至少对程序员来说是这样——艺术家必须绘制所有这些帧!你会在专业游戏中注意到很多这样的效果。虽然很微妙,但视觉吸引力使得游戏体验更加愉悦。

障碍物

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

图 2.29:带有障碍物的示例游戏

图 2.29:带有障碍物的示例游戏

创建一个新的 Area2D 场景并将其命名为 Cactus。给它添加 Sprite2DCollisionShape2D 子节点。从 Sprite2D 拖动仙人掌纹理。将 RectangleShape2D 添加到碰撞形状中,并调整其大小以覆盖图像。记得你在玩家代码中添加了 if area.is_in_group("obstacles"?) 吗?使用 节点 选项卡将 Cactus 添加到 obstacles 组。玩玩游戏,看看撞到仙人掌会发生什么。

你可能已经发现了问题——硬币可以出现在仙人掌上,这使得它们无法被捡起。当硬币放置时,如果它检测到与障碍物重叠,则需要移动。在 Coin 场景中,连接其 area_entered 信号并添加以下内容:

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

如果你从上一节添加了 Powerup 对象,你需要在它的脚本中也做同样的事情。

玩这个游戏,并测试对象是否正确生成,以及它们是否与障碍物重叠。撞到障碍物应该结束游戏。

你觉得这个游戏是具有挑战性还是容易?在进入下一章之前,花点时间思考一下你可能添加到这个游戏中的其他元素。尝试使用你到目前为止所学到的知识,看看你是否能够添加它们。如果不能,把它们写下来,稍后再回来,在你学习了下一章中的一些更多技术之后。

摘要

在这一章中,你通过创建一个小型 2D 游戏学习了 Godot 引擎的基础知识。你设置了一个项目并创建了多个场景,与精灵和动画一起工作,捕捉用户输入,使用信号在节点之间进行通信,并创建了一个用户界面。你在这一章中学到的知识是你在任何 Godot 项目中都会用到的关键技能。

在进入下一章之前,回顾一下项目。你知道每个节点的作用吗?有没有你不理解的代码片段?如果有,回到并复习那一章节。

此外,你也可以自由地尝试这个游戏并改变一些东西。了解游戏不同部分功能的一个最好的方法就是改变它们,看看会发生什么。

记得第一章中的提示吗?如果你真的想快速提高你的技能,关闭这本书,开始一个新的 Godot 项目,并尝试再次制作Coin Dash,不要偷看。如果你不得不看书,那没关系,但尽量只在尝试自己解决问题之后再查找东西。

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

第三章:空间岩石:使用物理构建 2D 街机经典游戏

到现在为止,你应该已经对在 Godot 中工作感到更加舒适:添加节点、创建脚本、在检查器中修改属性等等。如果你发现自己遇到了困难或者感觉不记得如何做某事,你可以回到最初解释该项目的项目。随着你在 Godot 中重复执行更常见的操作,它们将变得越来越熟悉。同时,每一章都会向你介绍更多节点和技术,以扩展你对 Godot 功能的理解。

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

图 3.1:空间岩石截图

图 3.1:空间岩石截图

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

  • 使用自定义输入操作

  • 使用RigidBody2D进行物理运算

  • 使用有限状态机组织游戏逻辑

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

  • 音频和音乐

  • 粒子效果

技术要求

从以下链接下载游戏资源,并将其解压缩到你的新项目文件夹中:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Downloads

你也可以在 GitHub 上找到本章的完整代码:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Chapter03%20-%20Space%20Rocks

设置项目

创建一个新的项目,并从以下 URL 下载项目资源:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Downloads.

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

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

你需要创建四个新的输入动作:rotate_leftrotate_rightthrustshoot。将每个动作的名称输入到添加新动作框中,然后按Enter键或点击添加按钮。确保你输入的名称与显示的完全一致,因为它们将在后面的代码中使用。

然后,对于每个动作,点击其右侧的+按钮。在弹出的窗口中,你可以手动选择特定的输入类型,或者按物理按钮,Godot 将检测它。你可以为每个动作添加多个输入。例如,为了允许玩家使用箭头键和 WASD 键,设置将看起来像这样:

图 3.2:输入动作

图 3.2:输入动作

如果你将游戏手柄或其他控制器连接到你的电脑,你也可以以相同的方式将其输入添加到动作中。

注意

在这个阶段,我们只考虑按钮式输入,所以虽然你将能够在这个项目中使用 D-pad,但使用模拟摇杆将需要更改项目的代码。

刚体物理

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

Godot 提供了三种类型的物理体,它们被归类在PhysicsBody2D节点类型下:

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

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

  • CharacterBody2D:这种类型的物体提供碰撞检测,但没有物理属性。所有运动都必须在代码中实现,你必须自己实现任何碰撞响应。运动学体通常用于玩家角色或其他需要街机风格物理而不是真实模拟的演员,或者当你需要更精确地控制物体移动时。

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

在这个项目中,你将使用RigidBody2D节点来控制船只以及岩石本身。你将在后面的章节中学习其他类型的物体。

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

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

图 3.3:项目物理设置

图 3.3:项目物理设置

在大多数情况下,你不需要修改这些设置。但是,请注意,默认情况下,重力值为 980,方向为 (0, 1),即向下。如果你想改变世界的重力,你可以在这里进行更改。

如果你点击 项目设置 窗口右上角的 高级设置 切换按钮,你将看到许多物理引擎的高级配置值。你应该特别注意其中的两个:默认线性阻尼默认角阻尼。这些属性分别控制物体失去前进速度和旋转速度的快慢。将它们设置为较低的值会使世界感觉没有摩擦,而使用较大的值会使物体移动时感觉像穿过泥浆。这可以是一种很好的方式,将不同的运动风格应用于各种游戏对象和环境。

区域物理覆盖

Area2D 节点也可以通过使用它们的 Space Override 属性来影响刚体物理。然后,将应用自定义的重力和阻尼值到进入该区域的任何物体上。

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

这就完成了项目设置任务。回顾这一节并确保你没有遗漏任何内容是个好主意,因为你在这里所做的更改将影响许多游戏对象的行为。你将在下一节中看到这一点,那时你将制作玩家的飞船。

玩家的飞船

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

图 3.4:玩家的飞船

图 3.4:玩家的飞船

身体和物理设置

创建一个新的场景,并添加一个名为 PlayerRigidBody2D 作为根节点,带有 Sprite2DCollisionShape2D 子节点。将 res://assets/player_ship.png 图像添加到 Sprite2D。飞船图像相当大,所以将 Sprite2D 设置为 (0.5, 0.5)90

图 3.5:玩家精灵设置

图 3.5:玩家精灵设置

精灵方向

飞船的图像是向上绘制的。在 Godot 中,0 度的旋转指向右侧(沿 x 轴)。这意味着你需要旋转精灵,使其与身体的朝向相匹配。如果你使用正确方向的绘画艺术,你可以避免这一步。然而,发现向上方向的绘画艺术是非常常见的,所以你应该知道该怎么做。

CollisionShape2D 中添加一个 CircleShape2D 并将其缩放以尽可能紧密地覆盖图像。

图 3.6:玩家碰撞形状

图 3.6:玩家碰撞形状

玩家飞船以像素艺术风格绘制,但如果你放大查看,可能会注意到它看起来非常模糊和“平滑”。Godot 默认的纹理绘制过滤器设置使用这种平滑技术,这对于某些艺术作品来说看起来不错,但对于像素艺术通常是不想要的。你可以在每个精灵(在 CanvasItem 部分中)上单独设置过滤器,或者你可以在 项目设置 中全局设置。

打开 项目设置 并检查 高级设置 开关,然后找到 渲染/纹理 部分。在底部附近,你会看到两个 Canvas Textures 设置。将 默认纹理过滤器 设置为 最近邻

图 3.7:默认纹理过滤器设置

图 3.7:默认纹理过滤器设置

保存场景。在处理更大规模的项目时,建议根据每个游戏对象将场景和脚本组织到文件夹中,而不是将它们全部保存在根项目文件夹中。例如,如果你创建一个“玩家”文件夹,你可以将所有与玩家相关的文件保存在那里。这使得查找和修改你的各种游戏对象变得更加容易。虽然这个项目相对较小——你将只有几个场景——但随着项目规模和复杂性的增加,养成这种习惯是很好的。

状态机

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

程序员处理此类情况的一种常见方式是在代码中添加布尔变量,或 标志。例如,当玩家首次生成时,将 invulnerable 标志设置为 true,或者当玩家死亡时,将 alive 设置为 false。然而,当由于某种原因同时将 aliveinvulnerable 都设置为 false 时,这可能会导致错误和奇怪的情况。在这种情况下,如果一块石头撞击玩家会发生什么?如果飞船只能处于一个明确定义的状态,那就更好了。

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

以下图显示了玩家飞船的 FSM:

图 3.8:状态机图

图 3.8:状态机图

有四个状态,由椭圆形表示,箭头指示状态之间可以发生什么转换,以及什么触发转换。通过检查当前状态,你可以决定玩家被允许做什么。例如,在死亡状态中,不允许输入,或者在无敌状态中,允许移动但不允许射击。

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

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

extends RigidBody2D
enum {INIT, ALIVE, INVULNERABLE, DEAD}
var state = INIT

上一段代码中的enum语句等同于编写以下代码:

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

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

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

每当你需要更改玩家的状态时,你将调用change_state()函数并传递新状态的价值。然后,通过使用match语句,你可以执行伴随新状态转换的任何代码,或者如果你不希望发生该转换,则禁止它。为了说明这一点,CollisionShape2D节点将由新状态启用/禁用。在_ready()中,我们设置ALIVE为初始状态——这是为了测试,但稍后我们将将其更改为INIT

添加玩家控制

在脚本的顶部添加以下变量:

@export var engine_power = 500
@export var spin_power = 8000
var thrust = Vector2.ZERO
var rotation_dir = 0

engine_powerspin_power控制飞船加速和转向的速度。thrust代表引擎施加的力:当滑行时为(0, 0),当引擎开启时为一个指向前方的向量。rotation_dir表示飞船转向的方向,以便你可以施加一个扭矩或旋转力。

如我们之前在15中看到的。你可以稍后调整它们以改变飞船的处理方式。

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

func _process(delta):
    get_input()
func get_input():
    thrust = Vector2.ZERO
    if state in [DEAD, INIT]:
        return
    if Input.is_action_pressed("thrust"):
        thrust = transform.x * engine_power
    rotation_dir = Input.get_axis("rotate_left",
        "rotate_right")
func _physics_process(delta):
    constant_force = thrust
    constant_torque = rotation_dir * spin_power

get_input()函数捕获按键动作并设置飞船的推力开启或关闭。请注意,推力的方向基于身体的transform.x,它始终代表身体的“前进”方向(参见附录以获取变换的概述)。

Input.get_axis()根据两个输入返回一个值,代表负值和正值。因此,rotation_dir将表示顺时针、逆时针或零,具体取决于两个输入动作的状态。

最后,当使用物理体时,它们的运动和相关函数应该始终在_physics_process()中调用。在这里,你可以应用由输入设置的力,以实际移动物体。

播放场景,你应该能够自由地飞来飞去。

屏幕环绕

经典 2D 街机游戏的一个特点是屏幕环绕。如果玩家离开屏幕的一侧,他们就会出现在另一侧。在实践中,你通过瞬间改变其位置来将飞船传送到另一侧。你需要知道屏幕的大小,所以请将以下变量添加到脚本顶部:

var screensize = Vector.ZERO

并将其添加到_ready()

screensize = get_viewport_rect().size

之后,你可以让游戏的主脚本处理设置所有游戏对象的screensize,但就目前而言,这将允许你仅通过玩家的场景来测试屏幕环绕效果。

当首次接触这个问题时,你可能认为可以使用物体的position属性,如果它超出了屏幕的边界,就将其设置为对面的边。如果你使用任何其他节点类型,那将工作得很好;然而,当使用RigidBody2D时,你不能直接设置position,因为这会与物理引擎正在计算的运动发生冲突。一个常见的错误是尝试添加如下内容:

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

如果你想在Coin Dash中的Area2D尝试这个,它将完美地工作。在这里,它将失败,将玩家困在屏幕边缘,并在角落处出现不可预测的故障。那么,答案是什么?

引用RigidBody2D文档:

注意:你不应该在每一帧或非常频繁地更改 RigidBody2D 的positionlinear_velocity。如果你需要直接影响物体的状态,请使用_integrate_forces,这允许你直接访问物理状态。

并且在_integrate_forces()的描述中:

(它)允许你读取并安全地修改对象的模拟状态。如果你需要直接更改物体的位置或其他物理属性,请使用此方法代替_physics_process

因此,答案是当你想直接影响刚体的位置时使用这个单独的函数。使用_integrate_forces()让你可以访问物体的PhysicsDirectBodyState2D – 一个包含大量关于物体当前状态的有用信息的 Godot 对象。由于你想改变物体的位置,这意味着你需要修改它的Transform2D

Transform2Dorigin属性。

使用这些信息,你可以通过添加以下代码来实现环绕效果:

func _integrate_forces(physics_state):
    var xform = physics_state.transform
    xform.origin.x = wrapf(xform.origin.x, 0, screensize.x)
    xform.origin.y = wrapf(xform.origin.y, 0, screensize.y)
    physics_state.transform = xform

wrapf()函数接受一个值(第一个参数)并将其“环绕”在你选择的任何最小/最大值之间。所以,如果值低于0,它就变成screensize.x,反之亦然。

注意,你使用的是physics_state作为参数名,而不是默认的state。这是为了避免混淆,因为state已经被用来跟踪玩家的状态。

再次运行场景,并检查一切是否按预期工作。确保你尝试在所有四个方向上环绕。

射击

现在是给你的船装备一些武器的时候了。当按下 shoot 动作时,一个子弹/激光应该出现在船的前端,然后沿直线飞行,直到飞出屏幕。玩家在经过一小段时间后(也称为 冷却时间)才能再次射击。

子弹场景

这是子弹的节点设置:

  • Area2D 命名为 Bullet

    • Sprite2D

    • CollisionShape2D

    • VisibleOnScreenNotifier2D

使用从资源文件夹 res://assets/laser.pngSprite2D 和一个 CapsuleShape2D 作为碰撞形状。你需要将 CollisionShape2D 设置为 90 以确保它正确对齐。你还应该将 Sprite2D 缩小到大约一半的大小:(``0.5, 0.5)

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

extends Area2D
@export var speed = 1000
var velocity = Vector2.ZERO
func start(_transform):
    transform = _transform
    velocity = transform.x * speed
func _process(delta):
    position += velocity * delta

每当你生成一个新的子弹时,你将调用 start() 函数。通过传递一个变换,你可以给它正确的位置和旋转——通常是船的炮口(关于这一点稍后会有更多介绍)。

VisibleOnScreenNotifier2D 是一个节点,每当一个节点变为可见或不可见时,它会通过一个信号通知你。你可以使用这个功能来自动删除飞出屏幕的子弹。连接节点的 screen_exited 信号并添加以下内容:

func _on_visible_on_screen_notifier_2d_screen_exited():
    queue_free()

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

func _on_bullet_body_entered(body):
    if body.is_in_group("rocks"):
        body.explode()
        queue_free()

发射子弹

下一步是在玩家按下 shoot 动作时创建 Bullet 场景的实例。然而,如果你把子弹变成玩家的子节点,那么它会随着玩家移动和旋转,而不是独立移动。你可以使用 get_parent().add_child() 将子弹添加到主场景中,因为当游戏运行时,Main 场景将是玩家的父节点。但是,这意味着你将无法单独运行和测试 Player 场景。或者,如果你决定重新排列你的 Main 场景,使玩家成为某个其他节点的子节点,子弹就不会出现在你期望的位置。

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

在任何情况下,SceneTree 总是存在的,对于这个游戏来说,将子弹作为树的根节点(即包含游戏的 Window)的子节点是完全可以的。

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

接下来,添加一个Timer节点并将其命名为GunCooldown。这将给枪提供冷却时间,防止在经过一定时间后发射新的子弹。勾选One ShotAutostart复选框以“开启”。

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

@export var bullet_scene : PackedScene
@export var fire_rate = 0.25
var can_shoot = true

bullet.tscn文件拖放到检查器中新的Bullet属性。

将此行添加到_ready()中:

$GunCooldown.wait_time = fire_rate

并将此添加到get_input()中:

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

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

func shoot():
    if state == INVULNERABLE:
        return
    can_shoot = false
    $GunCooldown.start()
    var b = bullet_scene.instantiate()
    get_tree().root.add_child(b)
    b.start($Muzzle.global_transform)

射击时,首先将can_shoot设置为false,这样动作就不会再调用shoot()。然后,将新子弹作为场景树根节点的子节点添加。最后,调用子弹的start()函数,并给它提供枪口节点的全局变换。注意,如果你在这里使用transform,你会给出相对于玩家的枪口位置(记住是(50, 0)),因此子弹会在完全错误的位置生成。这是理解局部和全局坐标之间区别重要性的另一个例子。

为了允许枪再次射击,连接GunCooldowntimeout信号:

func _on_gun_cooldown_timeout():
    can_shoot = true

测试玩家的飞船

创建一个新的场景,使用名为MainNode,并添加一个名为BackgroundSprite2D作为子节点。在Player场景中使用res://assets/space_background.png

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

现在玩家的飞船工作正常,是时候暂停并检查你的理解了。与刚体一起工作可能会有点棘手;花几分钟时间实验本节中的一些设置和代码。只是确保在进入下一节之前将它们改回原样,下一节你将添加小行星到游戏中。

添加岩石

游戏的目标是摧毁漂浮的太空岩石,所以现在你可以射击了,是时候添加它们了。像飞船一样,岩石将使用RigidBody2D,这将使它们以恒定的速度直线运动,除非受到干扰。它们还会以逼真的方式相互弹跳。为了使事情更有趣,岩石将开始时很大,当你射击它们时,会分裂成多个更小的岩石。

场景设置

创建一个新的场景,使用名为RockRigidBody2D节点,并添加一个使用res://assets/rock.png纹理的Sprite2D子节点。添加一个CollisionShape2D,但不要设置其形状。因为你会生成不同大小的岩石,所以碰撞形状需要在代码中设置并调整到正确的大小。

你不希望岩石滑行到停止,所以它们需要忽略默认的线性和角阻尼。将两个都设置为0New PhysicsMaterial,然后点击它以展开。设置显示为1

变量大小岩石

将脚本附加到Rock上并定义成员变量:

extends RigidBody2D
var screensize = Vector2.ZERO
var size
var radius
var scale_factor = 0.2

脚本将处理生成新岩石,包括在关卡开始时以及在大岩石爆炸后出现的较小岩石。一个大岩石将有一个大小为3,分解成大小为2的岩石,依此类推。scale_factor乘以size来设置Sprite2D缩放、碰撞半径等。你可以稍后调整它来改变每个岩石类别的尺寸。

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

func start(_position, _velocity, _size):
    position = _position
    size = _size
    mass = 1.5 * size
    $Sprite2D.scale = Vector2.ONE * scale_factor * size
    radius = int($Sprite2D.texture.get_size().x / 2 *
        $Sprite2D.scale.x)
    var shape = CircleShape2D.new()
    shape.radius = radius
    $CollisionShape2d.shape = shape
    linear_velocity = _velocity
    angular_velocity = randf_range(-PI, PI)

这是你根据岩石的size计算正确碰撞大小的地方。请注意,由于positionsize已经被用作类变量,你可以使用下划线作为函数的参数以防止冲突。

岩石也需要像玩家一样绕屏幕滚动,所以使用相同的技术与_integrate_forces()

func _integrate_forces(physics_state):
    var xform = physics_state.transform
    xform.origin.x = wrapf(xform.origin.x, 0 - radius,
        screensize.x + radius)
    xform.origin.y = wrapf(xform.origin.y, 0 - radius,
        screensize.y + radius)
    physics_state.transform = xform

这里的一个区别是,将岩石的radius包含在计算中会导致看起来更平滑的传送效果。岩石看起来会完全退出屏幕,然后进入对面。你可能也想用同样的方法处理玩家的飞船。试试看,看看你更喜欢哪一个。

实例化岩石

当生成新的岩石时,主场景需要选择一个随机的起始位置。为此,你可以使用一些数学方法来选择屏幕边缘的随机点,但相反,你可以利用另一种 Godot 节点类型。你将在屏幕边缘绘制一个路径,脚本将选择该路径上的一个随机位置。

场景中,添加一个Path2D节点并将其命名为RockPath。当你选择该节点时,你将在编辑器窗口的顶部看到一些新的按钮:

图 3.9:路径绘制工具

图 3.9:路径绘制工具

选择中间的(添加点)通过点击以下截图所示的点来绘制路径。为了使点对齐,请确保使用网格吸附被勾选。此选项位于编辑器窗口顶部的图标栏中:

图 3.10:启用网格吸附

图 3.10:启用网格吸附

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

图 3.11:路径绘制顺序

图 3.11:路径绘制顺序

如果你选择了RockPath,请不要再次在编辑器窗口中点击!如果你这样做,你会在曲线上添加额外的点,你的岩石可能不会出现在你想要的位置。你可以按Ctrl + Z来撤销你可能添加的任何额外点。

现在路径已经定义,将 PathFollow2D 添加为 RockPath 的子节点,并命名为 RockSpawn。此节点的目的是使用其 Progress 属性自动沿着其父路径移动,该属性表示路径上的偏移量。偏移量越高,它沿着路径移动得越远。由于我们的路径是闭合的,如果偏移量值大于路径长度,它也会循环。

将以下脚本添加到 Main.gd

extends Node
@export var rock_scene : PackedScene
var screensize = Vector2.ZERO
func _ready():
    screensize = get_viewport().get_visible_rect().size
    for i in 3:
        spawn_rock(3)

你首先获取 screensize,以便在石头生成时传递给它。然后,生成三个大小为 3 的石头。别忘了将 rock.tscn 拖到 Rock 属性上。

这里是 spawn_rock() 函数:

func spawn_rock(size, pos=null, vel=null):
    if pos == null:
        $RockPath/RockSpawn.progress = randi()
        pos = $RockPath/RockSpawn.position
    if vel == null:
        vel = Vector2.RIGHT.rotated(randf_range(0, TAU)) *
            randf_range(50, 125)
    var r = rock_scene.instantiate()
    r.screensize = screensize
    r.start(pos, vel, size)
    call_deferred("add_child", r)

此函数有两个作用。当只调用一个 size 参数时,它会在 RockPath 上选择一个随机位置和一个随机速度。然而,如果提供了这些值,它将使用它们。这将允许你通过指定它们的属性在爆炸位置生成较小的石头。

运行游戏后,你应该看到三块石头在周围漂浮,但你的子弹对它们没有影响。

爆炸石头

子弹检查 rocks 组中的身体,所以在 Rock 场景中,选择 rocks 并点击 Add

图 3.12:添加“rocks”组

图 3.12:添加“rocks”组

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

  • 移除石头

  • 播放爆炸动画

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

爆炸场景

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

  • Sprite2D 命名为 Explosion

  • AnimationPlayer

对于 Sprite2D 节点的 res://assets/explosion.png。你会注意到这是一个 Sprite2D 节点,它支持使用它们。

在检查器中,找到精灵的 8。这将把精灵图集切成 64 个单独的图像。你可以通过更改 063 来验证这一点。确保在继续之前将其设置回 0

图 3.13:精灵动画设置

图 3.13:精灵动画设置

AnimationPlayer 节点可以用来动画化任何节点的任何属性。你将使用它来随时间改变 Frame 属性。首先选择节点,你会在底部打开 Animation 面板:

图 3.14:动画面板

图 3.14:动画面板

点击 explosion。设置 0.640.01。选择 Sprite2D 节点,你会注意到检查器中现在每个属性旁边都有一个键符号。点击一个键将在当前动画中创建一个 关键帧

图 3.15:动画时间设置

图 3.15:动画时间设置

点击 Explosion 节点的 AnimationPlayer 旁边的键,在时间 0 时,你想让精灵的 0

滑动刮擦器到时间0.64(如果看不到,可以使用滑块调整缩放)。设置63并再次点击键。现在动画知道在动画的最终时间使用最后一张图像。然而,你还需要让AnimationPlayer知道你希望在两个点之间的时间使用所有中间值。在动画轨道的右侧有一个更新模式下拉菜单。它目前设置为离散,你需要将其更改为连续

图 3.16:设置更新模式

图 3.16:设置更新模式

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

你现在可以将爆炸添加到岩石上。在Rock场景中,添加一个Explosion实例,并点击节点旁边的眼睛图标使其隐藏。将此行添加到start()中:

$Explosion.scale = Vector2.ONE * 0.75 * size

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

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

func explode():
    $CollisionShape2D.set_deferred("disabled", true)
    $Sprite2d.hide()
    $Explosion/AnimationPlayer.play("explosion")
    $Explosion.show()
    exploded.emit(size, radius, position, linear_velocity)
    linear_velocity = Vector2.ZERO
    angular_velocity = 0
    await $Explosion/AnimationPlayer.animation_finished
    queue_free()

在这里,你隐藏岩石并播放爆炸,等待爆炸完成后才移除岩石。当你发出exploded信号时,你还包括所有岩石的信息,这样Main中的spawn_rock()就可以在相同的位置生成较小的岩石。

测试游戏并确认在射击岩石时可以看到爆炸效果。

生成较小的岩石

Rock场景正在发出信号,但Main还没有监听它。你无法在spawn_rock()中连接信号:

r.exploded.connect(self._on_rock_exploded)

这将把岩石的信号连接到Main中的一个函数,你也需要创建它:

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

在这个函数中,除非刚刚被摧毁的岩石大小为1(最小尺寸),否则你将创建两个新的岩石。offset循环变量确保两个新的岩石向相反方向移动(即,一个的速度将是负值)。dir变量找到玩家和岩石之间的向量,然后使用orthogonal()得到一个垂直的向量。这确保了新的岩石不会直接飞向玩家。

图 3.17:爆炸图

图 3.17:爆炸图

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

这是一个很好的地方停下来回顾你已经做了什么。你已经完成了游戏的所有基本功能:玩家可以飞行并射击;岩石漂浮、弹跳和爆炸;并且会生成新的岩石。你现在应该对使用刚体感到更加自在。在下一节中,你将开始构建界面,允许玩家开始游戏并在游戏过程中查看重要信息。

创建用户界面

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

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

  • 开始按钮

  • 状态信息(例如“准备”或“游戏结束”)

  • 得分

  • 生命值计数器

这里是你将要制作的内容预览:

图 3.18:UI 布局

图 3.18:UI 布局

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

布局

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

按照以下步骤构建布局:

  1. 首先添加 TimerMarginContainer 子节点,它们将包含得分和生命值计数器。在 布局 下拉菜单中,选择 Top Wide

图 3.19:Top Wide 控件对齐

图 3.19:Top Wide 控件对齐

  1. 在检查器中,将 主题覆盖/常量 中的四个边距设置为 20。

  2. Timer 设置为开启并设置为 2

  3. 作为容器的子节点,添加一个 HBoxContainer,它将把得分计数器放在左边,生命值计数器放在右边。在这个容器下,添加一个 Label(命名为 ScoreLabel)和一个 HBoxContainer(命名为 LivesCounter)。

ScoreLabel 的值设置为 0,并在 res://assets/kenvector_future_thin.ttf 下设置字体大小为 64

  1. 选择 LivesCounter 并设置 20,然后添加一个子节点 TextureRect 并命名为 L1。将 res://assets/player_small.png 拖到选中的 L1 节点,并按 duplicate (Ctrl + D) 键两次以创建 L2L3(它们将被自动命名)。在游戏过程中,HUD 将显示或隐藏这三个纹理,以指示玩家剩余的生命值。

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

  3. 作为 HUD 的子节点,添加一个 VBoxContainer,并在其中添加一个名为 MessageLabel 和一个名为 StartButtonTextureButton。将 VBoxContainer 的布局设置为 100

  4. res://assets文件夹中,有两个StartButton的纹理,一个是正常的(play_button.png),另一个是在鼠标悬停时显示的('play_button_h.png')。将这些拖到检查器的Textures/NormalTextures/Hover中。将按钮的布局/容器大小/水平设置为收缩居中,这样它就会在水平方向上居中。

  5. Message文本设置为“Space Rocks!”,并使用与ScoreLabel相同的设置来设置其字体。将水平对齐设置为居中

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

图 3.20:HUD 节点布局

图 3.20:HUD 节点布局

编写 UI 脚本

你已经完成了 UI 布局,现在向HUD添加一个脚本。由于你将需要引用的节点位于容器下,你可以在开始时将这些节点的引用存储在变量中。由于这需要在节点添加到树之后发生,你可以使用@onready装饰器来使变量的值在_ready()函数运行时同时设置。

extends CanvasLayer
signal start_game
@onready var lives_counter = $MarginContainer/HBoxContainer/LivesCounter.get_children()
@onready var score_label = $MarginContainer/HBoxContainer/ScoreLabel
@onready var message = $VBoxContainer/Message
@onready var start_button = $VBoxContainer/StartButton

当玩家点击StartButton时,你会发出start_game信号。lives_counter变量是一个包含三个生命计数器图像引用的数组,这样它们就可以根据需要隐藏/显示。

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

func show_message(text):
    message.text = text
    message.show()
    $Timer.start()
func update_score(value):
    score_label.text = str(value)
func update_lives(value):
    for item in 3:
        lives_counter[item].visible = value > item

Main将在相关值更改时调用这些函数。现在添加一个处理游戏结束的函数:

func game_over():
    show_message("Game Over")
    await $Timer.timeout
    start_button.show()

StartButtonpressed信号和Timertimeout信号连接起来:

func _on_start_button_pressed():
    start_button.hide()
    start_game.emit()
func _on_timer_timeout():
    message.hide()
    message.text = ""

主场景的 UI 代码

HUD场景的一个实例添加到Main场景中。将这些变量添加到main.gd中:

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

以及一个处理开始新游戏的函数:

func new_game():
    # remove any old rocks from previous game
    get_tree().call_group("rocks", "queue_free")
    level = 0
    score = 0
    $HUD.update_score(score)
    $HUD.show_message("Get Ready!")
    $Player.reset()
    await $HUD/Timer.timeout
    playing = true

注意到$Player.reset()这一行——别担心,你很快就会添加它。

当玩家摧毁所有岩石时,他们会进入下一级:

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

你会在每次关卡变化时调用这个函数。它宣布关卡编号,并生成与数量相匹配的岩石。注意,由于你初始化level0,这会将它设置为1,用于第一个关卡。你还应该删除_ready()中生成岩石的代码——你不再需要它了。

为了检测关卡何时结束,你需要检查还剩下多少岩石:

func _process(delta):
    if not playing:
        return
    if get_tree().get_nodes_in_group("rocks").size() == 0:
        new_level()

接下来,你需要将HUDstart_game信号连接到Mainnew_game()函数。

Main中选择HUD实例,并在Main中找到其start_game信号,然后你可以选择new_game()函数:

图 3.21:将信号连接到现有函数

图 3.21:将信号连接到现有函数

添加这个函数来处理游戏结束时会发生什么:

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

玩家代码

player.gd添加新的信号和新的变量:

signal lives_changed
signal dead
var reset_pos = false
var lives = 0: set = set_lives
func set_lives(value):
    lives = value
    lives_changed.emit(lives)
    if lives <= 0:
        change_state(DEAD)
    else:
        change_state(INVULNERABLE)

对于lives变量,你添加了一个名为lives的变化,set_lives()函数将被调用。这让你可以自动发出信号,同时检查它何时达到0

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

func reset():
    reset_pos = true
    $Sprite2d.show()
    lives = 3
    change_state(ALIVE)

重置玩家意味着将其位置设置回屏幕中心。正如我们之前看到的,这需要在_integrate_forces()中完成。将此添加到该函数中:

if reset_pos:
    physics_state.transform.origin = screensize / 2
    reset_pos = false

返回到Main场景,选择Player实例,并在HUD节点中找到其lives_changed信号,然后在接收方法中输入update_lives

图 3.22:将玩家信号连接到 HUD

图 3.22:将玩家信号连接到 HUD

在本节中,你创建了一个比以前项目更复杂的 UI,包括一些新的Control节点,如TextureProgressBar,并使用信号将所有内容连接在一起。在下一节中,你将处理游戏的结束:玩家死亡时应该发生什么。

结束游戏

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

Explosion场景的一个实例添加到Player场景中,取消选中其名为InvulnerabilityTimerTimer节点,并将2单次设置为“开启”。

你将发出dead信号来通知Main游戏应该结束。在此之前,你需要更新状态机,以便对每个状态进行更多操作:

func change_state(new_state):
    match new_state:
        INIT:
            $CollisionShape2D.set_deferred("disabled",
                true)
            $Sprite2D.modulate.a = 0.5
        ALIVE:
            $CollisionShape2d.set_deferred("disabled",
                false)
            $Sprite2d.modulate.a = 1.0
        INVULNERABLE:
            $CollisionShape2d.set_deferred("disabled",
                true)
            $Sprite2d.modulate.a = 0.5
            $InvulnerabilityTimer.start()
        DEAD:
            $CollisionShape2d.set_deferred("disabled",
                true)
            $Sprite2d.hide()
            linear_velocity = Vector2.ZERO
            dead.emit()
    state = new_state

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

进入INVULNERABLE状态后,开始计时器。连接其timeout信号:

func _on_invulnerability_timer_timeout():
    change_state(ALIVE)

检测刚体之间的碰撞

当你飞来飞去时,飞船会从岩石上弹开,因为两者都是刚体。然而,如果你想当两个刚体碰撞时发生某些事情,你需要启用Player场景,选择Player节点,然后在检查器中将其设置为1。现在玩家在接触到另一个物体时会发出信号。点击body_entered信号:

func _on_body_entered(body):
    if body.is_in_group("rocks"):
        body.explode()
        lives -= 1
        explode()
func explode():
    $Explosion.show()
    $Explosion/AnimationPlayer.play("explosion")
    await $Explosion/AnimationPlayer.animation_finished
    $Explosion.hide()

现在转到Main场景,将Player实例的dead信号连接到game_over()方法。玩游戏并尝试撞上岩石。你的飞船应该会爆炸,两秒内无敌,并失去一条生命。还要检查如果你被击中三次,游戏是否会结束。

在本节中,你学习了刚体碰撞,并使用它们来处理船只与岩石碰撞的情况。现在整个游戏周期已经完成:起始屏幕引导到游戏玩法,最后以游戏结束显示结束。在章节的剩余部分,你将添加一些额外的游戏功能,例如暂停功能。

暂停游戏

许多游戏需要某种暂停模式,以便玩家可以从动作中休息。在 Godot 中,暂停是SceneTree的功能,可以通过其paused属性设置。当SceneTree暂停时,会发生三件事:

  • 物理线程停止运行

  • _process()_physics_process()不再在任何节点上调用

  • _input()_input_event()方法也不会调用输入

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

暂停模式可以设置为以下值:

  • 继承 – 节点使用与其父节点相同的模式

  • 可暂停 – 当场景树暂停时,节点会暂停

  • 当暂停时 – 节点仅在树暂停时运行

  • 始终 – 节点始终运行,忽略树的暂停状态

  • 禁用 – 节点永远不会运行,忽略树的暂停状态

打开pause。分配一个您想要用于切换暂停模式的键。P是一个不错的选择。

将以下函数添加到Main.gd

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

此代码检测按键按下并切换树的paused状态为其当前状态的相反。它还在屏幕上显示暂停,这样就不会让人误以为游戏已经冻结。

如果现在运行游戏,您会遇到问题——所有节点都处于暂停状态,包括Main。这意味着它不再处理_input(),因此无法再次检测输入来暂停游戏!为了解决这个问题,将Main节点设置为始终

暂停功能是一个非常有用的功能,您可以在您制作的任何游戏中使用此技术,因此请复习它以确保您理解它是如何工作的。您甚至可以尝试回到并添加到Coin Dash。我们下一节通过向游戏中添加敌人来增加动作。

敌人

空间中不仅有岩石,还有更多的危险。在本节中,您将创建一个敌人飞船,它将定期出现并向玩家射击。

沿着路径行走

当敌人出现时,它应该在屏幕上沿着路径行走。如果它不是一条直线,看起来会更好。为了防止它看起来过于重复,您可以创建多个路径,并在敌人出现时随机选择一个。

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

图 3.23:路径绘制选项

图 3.23:路径绘制选项

这些按钮让您可以绘制和修改路径的点。点击带有绿色+符号的按钮来添加点。点击游戏窗口稍外的地方开始路径,然后点击几个点来绘制曲线。请注意,箭头指示路径的方向。现在不必担心让它变得平滑:

图 3.24:一个示例路径

图 3.24:一个示例路径

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

图 3.25:使用控制点

图 3.25:使用控制点

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

图 3.26:添加多个路径

图 3.26:添加多个路径

保存场景。您将将其添加到敌人的场景中,以便它能够跟随这些路径。

敌人场景

为敌人创建一个新的场景,使用 Area2D 作为其根节点。添加一个 Sprite2D 子节点,并使用 res://assets/enemy_saucer.png 作为其 3,这样您就可以在不同颜色的飞碟之间进行选择:

  1. 如您之前所做的那样,添加一个 CollisionShape2D 并将其缩放为 CircleShape2D 以覆盖图像。添加一个 EnemyPaths 场景实例和一个 AnimationPlayer。在 AnimationPlayer 中,您将添加一个动画,以便在飞碟被击中时产生闪光效果。

  2. 添加一个名为 flash 的动画。设置为 0.250.01。您将动画化的属性是 Sprite2D0.04 并将颜色从 0.04 改变回来,使其变回白色。

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

  4. 添加一个 Explosion 场景实例并将其隐藏。添加一个名为 GunCooldownTimer 节点来控制敌人射击的频率。设置为 1.5 并将 Autostart 设置为开启。

  5. 向敌人添加一个脚本并连接计时器的 timeout。暂时不要向函数中添加任何内容。

  6. enemies 中。与岩石一样,这将为您提供一种识别对象的方法,即使屏幕上同时有多个敌人。

移动敌人

首先,您将编写代码来选择路径并将敌人沿着它移动:

extends Area2D
@export var bullet_scene : PackedScene
@export var speed = 150
@export var rotation_speed = 120
@export var health = 3
var follow = PathFollow2D.new()
var target = null
func _ready():
    $Sprite2D.frame = randi() % 3
    var path = $EnemyPaths.get_children()[randi() %
        $EnemyPaths.get_child_count()]
    path.add_child(follow)
    follow.loop = false

请记住,PathFollow2D 节点会自动沿着父 Path2D 移动。默认情况下,当它到达路径的末尾时,它会绕路径循环,因此您需要将其设置为 false 以禁用它。

下一步是沿着路径移动并在敌人到达路径末尾时将其移除:

func _physics_process(delta):
    rotation += deg_to_rad(rotation_speed) * delta
    follow.progress += speed * delta
    position = follow.global_position
    if follow.progress_ratio >= 1:
        queue_free()

progress 大于路径总长度时,您可以检测路径的末尾。然而,使用 progress_ratio 更为直接,它在路径长度上从零变化到一,因此您不需要知道每个路径有多长。

敌人生成

Main 场景中,添加一个新的 Timer 节点,称为 EnemyTimer。设置其 main.gd,添加一个变量来引用敌人场景:

@export var enemy_scene : PackedScene

将此行添加到 new_level() 中:

$EnemyTimer.start(randf_range(5, 10))

连接 EnemyTimertimeout 信号:

func _on_enemy_timer_timeout():
    var e = enemy_scene.instantiate()
    add_child(e)
    e.target = $Player
    $EnemyTimer.start(randf_range(20, 40))

此代码在 EnemyTimer 超时时实例化敌人。你一段时间内不想有另一个敌人,所以计时器会以更长的延迟重新启动。

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

射击和碰撞

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

敌人的子弹将与玩家的子弹相似,但我们将使用不同的纹理。你可以从头开始创建它,或者使用以下过程来重用节点设置。

打开 Bullet 场景,选择 enemy_bullet.tscn(之后,别忘了将根节点重命名)。通过点击分离脚本按钮移除脚本。通过点击节点选项卡并选择断开连接来断开信号连接。你可以通过查找节点名称旁边的 图标来查看哪些节点有信号连接。

将精灵的纹理替换为 laser_green.png 图像,并在根节点上添加一个新的脚本。

敌人子弹的脚本将与普通子弹非常相似。连接区域的 body_entered 信号和 VisibleOnScreenNotifier2Dscreen_exited 信号:

extends Area2D
@export var speed = 1000
func start(_pos, _dir):
    position = _pos
    rotation = _dir.angle()
func _process(delta):
    position += transform.x * speed * delta
func _on_body_entered(body):
    queue_free()
func _on_visible_on_screen_notifier_2d_screen_exited():
    queue_free()

注意,你需要指定子弹的位置和方向。这是因为,与总是向前射击的玩家不同,敌人总是朝向玩家射击。

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

保存场景并将其拖入 Enemy

enemy.gd 中,添加一个变量以对子弹进行一些随机变化,并添加 shoot() 函数:

@export var bullet_spread = 0.2
func shoot():
    var dir =
       global_position.direction_to(target.global_position)
    dir = dir.rotated(randf_range(-bullet_spread,
       bullet_spread))
    var b = bullet_scene.instantiate()
    get_tree().root.add_child(b)
    b.start(global_position, dir)

首先,找到指向玩家位置的向量,然后添加一点随机性,使其可以“错过”。

GunCooldown 超时时调用 shoot() 函数:

func _on_gun_cooldown_timeout():
    shoot()

为了增加难度,你可以让敌人以脉冲或多次快速射击的方式射击:

func shoot_pulse(n, delay):
    for i in n:
        shoot()
        await get_tree().create_timer(delay).timeout

这将发射一定数量的子弹,n,子弹之间有 delay 秒的延迟。当冷却时间触发时,你可以调用此方法:

func _on_gun_cooldown_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()
func explode():
    speed = 0
    $GunCooldown.stop()
    $CollisionShape2D.set_deferred("disabled", true)
    $Sprite2D.hide()
    $Explosion.show()
    $Explosion/AnimationPlayer.play("explosion")
    await $Explosion/AnimationPlayer.animation_finished
    queue_free()

此外,连接敌人的 body_entered 信号,以便当玩家撞到敌人时,敌人会爆炸:

func _on_body_entered(body):
    if body.is_in_group("rocks"):
        return
    explode()

同样,你需要在玩家护盾实现之前对玩家造成伤害,所以现在这个碰撞只会摧毁敌人。

目前,玩家的子弹只能检测到岩石,因为它的 body_entered 信号不会被敌人触发,而敌人是一个 Area2D。为了检测敌人,请转到 Bullet 场景并连接 area_entered 信号:

func _on_area_entered(area):
    if area.is_in_group("enemies"):
        area.take_damage(1)

尝试再次玩游戏,你将与一个侵略性的外星对手战斗!验证所有碰撞组合是否被处理(除了敌人射击玩家)。还请注意,敌人的子弹可以被岩石阻挡——也许你可以躲在它们后面作为掩护!

现在游戏有了敌人,它变得更加具有挑战性。如果你仍然觉得太简单,尝试增加敌人的属性:它出现的频率、它造成的伤害以及摧毁它所需的射击次数。如果你让它变得太难是完全可以的,因为在下一节中,你将通过添加一个吸收伤害的护盾来为玩家提供一些帮助。

玩家护盾

在本节中,你将为玩家添加一个护盾,并在 HUD 中添加一个显示当前护盾级别的显示元素。

首先,将以下内容添加到 player.gd 脚本的顶部:

signal shield_changed
@export var max_shield = 100.0
@export var shield_regen = 5.0
var shield = 0: set = set_shield
func set_shield(value):
    value = min(value, max_shield)
    shield = value
    shield_changed.emit(shield / max_shield)
    if shield <= 0:
        lives -= 1
        explode()

shield 变量与 lives 类似,每当它发生变化时都会发出信号。由于值将由护盾的再生添加,你需要确保它不会超过 max_shield 值。然后,当你发出 shield_changed 信号时,你传递 shield / max_shield 的比率而不是实际值。这样,HUD 的显示就不需要知道护盾实际有多大,只需要知道它的百分比。

你还应该从 _on_body_entered() 中移除 explode() 行,因为你现在不希望仅仅击中岩石就会炸毁飞船——现在这只会发生在护盾耗尽时。

击中岩石会损坏护盾,较大的岩石应该造成更多的伤害:

func _on_body_entered(body):
    if body.is_in_group("rocks"):
        shield -= body.size * 25
        body.explode()

敌人的子弹也应该造成伤害,所以将此更改应用到 enemy_bullet.gd

@export var damage = 15
func _on_body_entered(body):
    if body.name == "Player":
        body.shield -= damage
    queue_free()

同样,撞到敌人应该伤害玩家,所以更新 enemy.gd 中的此内容:

func _on_body_entered(body):
    if body.is_in_group("rocks"):
        return
    explode()
    body.shield -= 50

如果玩家的护盾耗尽并且他们失去了一条生命,你应该将护盾重置为其最大值。将此行添加到 set_lives()

shield = max_shield

玩家脚本中的最后一个添加是每帧再生护盾。将此行添加到 player.gd 中的 _process()

shield += shield_regen * delta

现在代码已经完成,你需要在 HUD 场景中添加一个新的显示元素。与其将护盾的值显示为数字,你将创建一个 TextureProgressBar,这是一个 Control 节点,它将给定的值显示为一个填充的条形。它还允许你为条形分配一个要使用的纹理。

转到 HUD 场景,并将两个新节点作为现有 HBoxContainer 的子节点添加:TextureRectTextureProgressBar。将 TextureProgressBar 重命名为 ShieldBar。将它们放置在 Score 标签之后和 LivesCounter 之前。你的节点设置应该看起来像这样:

图 3.27:更新后的 HUD 节点布局

图 3.27:更新后的 HUD 节点布局

res://assets/shield_gold.png 拖入 TextureRect。这将是一个图标,表示这个条形图显示护盾值。将 拉伸模式 设置为 居中,这样纹理就不会扭曲。

ShieldBar 有三个 res://assets/bar_green_200.png 放入这个属性。其他两个纹理属性允许你通过设置一个图像来绘制在进度纹理之上或之下来自定义外观。将 res://assets/bar_glass_200.png 拖入 Over 属性。

01 中,因为这个条形图将显示护盾与其最大值的比率,而不是其数值。这意味着 0.01.75 以看到条形图部分填充。此外,在 布局/容器大小 部分,勾选 扩展 复选框并将 垂直 设置为 收缩居中

完成后,HUD 应该看起来像这样:

图 3.28:更新后的带有护盾栏的 HUD

图 3.28:更新后的带有护盾栏的 HUD

你现在可以更新脚本以设置护盾栏的值,以及使其在接近零时改变颜色。将这些变量添加到 hud.gd 中:

@onready var shield_bar =
    $MarginContainer/HBoxContainer/ShieldBar
var bar_textures = {
    "green": preload("res://assets/bar_green_200.png"),
    "yellow": preload("res://assets/bar_yellow_200.png"),
    "red": preload("res://assets/bar_red_200.png")
}

除了绿色条形图,assets 文件夹中还有红色和黄色条形图。这允许你在值降低时更改护盾栏的颜色。以这种方式加载纹理使得在脚本中稍后更容易访问,当你想要为条形图分配适当的图像时:

func update_shield(value):
    shield_bar.texture_progress = bar_textures["green"]
    if value < 0.4:
        shield_bar.texture_progress = bar_textures["red"]
    elif value < 0.7:
        shield_bar.texture_progress = bar_textures["yellow"]
    shield_bar.value = value

最后,点击 Main 场景的 Player 节点,并将 shield_changed 信号连接到 HUDupdate_shield() 函数。

运行游戏并验证护盾是否正常工作。你可能想要增加或减少护盾再生速率以获得你满意的速度。当你准备好继续时,在下一节中,你将为游戏添加一些声音。

声音和视觉效果

游戏的结构和玩法已经完成。在本节中,你将添加一些额外的效果来提升游戏体验。

声音和音乐

res://assets/sounds 文件夹中包含了一些游戏音频效果。要播放声音,需要通过 AudioStreamPlayer 节点加载。将两个这样的节点添加到 Player 场景中,分别命名为 LaserSoundEngineSound。将相应的声音文件拖入每个节点的 player.gd 中的 shoot()

$LaserSound.play()

播放游戏并尝试射击。如果你觉得声音太大,可以将 -10 调整一下。

引擎声音的工作方式略有不同。它需要在推力开启时播放,但如果你只是尝试在玩家按下键时在 get_input() 函数中对声音调用 play(),它将每帧重新启动声音。这听起来不太好,所以你只想在声音尚未播放时开始播放声音。以下是 get_input() 函数的相关部分:

if Input.is_action_pressed("thrust"):
    thrust = transform.x * engine_power
    if not $EngineSound.playing:
        $EngineSound.play()
else:
    $EngineSound.stop()

注意,可能会出现一个问题:如果玩家在按下推力键时死亡,由于在 $EngineSound.stop()change_state() 中,引擎声音将卡在播放状态。

Main场景中,添加三个更多的AudioStreamPlayer节点:ExplosionSoundLevelupSoundMusic。在它们的explosion.wavlevelup.oggFunky-Gameplay_Looping.ogg中。

$ExplosionSound.play()作为_on_rock_exploded()的第一行,并将$LevelupSound.play()添加到new_level()

要开始和停止背景音乐,将$Music.play()添加到new_game(),将$Music.stop()添加到game_over()

敌人也需要ExplosionSoundShootSound节点。你可以使用enemy_laser.wav作为它们的射击声音。

粒子

玩家飞船的推力是粒子效果的一个完美应用,它从引擎处产生一条流火。

添加一个CPUParticles2D节点,并将其命名为Exhaust。你可能想在执行这部分操作时放大飞船。

粒子节点类型

Godot 提供了两种类型的粒子节点:一种使用 CPU 进行渲染,另一种使用 GPU 进行渲染。由于并非所有平台,尤其是移动或较旧的桌面,都支持粒子的硬件加速,因此你可以使用 CPU 版本以实现更广泛的兼容性。如果你知道你的游戏将在更强大的系统上运行,你可以使用 GPU 版本。

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

配置粒子时,有非常多的属性可供选择。在设置此效果的过程中,请随意尝试它们,看看它们如何影响结果。

设置Exhaust节点的这些属性:

  • 25

  • 绘图/局部坐标:开启

  • (-28, 0)

  • 180

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

你将要更改的剩余属性将影响粒子的行为。从(1, 5)开始。现在粒子是在一个小区域内发射,而不是从单个点发射。

接下来,设置0(0, 0)。注意,虽然粒子移动非常缓慢,但它们并没有下落或扩散。

设置400,然后向下滚动到8

要使大小随时间变化,你可以设置缩放量曲线。选择新建曲线然后点击打开它。在小图中,右键点击添加两个点——一个在左侧,一个在右侧。将右侧的点向下拖动,直到曲线看起来像这样:

图 3.29:添加粒子缩放曲线

图 3.29:添加粒子缩放曲线

现在,你应该能看到粒子从飞船后方流出时逐渐缩小。

最后要调整的部分是颜色。为了让粒子看起来像火焰,它们应该从明亮的橙黄色开始,随着淡出而变为红色。在颜色渐变属性中,点击新建渐变,你会看到一个看起来像这样的渐变编辑器:

图 3.30:颜色渐变设置

图 3.30:颜色渐变设置

标有 12 的两个矩形滑块设置渐变的起始和结束颜色。点击任何一个都会在标有 3 的框中显示其颜色。选择滑块 1 然后点击框 3 以打开颜色选择器。选择橙色,然后对滑块 2 做同样的操作,选择深红色。

现在粒子有了正确的外观,但它们持续的时间太长了。在节点的 0.1

希望您的飞船尾气看起来有点像火焰。如果不像,请随意调整属性,直到您对其外观满意为止。

一旦火焰看起来不错,就需要根据玩家的输入来开启和关闭。转到 player.gd 并在 get_input() 的开头添加 $Exhaust.emitting = false。然后,在检查 thrust 输入的 if 语句下,添加 $Exhaust.emitting = true

敌人轨迹

您还可以使用粒子来为敌人的飞碟添加一条闪耀的轨迹。将一个 CPUParticles2D 添加到敌人场景中,并配置以下设置:

  • 20

  • 可见性/显示背后 父级: 开启

  • Sphere

  • 25

  • (``0, 0)

您现在应该在整个飞碟半径上看到粒子出现(如果您想更好地看到它们,可以在这一部分隐藏 Sprite2D)。粒子的默认形状是正方形,但您也可以使用纹理来获得更多的视觉吸引力。将 res://assets/corona.png 添加到 绘图/纹理

这张图片提供了一个很好的发光效果,但与飞碟相比,它相当大,所以设置为 0.1。您还会注意到这张图片在黑色背景上是白色的。为了看起来正确,需要更改其 混合模式。为此,找到 材质 属性并选择 新建 CanvasItemMaterial。在那里,您可以将 混合模式混合 更改为 添加

最后,您可以通过在 缩放 部分的 缩放量曲线 中使用,使粒子逐渐消失,就像您对玩家粒子所做的那样。

播放您的游戏并欣赏效果。您还能用粒子添加些什么?

摘要

在本章中,您学习了如何与 RigidBody2D 节点一起工作,并更深入地了解了 Godot 物理的工作原理。您还实现了一个基本的有限状态机——随着您的项目变得更大,您会发现这很有用,您将在未来的章节中再次使用它。您看到了 Container 节点如何帮助组织和保持 UI 节点对齐。最后,您添加了音效,并通过使用 AnimationCPUParticles2D 节点,第一次尝到了高级视觉效果的滋味。

您还继续使用标准的 Godot 层次结构创建游戏对象,例如将 CollisionShapes 附着到 CollisionObjects 上,以及使用信号来处理节点间的通信。到目前为止,这些做法应该开始对您熟悉了。

你准备好尝试独立重做这个项目了吗?尝试在不看书的条件下,重复所有,甚至部分,本章内容。这是一个检查你吸收了哪些信息以及需要再次复习哪些内容的不错方法。你也可以尝试加入自己的变体来重做,而不仅仅是做一个精确的复制。

当你准备好继续前进,在下一章中,你将制作另一种非常流行的游戏风格:一款遵循超级马里奥兄弟传统的平台游戏。

第四章:热带跳跃 – 在 2D 平台游戏中奔跑和跳跃

在本章中,你将按照经典游戏如超级马里奥兄弟的传统构建一个平台游戏。平台游戏是一个非常受欢迎的游戏类型,了解它们的工作原理可以帮助你制作各种不同的游戏风格。如果你之前从未尝试过制作这样的游戏,平台游戏中的玩家动作实现可能会出人意料地复杂,你将看到 Godot 的 CharacterBody2D 节点如何帮助你完成这个过程。

在这个项目中,你将学习以下内容:

  • 使用 CharacterBody2D 节点

  • 使用 Camera2D 节点

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

  • 使用 TileMap 设计关卡

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

  • 在场景之间切换

  • 组织你的项目和规划扩展

这是完成游戏的截图:

图 4.1:完成的游戏截图

图 4.1:完成的游戏截图

技术要求

与之前的项目一样,你将首先下载游戏的美术资源,这些资源可以在以下链接找到:github.com/PacktPublishing/Godot-Engine-Game-Development-Projects-Second-Edition/tree/main/Downloads

你也可以在 GitHub 上找到本章的完整代码:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Chapter04%20-%20Jungle%20Jump

设置项目

要创建一个新项目,首先打开项目设置,以便你可以配置所需的默认设置。

本游戏的美术资源采用像素艺术风格,这意味着当图像未进行平滑处理时它们看起来最好,这是 Godot 对纹理过滤的默认设置:

图 4.2:纹理过滤

图 4.2:纹理过滤

虽然可以在每个 Sprite2D 上设置此选项,但指定默认设置更为方便。点击右上角的高级切换按钮,然后在左侧找到渲染/纹理部分。在设置列表中滚动到最底部,找到画布纹理/默认纹理过滤设置。将其从线性更改为最近

然后,在显示/窗口下,将拉伸/模式更改为画布项,并将纵横比更改为扩展。这些设置将允许用户在保持图像质量的同时调整游戏窗口的大小。一旦项目完成,你将能够看到此设置的成效。

接下来,你可以设置碰撞层。因为这款游戏将有几种不同类型的碰撞对象,它们需要以不同的方式交互,所以你会使用 Godot 的 碰撞层 系统来帮助组织它们。如果它们被分配了名称,那么使用起来会更方便,所以前往 层名称 | 2D 物理 部分,并将前四个层命名为如下(直接在层编号旁边的框中键入):

图 4.3:设置物理层名称

图 4.3:设置物理层名称

最后,将以下动作添加到 输入 映射 区域的玩家控制中:

动作名称 按键
right D, →
left A, ←
jump 空格键
up S, ↑
down W, ↓

确保你使用确切的名称来命名输入动作,因为你稍后会在代码中引用它们。

这是你需要在 项目设置 中设置的所有内容。但在你开始制作玩家场景之前,你需要了解不同类型的物理节点。

介绍运动学物体

平台游戏需要重力、碰撞、跳跃和其他物理行为,所以你可能认为 RigidBody2D 是实现角色移动的完美选择。在实践中,你会发现刚体的更真实物理特性对于平台角色来说并不理想。对于玩家来说,现实感不如响应控制感和动作感重要。因此,作为开发者,你希望对角色的移动和碰撞响应有精确的控制。因此,对于平台角色来说,运动学风格的物理通常是更好的选择。

CharacterBody2D 节点是为了实现那些需要通过代码直接控制的物理体而设计的。当它们移动时,这些节点会检测与其他物体的碰撞,但不会受到全局物理属性(如重力或摩擦)的影响。这并不意味着它们不能受到重力和其他力的作用——只是你必须计算这些力及其在代码中的效果;物理引擎不会自动移动 CharacterBody2D 节点。

当移动 CharacterBody2D 节点,就像使用 RigidBody2D 一样,你不应该直接设置其 position 属性。相反,你必须使用由物体提供的 move_and_collide()move_and_slide() 方法。这些方法沿着给定的向量移动物体,并在检测到与其他物体的碰撞时立即停止。然后,由你来决定任何 碰撞响应

碰撞响应

碰撞发生后,你可能想让物体弹跳、沿着墙壁滑动,或者改变它撞击物体的属性。处理碰撞响应的方式取决于你使用哪种方法来移动物体:

move_and_collide()

当使用这种方法时,在碰撞发生时函数会返回一个KinematicCollision2D对象。这个对象包含有关碰撞和碰撞体的信息。你可以使用这些信息来确定响应。请注意,当没有碰撞且移动成功完成时,函数返回null

例如,如果你想使身体从碰撞对象上弹开,你可以使用以下脚本:

extends CharacterBody2D
velocity = Vector2(250, 250)
func _physics_process(delta):
    var collision = move_and_collide(velocity * delta)
    if collision:
        velocity = velocity.bounce(collision.get_normal())

move_and_slide()

滑动是碰撞响应中一个非常常见的选项。想象一下在一个俯视角游戏中,玩家沿着墙壁移动,或者在平台游戏中沿着地面奔跑。在使用move_and_collide()之后,你可以自己编写代码来实现响应,但move_and_slide()提供了一个方便的方式来实现滑动移动。当使用这种方法时,身体会自动沿着碰撞对象的表面滑动。此外,滑动碰撞将允许你使用is_on_floor()等方法检测表面的方向。

由于这个项目需要你允许玩家角色在地面和上下坡道上奔跑,move_and_slide()将在你的玩家移动中扮演重要角色。

现在你已经了解了运动学身体是什么,你将使用一个来制作这个游戏的角色。

创建玩家场景

实现运动学移动和碰撞的 Godot 节点被称为CharacterBody 2D

打开一个新的场景,并添加一个名为PlayerCharacterBody2D节点作为根节点,并保存场景。别忘了点击Player场景,你还应该创建一个新的文件夹来包含它。这有助于在你添加更多场景和脚本时保持你的项目文件夹组织有序。

查看 Inspector 中CharacterBody2D的属性。注意运动模式向上方向的默认值。“地面”模式意味着身体将考虑一个碰撞方向作为“地板”,相对的墙壁作为“天花板”,其他任何作为“墙壁”——哪一个由向上方向决定。

正如你在之前的项目中做的那样,你将在玩家场景中包含玩家角色需要的功能节点。对于这个游戏,这意味着处理与各种游戏对象的碰撞,包括平台、敌人和可收集物品;显示动作动画,如奔跑或跳跃;并将相机附加到跟随玩家在关卡中移动。

编写各种动画的脚本可能会很快变得难以管理,所以你需要使用一个有限状态机FSM)来管理和跟踪玩家的状态。参见第三章回顾如何构建简化的 FSM。你将遵循类似的项目模式。

碰撞层和掩码

一个身体的Player需要分配到“player”层(您在项目设置中命名的层)。同样,碰撞/遮罩设置身体可以“看到”或与之交互的层。如果一个对象在一个不在玩家遮罩中的层上,那么玩家根本不会与之交互。

将玩家的设置为player遮罩设置为环境敌人物品。点击右侧的三个点以打开一个复选框列表,显示您分配给层的名称:

图 4.4:设置碰撞层

图 4.4:设置碰撞层

这将确保玩家位于“player”层,以便其他对象可以配置为检测玩家或不检测玩家。将遮罩值设置为所有三个层意味着玩家将能够与这些层上的任何对象交互。

关于 AnimationPlayer

在本书的早期,您使用了AnimatedSprite2D来显示角色的基于帧的动画。这是一个很好的工具,但它仅适用于动画节点的视觉纹理。如果您还想动画化节点上的其他任何属性怎么办?

这就是AnimationPlayer发挥作用的地方。这个节点是一个非常强大的工具,可以一次性影响多个节点创建动画;你可以修改它们的任何属性。

动画

要设置角色的动画,请按照以下步骤操作:

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

图 4.5:精灵图集

  1. 您将使用AnimationPlayer来处理动画,因此,在Sprite2D中设置19。然后,将7设置为查看玩家站立。最后,通过将(0, -16)设置为向上移动Sprite2D,使其脚部站在地面上。这将使您在稍后编码玩家的交互时更容易,因为您将知道玩家的position属性代表其脚部的位置。

  2. 将一个AnimationPlayer节点添加到场景中。您将使用此节点来更改每个动画的Sprite2D的适当值。

  3. 在开始之前,请回顾一下动画面板的不同部分:

图 4.6:动画面板

图 4.6:动画面板

  1. 点击idle

  2. 设置其0.4秒。点击循环图标以使动画循环,并将轨道的更新模式设置为连续

Sprite2D更改为7,这是空闲动画的第一帧,并点击属性旁边的关键帧图标以添加一个带有新关键帧的动画轨道:

图 4.7:添加关键帧

图 4.7:添加关键帧

  1. 将播放刮擦器滑到0.3(您可以在右下角的缩放滑块中调整以使其更容易找到)。为第10帧添加一个关键帧,这是idle的最后一帧。

  2. 7键并结束在第10帧。

现在,为其他动画重复此过程。以下表格列出了它们的设置:

名称 长度 帧数 循环
idle 0.4 710 开启
run 0.5 1318 开启
hurt 0.2 56 开启
jump_up 0.1 11 关闭
jump_down 0.1 12 关闭

精灵图中也有蹲下和攀爬的动画,但可以在基本移动完成后添加这些动画。

碰撞形状

与其他身体一样,CharacterBody2D 需要一个形状来定义其碰撞边界。添加一个 CollisionShape2D 节点并在其中创建一个新的 RectangleShape2D。在调整形状大小时,你希望它达到图像的底部(玩家的脚),但比玩家的图像略窄。一般来说,使形状比图像略小会在游戏中产生更好的感觉,避免击中看起来不会导致碰撞的东西的经验。

你还需要稍微偏移形状以使其适合。设置 CollisionShape2D 节点的 (0, -10) 会很有效。完成时,它应该看起来大约是这样的:

图 4.8:玩家碰撞形状

图 4.8:玩家碰撞形状

多个形状

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

完成玩家场景

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

要启用相机,设置 (2.5, 2.5)。小于 1 的值会使相机缩小,而较大的值会使相机放大。

你会看到一个围绕玩家的粉紫色矩形。那是相机的 屏幕矩形,它显示了相机将看到的内容。你可以调整 缩放 属性来增加或减少其大小,以便看到更多或更少的玩家周围的世界。

玩家状态

玩家角色有多种行为,例如跳跃、奔跑和蹲下。编码这些行为可能会变得非常复杂且难以管理。一个解决方案是使用布尔变量(例如 is_jumpingis_running),但这可能导致可能令人困惑的状态(如果 is_crouchingis_jumping 都为 true 会怎样?)并且很快就会导致 _spaghetti_ 代码

解决这个问题的更好方法是使用状态机来处理玩家的当前状态并控制到其他状态的转换。这个概念在第三章中介绍过,您将在本项目中对其进行扩展。

这里是玩家状态及其之间转换的图示:

图 4.9:玩家状态图

图 4.9:玩家状态图

如您所见,状态图可能会变得相当复杂,即使是在相对较少的状态下。

其他状态

注意,虽然精灵图包含它们的动画,但CROUCHCLIMB状态不包括在内。这是为了在项目开始时保持状态数量可管理。稍后,您将有机会添加它们。

玩家脚本

将一个新的脚本附加到Player节点上。注意,对话框显示了一个模板属性,这是 Godot 为此节点类型提供的默认基本移动。取消选择模板框——您不需要这个示例代码来完成这个项目。

将以下代码添加到开始设置玩家状态机。与太空岩石游戏一样,您可以使用enum类型来定义系统的允许状态。当您想要更改玩家的状态时,您可以调用change_state()

extends CharacterBody2D
@export var gravity = 750
@export var run_speed = 150
@export var jump_speed = -300
enum {IDLE, RUN, JUMP, HURT, DEAD}
var state = IDLE
func _ready():
    change_state(IDLE)
func change_state(new_state):
    state = new_state
    match state:
        IDLE:
            $AnimationPlayer.play("idle")
        RUN:
            $AnimationPlayer.play("run")
        HURT:
            $AnimationPlayer.play("hurt")
        JUMP:
            $AnimationPlayer.play("jump_up")
        DEAD:
            hide()

目前,脚本只更改正在播放的动画,但您将在稍后添加更多状态功能。

玩家移动

玩家需要三个控制键:左、右和跳跃。比较当前状态和按下的键,如果状态图规则允许转换,则会触发状态变化。添加get_input()函数来处理输入并确定结果。每个if条件代表状态图中的一个转换:

func get_input():
    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
        $Sprite2D.flip_h = false
    if left:
        velocity.x -= run_speed
        $Sprite2D.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 in the air
    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()
    move_and_slide()

记住,由于(0, -1),任何在玩家脚下的碰撞都将被视为“地板”,并且is_on_floor()将由move_and_slide()设置为true。您可以使用这个事实来检测跳跃何时结束,在move_and_slide()之后添加以下代码:

if state == JUMP and is_on_floor():
    change_state(IDLE)

如果动画在掉落时从jump_up切换到jump_down,跳跃看起来会更好:

if state == JUMP and velocity.y > 0:
    $AnimationPlayer.play("jump_down")

之后,一旦关卡完成,玩家将获得一个出生位置。为了处理这个问题,将以下函数添加到脚本中:

func reset(_position):
    position = _position
    show()
    change_state(IDLE)

这样,你已经完成了移动的添加,并且每种情况都应播放正确的动画。这是一个很好的停止点来测试玩家,以确保一切正常工作。但是,你不能只是运行场景,因为玩家会开始无任何立足之地的下落。

测试移动

创建一个新的场景,并添加一个名为 MainNode 对象(稍后,这将成为你的主场景)。添加一个 Player 实例,然后添加一个具有矩形碰撞形状的 StaticBody2D 节点。将碰撞形状水平拉伸,使其足够宽,可以来回行走,并将其放置在角色下方:

图 4.10:带有平台的测试场景

图 4.10:带有平台的测试场景

由于它没有 Sprite2D 节点,静态身体在运行游戏时将是不可见的。在菜单中,选择 调试 > 可见碰撞形状。这是一个有用的调试设置,可以在游戏运行时绘制碰撞形状。你可以在需要测试或排除故障时随时打开它。

当它撞击静态身体时,按下 idle 动画。

在继续之前,请确保所有移动和动画都正常工作。在所有方向上跑和跳,并检查状态改变时是否播放了正确的动画。如果你发现任何问题,请回顾前面的部分,并确保你没有错过任何步骤。

玩家健康

最终,玩家会遇到危险,因此你应该添加一个伤害系统。玩家开始时有三个心形生命值,每次受到伤害就会失去一个。

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

signal life_changed
signal died
var life = 3: set = set_life
func set_life(value):
    life = value
    life_changed.emit(life)
    if life <= 0:
        change_state(DEAD)

每当 life 的值发生变化时,你将发出 life_changed 信号,通知显示更新。当 life 达到 0 时,将发出 dead 信号。

reset() 函数中添加 life = 3

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

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

这段代码对玩家很友好:如果他们已经受伤,他们就不能再次受伤(至少在 hurt 动画停止播放的短时间内不能)。如果没有这个,很容易陷入受伤的循环,导致快速死亡。

当你在之前创建的 change_state() 函数中将状态更改为 HURT 时,有一些事情要做:

HURT:
    $AnimationPlayer.play("hurt")
    velocity.y = -200
    velocity.x = -100 * sign(velocity.x)
    life -= 1
    await get_tree().create_timer(0.5).timeout
    change_state(IDLE)
DEAD:
    died.emit()
    hide()

当他们受伤时,不仅会失去一个 生命值,而且还会被弹起并远离造成伤害的物体。经过一段时间后,状态会变回 IDLE

此外,在 HURT 状态期间应禁用输入。将以下内容添加到 get_input() 的开头:

if state == HURT:
    return

现在,一旦游戏的其他部分设置完成,玩家就可以开始受到伤害。接下来,你将创建玩家在游戏中可以收集的物体。

可收集物品

在你开始制作关卡之前,你需要创建一些玩家可以收集的物品,因为那些也将是关卡的一部分。assets/sprites 文件夹包含两种类型可收集物品的精灵图集:樱桃和宝石。

而不是为每种类型的物品创建一个单独的场景,你可以使用一个场景并在脚本中交换 texture 属性。这两个对象具有相同的行为:在原地动画并在被玩家收集时消失。你还可以为收集添加一个 tween 效果(见 第二章)。

场景设置

使用 Area2D 开始新的场景并将其命名为 Item。将场景保存在新的 items 文件夹中。

这些对象是一个好选择,因为你想要检测玩家何时接触它们,但你不需要从它们那里获得碰撞响应。在检查器中设置 collectibles(第 4 层)和 player(第 2 层)。这将确保只有 Player 节点能够收集它们,而敌人将直接穿过。

添加三个子节点:Sprite2DCollisionShape2DAnimationPlayer。将 res://assets/sprites/cherry.png 拖入 Sprite2D 节点的 5 位置。然后,在 CollisionShape2D 中添加一个圆形形状并适当调整其大小:

图 4.11:具有碰撞的物品

图 4.11:具有碰撞的物品

选择碰撞大小

作为一般规则,你应该调整你的碰撞形状的大小,以便它们对玩家有益。这意味着敌人的击中框应该比图像略小,而有益物品的击中框应该略微放大。这减少了玩家的挫败感,并导致更好的游戏体验。

AnimationPlayer 添加一个新的动画(你只需要一个,所以你可以给它起任何名字)。设置 1.6 秒、0.2 秒,并将 Looping 设置为 开启。点击 加载时自动播放 按钮,以便动画将自动开始。

设置 Sprite2D 节点的 0 并点击键按钮以创建轨迹。这个精灵图集只包含动画的一半,因此动画需要按以下顺序播放帧:

0 -> 1 -> 2 -> 3 -> 4 -> 3 -> 2 -> 1

将滑块拖到时间 0.8 并键入 4。然后,在时间 1.4 处键入 1。将 res://assets/sprites/coin.png 图像设置为 Texture,它将同样工作,因为它有相同数量的帧。这将使你在游戏中生成樱桃和宝石变得容易。

可收集物品脚本

Item 脚本需要完成两件事:

  • 设置起始条件(哪个 textureposition

  • 检测玩家何时重叠

对于第一部分,将以下代码添加到你的新物品脚本中:

extends Area2D
signal picked_up
var textures = {
    "cherry": "res://assets/sprites/cherry.png",
    "gem": "res://assets/sprites/gem.png"
}
func init(type, _position):
    $Sprite2D.texture = load(textures[type])
    position = _position

当玩家收集物品时,你会发出 picked_up 信号。在 textures 字典中,你可以找到一个物品类型及其对应图像文件的列表。注意,你可以通过将文件从 FileSystem 拖动并放入脚本编辑器来快速粘贴这些路径。

接下来,init()函数设置textureposition。你的关卡脚本将使用这些信息来生成你在关卡地图中放置的所有物品。

最后,连接Itembody_entered信号并添加以下代码:

func _on_item_body_entered(body):
    picked_up.emit()
    queue_free()

这个信号将允许游戏的主脚本对拾取物品做出反应。它可以增加分数,提高玩家的健康值,或者实现你希望物品产生的任何其他效果。

你可能已经注意到,这些可收集物品的设置与Coin Dash中的硬币非常相似。区域对于任何需要知道何时被触摸的物品类型都非常有用。在下一节中,你将开始布置关卡场景,以便放置这些可收集物品。

设计关卡

对于大多数人来说,这一部分将占用你大部分的时间。一旦你开始设计关卡,你会发现布置所有部件并创建挑战性跳跃、秘密路径和危险遭遇非常有趣。

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

使用TileMap

创建一个新的场景并添加一个名为LevelBaseNode2D节点。将场景保存在名为levels的新文件夹中。这是你将保存所有创建的关卡的地方,它们都将继承自这个level_base.tscn场景的功能。它们将具有相同的节点层次结构——只有布局不同。

瓦片地图是使用瓦片网格设计游戏环境的常用工具。它们允许你通过在网格上绘制瓦片来绘制关卡布局,而不是逐个放置许多单独的节点。它们也更有效率,因为它们将所有单个瓦片纹理和碰撞形状批处理到单个游戏对象中。

添加一个TileMap节点;在编辑器窗口底部将出现一个新的TileMap面板。注意,它说编辑的 TileMap 没有 TileSet 资源

关于TileSet

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

根据你可能拥有的瓦片数量,创建TileSet可能很耗时,尤其是第一次。因此,assets文件夹中包含了一些预生成的瓦片集。你可以自由使用这些瓦片集,但请阅读以下部分。它包含有用的信息,帮助你理解TileSet的工作原理。如果你更愿意使用提供的瓦片集,请跳转到使用提供的 TileSets部分。

创建一个TileSet

在 Godot 中,TileSet是一种Resource类型。其他资源的例子包括TextureAnimationRectangleShape2D。它们不是节点;相反,它们是包含特定类型数据的容器,通常保存为.tres文件。

创建TileSet容器的步骤如下:

  1. 点击TileMap。你会看到现在有一个TileSet面板可用,你可以在编辑器窗口底部选择它。你可以点击两个向上的箭头,,使面板填满编辑器屏幕。再次点击它以缩小面板。

  2. TileSet面板是你可以放置想要切割成瓦片的纹理的地方。将res://assets/environment/tileset.png拖入此框。将出现一个弹出窗口,询问你是否想自动创建瓦片。在图像中选择16x16像素的瓦片:

图 4.12:添加 TileSet

图 4.12:添加 TileSet

  1. 尝试选择底部的TileMap面板,然后选择瓦片左上角的草地块图像。然后,在编辑器窗口中点击以通过在编辑器窗口中左键点击来绘制一些瓦片。你可以在瓦片上右键点击以清除它:

图 4.13:使用 TileMaps 绘制

图 4.13:使用 TileMaps 绘制

如果你只想绘制背景,那么你就完成了。然而,你还需要将这些瓦片添加碰撞,以便玩家可以站在上面。

  1. 再次打开TileSet面板,在检查器中找到PhysicsLayers属性并点击添加元素

图 4.14:向 TileSet 添加物理层

图 4.14:向 TileSet 添加物理层

由于这些瓦片将位于环境层,你不需要更改图层/掩码设置。

  1. 点击Physics Layer 0

图 4.15:向瓦片添加碰撞

图 4.15:向瓦片添加碰撞

  1. 开始点击瓦片以向它们添加默认的方形碰撞形状。如果你想编辑瓦片的碰撞形状,你可以这样做——再次点击瓦片以应用更改。如果你卡在一个你不喜欢的外形上,点击三个点并选择重置为默认 瓦片形状

你也可以将props.png图像拖入纹理列表,为一些装饰物品增添你的关卡色彩。

使用提供的 TileSets

预配置的瓦片集已包含在此项目的assets下载中。有三个需要添加到三个不同的TileMap节点:

  • 世界tiles_world.tres:地面和平台瓦片

  • Itemstiles_items.tres:生成可收集物品的标记

  • 危险tiles_spikes.tres:碰撞时造成伤害的物品

创建ItemsDanger瓦片地图,并将相关的瓦片集添加到Tile Set属性。

添加一个Player场景实例和一个名为SpawnPointMarker2D节点。你可以使用此节点来标记玩家在关卡中开始的位置。

将脚本附加到Level节点:

extends Node2D
func _ready():
    $Items.hide()
    $Player.reset($SpawnPoint.position)

之后,你将扫描Items地图以在指定位置生成可收集物品。这个地图层不应该被看到,所以你可以将其设置为场景中的隐藏。然而,这很容易忘记,所以_ready()确保在游戏过程中它不可见。

设计第一个关卡

现在,你准备好开始绘制等级了!点击level_base.tscn。将根节点命名为Level01并保存(在levels文件夹中)。注意,子节点被涂成黄色,表示它们是level_base.tscn。如果你对原始场景进行了更改,这些更改也会出现在这个场景中。

世界地图开始,发挥创意。你喜欢很多跳跃,还是曲折的隧道去探索?长跑还是小心翼翼的向上攀登?

在深入进行等级设计之前,请确保你尝试了跳跃距离。你可以更改玩家的jump_speedrun_speedgravity属性来改变他们可以跳多高和多远。设置不同大小的间隙并运行场景来尝试它们。别忘了将SpawnPoint节点拖到玩家开始的地方。

你设置玩家移动属性的方式将对你的等级布局产生重大影响。在花费太多时间在完整设计之前,请确保你对你的设置感到满意。

一旦你设置了世界地图,使用物品地图来标记你想要生成樱桃和宝石的位置。标记生成位置的瓦片以洋红色背景绘制,以便突出显示。记住,它们将在运行时被替换,瓦片本身将不会被看到。

一旦你确定了你的等级布局,你可以限制玩家摄像机的水平滚动以匹配地图的大小(并在两端各添加一个小缓冲区)。将以下代码添加到level_base.gd文件中:

func _ready():
    $Items.hide()
    $Player.reset($SpawnPoint.position)
    set_camera_limits()
func set_camera_limits():
    var map_size = $World.get_used_rect()
    var cell_size = $World.tile_set.tile_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

脚本还需要扫描物品地图并查找物品标记。收集物品将增加玩家的分数,因此你可以添加一个变量来跟踪这一点:

signal score_changed
var item_scene = load("res://items/item.tscn")
var score = 0: set = set_score
func spawn_items():
    var item_cells = $Items.get_used_cells(0)
    for cell in item_cells:
        var data = $Items.get_cell_tile_data(0, cell)
        var type = data.get_custom_data("type")
        var item = item_scene.instantiate()
        add_child(item)
        item.init(type, $Items.map_to_local(cell))
        item.picked_up.connect(self._on_item_picked_up)
func _on_item_picked_up():
    score += 1
func set_score(value):
    score = value
    score_changed.emit(score)

spawn_items()函数使用get_used_cells()来获取一个列表,列出TileMap中哪些单元格不为空。这些单元格位于_ 地图坐标 _,而不是像素坐标,因此,当你生成物品时,你可以使用map_to_local()来转换这些值。

标记瓦片有一个宝石樱桃。这被用来告诉新实例它应该是什么类型的物品。

score变量用于跟踪玩家收集了多少物品。你可以使用这个触发器来完成等级,提供奖励等等。

spawn_items()添加到_ready()并尝试运行等级。你应该会看到你在添加的地方出现了宝石和樱桃。同时,检查它们在你收集它们时是否消失。

添加危险物体

危险地图层被设计用来存放当被触碰时会伤害玩家的尖刺物体。这个TileMap上的任何瓦片都会对玩家造成伤害!尝试将几个它们放置在你容易测试撞到它们的地方。

危险瓦地图中将其添加到名为danger的组中,这样你就可以在碰撞时轻松识别它。这还将允许你在将它们添加到同一组时创建其他有害物体。

关于滑动碰撞

CharacterBody2D节点使用move_and_slide()移动时,它可能在同一帧的移动中与多个对象发生碰撞。例如,当撞到角落时,身体可能会同时撞到墙和地板。你可以使用get_slide_collision_count()函数来找出发生了多少次碰撞;然后,你可以使用get_slide_collision()获取每次碰撞的信息。

对于Player,你想要检测当与Danger瓦片地图发生碰撞时。你可以在player.gd中使用move_and_slide()之后这样做:

if state == HURT:
    return
for i in get_slide_collision_count():
    var collision = get_slide_collision(i)
    if collision.get_collider().is_in_group("danger"):
        hurt()

注意,在检查与danger组发生碰撞之前,你可以首先检查玩家是否已经处于HURT状态。如果是,你可以跳过检查他们是否与危险物体发生碰撞。

for循环遍历由get_slide_collision_count()给出的碰撞次数,以检查每个碰撞中的危险组对象。

播放你的场景,并尝试撞到其中一个尖刺。你应该看到玩家在短暂地变为HURT状态(播放动画)后返回到IDLE状态。经过三次打击后,玩家将进入DEAD状态,目前这只会隐藏玩家。

滚动背景

res://assets/environment/文件夹中有两个背景图像:back.pngmiddle.png,分别用于远背景和近背景。通过将这些图像放置在瓦片地图后面,并以相对于相机的不同速度滚动,你可以在背景中创建一个吸引人的深度错觉:

  1. ParallaxBackground节点添加到LevelBase场景中(这样它就会出现在所有继承的级别中)。这个节点与相机一起创建滚动效果。将此节点拖到场景树的最顶部,以便它会在其他节点之后被绘制。接下来,添加一个ParallaxLayer节点作为其子节点。ParallaxBackground可以有任意数量的ParallaxLayer子节点,允许你创建多个独立滚动的层。

  2. Sprite2D节点添加为ParallaxLayer的子节点,并将back.png图像拖放到其Sprite2D节点的(``1.5, 1.5)

  3. ParallaxLayer上设置(0.2, 1)(你需要分别点击xy值来单独设置)。这个设置控制背景相对于相机移动的速度。通过将其设置为小于1的数字,当玩家左右移动时,图像只会移动一小段距离。

  4. 如果你水平方向上的关卡宽度大于图像的大小,你需要确保图像重复,所以设置(576, 0)。这正好是图像的宽度(384乘以1.5),所以当图像移动了这么多像素时,它将会重复。

  5. 注意,这个背景图像是为宽度较宽而不是高度较高的关卡设计的。如果你跳得太高,你会看到图像的顶部。你可以通过设置摄像机的顶部限制来修复这个问题。如果你没有移动背景的位置,其左上角仍然在 (0, 0),所以你可以设置 0。如果你已经移动了 ParallaxLayer 或其 Sprite2D 节点,你可以通过查看节点 Positiony 值来找到正确的值。

  6. 尝试播放关卡并左右移动。你应该会看到背景相对于你跑的距离移动了一小部分。

  7. 添加另一个 ParallaxLayer(也作为 ParallaxBackground 的子节点),并给它一个 Sprite2D 子节点。这次,使用 middle.png 图像。这个图像比天空图像窄得多,所以你需要调整一些设置来使其正确重复。这是因为 ParallaxBackground 需要图像至少与视口区域一样大。

  8. 找到 Sprite2D 节点的 Mirror。然后,扩展 (880, 368)880 是图像宽度 (176) 乘以 5,所以你现在将看到五个图像的重复,每个都是上一个图像的镜像。

  9. Sprite2D 节点移动,使图像与海洋/天空图像的下半部分重叠:

图 4.16:平行背景设置

图 4.16:平行背景设置

  1. 设置第二个 ParallaxLayer 节点的 (0.6, 1)880, 0)。使用更高的缩放因子意味着这个层将比它后面的云层滚动得更快。播放场景以测试效果。

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

图 4.17:关卡场景节点

图 4.17:关卡场景节点

你的关卡场景现在拥有了创建关卡设计所需的所有部件。你希望玩家必须进行非常精确的跳跃(跑酷关卡),穿过一系列蜿蜒的通道试图找到所有物品(迷宫关卡),或者两者的组合?这是你尝试一些创意想法的机会,但请确保为下一个要创建的对象(敌人)留出一些空间。

添加敌人

你可以为敌人添加许多不同的行为。对于这个游戏,敌人将沿着一个平台直线行走,并在碰到障碍物时改变方向。

场景设置

如前所述,你需要创建一个新的场景来表示敌人:

  1. 从一个名为 EnemyCharacterBody2D 节点开始,并给它三个子节点:Sprite2DCollisionShape2DAnimationPlayer

  2. 在名为 enemies 的文件夹中保存场景。如果你决定为游戏添加更多敌人类型,你都可以在这里保存。

  3. 将身体的碰撞 设置为 敌人,其 遮罩 设置为 环境玩家敌人。与玩家一样,这决定了敌人会与哪些类型的对象发生碰撞。

  4. 将敌人分组在一起也很有用,所以点击 enemies

  5. res://assets/sprites/opossum.png 添加到 6

  6. 添加一个矩形碰撞形状,覆盖图像的大部分(但不是全部),确保碰撞形状的底部与负鼠的脚底对齐:

图 4.18:敌人碰撞形状

图 4.18:敌人碰撞形状

  1. AnimationPlayer添加一个新的动画,命名为walk。设置为0.6秒,并开启循环加载时自动播放

  2. walk动画需要有两个轨道:一个设置Sprite2D节点,另一个在时间零时将其0设置为5。别忘了将更新模式改为连续

完成后,你的动画应该看起来像这样:

图 4.19:敌人动画

图 4.19:敌人动画

敌人脚本编写

到现在为止,移动CharacterBody2D节点应该已经熟悉了。看看这个脚本,在阅读解释之前尝试理解它在做什么:

extends CharacterBody2D
@export var speed = 50
@export var gravity = 900
var facing = 1
func _physics_process(delta):
    velocity.y += gravity * delta
    velocity.x = facing * speed
    $Sprite2D.flip_h = velocity.x > 0
    move_and_slide()
    for i in get_slide_collision_count():
        var collision = get_slide_collision(i)
        if collision.get_collider().name == "Player":
            collision.get_collider().hurt()
        if collision.get_normal().x != 0:
            facing = sign(collision.get_normal().x)
            velocity.y = -100
    if position.y > 10000:
        queue_free()

在这个脚本中,facing变量跟踪x方向上的移动,要么是1要么是-1。与玩家一样,移动后你必须检查滑动碰撞。如果碰撞的对象是玩家,你必须调用它的hurt()函数。

接下来,你必须检查碰撞体的x分量是否不为0。这意味着它指向左边或右边,这意味着它是一堵墙或其他障碍物。然后使用法线方向设置新的朝向。给身体一个小的向上速度,当敌人转身时会有一个小弹跳效果,这会使它看起来更吸引人。

最后,如果由于某种原因敌人从平台上掉下来,你不想让游戏必须跟踪它永远掉落,所以你必须删除任何y坐标变得太大的敌人。

Enemy实例添加到你的关卡场景中。确保它两侧有一些障碍物,并播放场景。检查敌人是否在障碍物之间来回走动。尝试将玩家放在它的路径上,并验证玩家的hurt()函数是否被调用。

你可能会注意到,如果你跳到敌人身上,什么也不会发生。我们将在下一部分处理这个问题。

伤害敌人

如果玩家不能反击,那就太不公平了,所以按照马里奥的传统,跳到敌人上方可以击败它。

首先,向敌人的AnimationPlayer节点添加一个新的动画,命名为death。设置为0.30.05。不要为这个动画开启循环。

死亡动画也会在动画的开始和结束时将res://assets/sprites/enemy_death.png图像设置为精灵的Frame05值。请记住将更新模式设置为连续

将以下代码添加到enemy.gd中,以便你有触发敌人死亡动画的方法:

func take_damage():
    $AnimationPlayer.play("death")
    $CollisionShape2D.set_deferred("disabled", true)
    set_physics_process(false)

当玩家在正确条件下击中敌人时,它会调用take_damage()函数,播放死亡动画,禁用碰撞,并停止移动。

当死亡动画播放完毕后,可以移除敌人,因此连接 AnimationPlayeranimation_finished 信号:

图 4.20:AnimationPlayer 的信号

图 4.20:AnimationPlayer 的信号

此信号在每次任何动画播放完毕时都会被调用,因此你需要检查它是否是正确的:

func _on_animation_player_animation_finished(anim_name):
    if anim_name == "death":
        queue_free()

要完成此过程,请转到 player.gd 脚本,并在检查碰撞的 _physics_process() 部分添加以下代码。此代码将检查玩家是否从上方击中敌人:

for i in get_slide_collision_count():
    var collision = get_slide_collision(i)
    if collision.get_collider().is_in_group("danger"):
        hurt()
    if collision.get_collider().is_in_group("enemies"):
        if position.y < collision.get_collider().position.y:
            collision.get_collider().take_damage()
            velocity.y = -200
        else:
            hurt()

此代码比较玩家脚跟的 y 位置和敌人的 y 位置,以查看玩家是否在敌人上方。如果是,敌人应该受伤;否则,玩家应该受伤。

再次运行关卡并尝试跳到敌人身上以检查一切是否按预期工作。

玩家脚本

你已经对玩家的脚本做了几个添加。现在完整的脚本应该看起来像这样:

extends CharacterBody2D
signal life_changed
signal died
@export var gravity = 750
@export var run_speed = 150
@export var jump_speed = -300
enum {IDLE, RUN, JUMP, HURT, DEAD}
var state = IDLE
var life = 3: set = set_life
func _ready():
    change_state(IDLE)
func change_state(new_state):
    state = new_state
    match state:
        IDLE:
            $AnimationPlayer.play("idle")
        RUN:
            $AnimationPlayer.play("run")
        HURT:
            $AnimationPlayer.play("hurt")
            velocity.y = -200
            velocity.x = -100 * sign(velocity.x)
            life -= 1
            await get_tree().create_timer(0.5).timeout
            change_state(IDLE)
        JUMP:
            $AnimationPlayer.play("jump_up")
        DEAD:
            died.emit()
            hide()
func get_input():
    if state == HURT:
        return
    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
        $Sprite2D.flip_h = false
    if left:
        velocity.x -= run_speed
        $Sprite2D.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 in the air
    if state in [IDLE, RUN] and !is_on_floor():
        change_state(JUMP)
func _physics_process(delta):
    velocity.y += gravity * delta
    get_input()
    move_and_slide()
    if state == HURT:
        return
    for i in get_slide_collision_count():
        var collision = get_slide_collision(i)
        if collision.get_collider().is_in_group("danger"):
            hurt()
        if collision.get_collider().is_in_group("enemies"):
            if position.y <
            collision.get_collider().position.y:
                collision.get_collider().take_damage()
                velocity.y = -200
            else:
                hurt()
    if state == JUMP and is_on_floor():
        change_state(IDLE)
    if state == JUMP and velocity.y > 0:
        $AnimationPlayer.play("jump_down")
func reset(_position):
    position = _position
    show()
    change_state(IDLE)
    life = 3
func set_life(value):
    life = value
    life_changed.emit(life)
    if life <= 0:
        change_state(DEAD)
func hurt():
    if state != HURT:
        change_state(HURT)

如果你在玩家代码方面遇到任何问题,试着想想可能出问题的部分。是移动?遇到敌人时的碰撞检测?如果你能缩小问题范围,这将帮助你确定应该关注脚本的哪个部分。

在继续到下一节之前,确保你对玩家的行为满意。

游戏用户界面

正如你在之前的项目中所做的那样,你需要一个 HUD 来在游戏过程中显示信息。收集物品会增加玩家的分数,因此应该显示这个数字,以及玩家的剩余生命值,这将以一系列心形图案显示。

场景设置

创建一个新的场景,根节点为 MarginContainer 并命名为 HUD,将其保存在一个新的 ui 文件夹中。设置左右边距为 50,上下边距为 20

添加一个 HBoxContainer 节点以保持对齐并给它两个子节点,分别命名为 LabelHBoxContainer,分别是 ScoreLifeCounter

Score 标签上设置 100,并在检查器中,在 res://assets/Kenney Thick.ttf 中将其设置为 48。在 16100 显示为白色,带有黑色轮廓。

对于 LifeCounter,添加一个 TextureRect 子节点并命名为 L1。将 res://assets/heart.png 拖入其 L1 并复制 (Ctrl + D) 四次,以便你有一排五个心形图案:

图 4.21:HUD 节点设置

图 4.21:HUD 节点设置

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

图 4.22:HUD 预览

图 4.22:HUD 预览

下一步将是添加一个脚本,以便在游戏过程中更新 HUD。

编写 HUD 脚本

此脚本需要两个可以调用的函数来更新显示的两个值:

extends MarginContainer
@onready var life_counter = $HBoxContainer/LifeCounter.get_children()
func update_life(value):
    for heart in life_counter.size():
        life_counter[heart].visible = value > heart
func update_score(value):
    $HBoxContainer/Score.text = str(value)

注意,在 update_life() 中,你通过将心形图案的数量设置为 false 来计算显示多少心形图案,如果该心形图案的数量小于生命值。

连接 HUD

打开level_base.tscn(基础场景,不是你的Level01场景)并添加CanvasLayer。将HUD实例作为此Canvaslayer的子节点添加。

选择Player实例并连接其life_changed信号到 HUD 的update_life()方法:

图 4.23:连接信号

图 4.23:连接信号

以相同的方式处理Level节点的score_changed信号,将其连接到 HUD 的update_score()方法。

注意,如果你不想使用场景树来连接信号,或者如果你觉得信号连接窗口令人困惑或难以使用,你可以在level.gd_ready()函数中添加这些行来完成相同的事情:

$Player.life_changed.connect($CanvasLayer/HUD.update_life)
score_changed.connect($CanvasLayer/HUD.update_score)

玩游戏并验证你能否看到 HUD 并且它是否正确更新。确保你收集一些物品并让敌人攻击你。你的分数是否在增加?当你被击中时,你是否失去一颗心?一旦你检查了这些,你就可以继续到下一节并制作标题屏幕。

标题屏幕

标题屏幕是玩家首先看到的内容,当玩家死亡和游戏结束时,游戏将返回到这个屏幕。

场景设置

从一个Control节点开始,并使用back.png图像设置TextureRect节点。将布局设置为全矩形,将拉伸模式设置为保持 纵横比

添加另一个TextureRect,这次使用middle.png并将拉伸模式设置为平铺。拖动矩形的宽度直到它比屏幕宽,并调整它以覆盖下半部分。

添加两个名为TitleMessageLabel节点,分别设置它们的Jungle JumpPress Space to Play。像之前一样为每个添加字体,将标题的大小设置为72,将消息的大小设置为48。将标题的布局设置为居中,将消息的布局设置为居中底部

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

图 4.24:标题屏幕

图 4.24:标题屏幕

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

例如,在时间0.5处为当前的Title设置关键帧。然后,在时间0处将Title拖离屏幕顶部并添加另一个关键帧。现在,当你播放场景时,标题将掉落到屏幕上。

随意添加可以动画化其他节点属性的轨道。例如,这里有一个动画,将标题向下移动,淡入两个纹理,然后使消息出现:

图 4.25:标题屏幕动画

图 4.25:标题屏幕动画

这个标题屏幕被保持得很简单,但如果你愿意,可以自由地添加内容。你可以展示一些平台示例,添加一个角色在屏幕上奔跑的动画,或者一些其他游戏艺术作品。但是当玩家点击“开始”时会发生什么?为此,你需要加载主场景中的第一个关卡。

设置主场景

你已经创建了一些关卡场景,但最终你将想要创建更多。游戏如何知道加载哪一个?你的Main场景将负责处理这个问题。

在测试玩家移动时,删除你添加到main.tscn中的任何额外节点。现在这个场景将负责加载当前关卡。然而,在它能够这样做之前,你需要一种方法来跟踪当前关卡。你无法在关卡场景中跟踪这个变量,因为当它结束时,它将被新加载的关卡所替换。为了跟踪需要从场景到场景携带的数据,你可以使用自动加载

关于自动加载

在 Godot 中,你可以配置一个脚本或场景作为自动加载。这意味着引擎将始终自动加载它。即使你更改了SceneTree中的当前场景,自动加载的节点也将保持。你还可以从游戏中的任何其他节点通过名称引用该自动加载的场景。

game_state.gd中添加以下代码:

extends Node
var num_levels = 2
var current_level = 0
var game_scene = "res://main.tscn"
var title_screen = "res://ui/title.tscn"
func restart():
    current_level = 0
    get_tree().change_scene_to_file(title_screen)
func next_level():
    current_level += 1
    if current_level <= num_levels:
        get_tree().change_scene_to_file(game_scene)

你应该将num_levels设置为你在levels文件夹中创建的关卡数量。确保它们被一致地命名为level_01.tscnlevel_02.tscn等等,这样它们就可以很容易地被找到。

要将此脚本作为自动加载添加,打开game_state.gd然后点击添加按钮。

接下来,将此脚本添加到你的Main场景:

extends Node
func _ready():
    var level_num = str(GameState.current_level).pad_zeros(2)
    var path = "res://levels/level_%s.tscn" % level_num
    var level = load(path).instantiate()
    add_child(level)

现在,每次加载Main场景时,它将包括与当前关卡相对应的关卡场景。

标题屏幕需要过渡到游戏场景,所以将此脚本附加到Title节点:

extends Control
func _input(event):
    if event.is_action_pressed("ui_select"):
        GameState.next_level()

最后,当玩家死亡时,通过将其添加到level.gd中调用restart()函数。在Level场景中,连接Player实例的died信号:

func _on_player_died():
    GameState.restart()

现在,你应该能够完整地玩过游戏。确保title.tscn被设置为游戏的主场景(即首先运行的场景)。如果你之前将不同的场景设置为“主”场景,你可以在项目设置下的应用程序/运行中更改此设置:

图 4.26:选择主场景

图 4.26:选择主场景

在关卡之间过渡

你的关卡现在需要一种从一关过渡到下一关的方法。在res://assets/environment/props.png精灵图中,有一个你可以用于关卡出口的门图像。找到并走进门将玩家带到下一关。

门场景

创建一个新的场景,并命名为DoorArea2D节点,并将其保存在items文件夹中。添加一个Sprite2D节点,并使用props.png图像作为-8。这将确保当门放置在图块位置时,它将被正确定位。

添加一个CollisionShape2D节点,并给它一个覆盖门的矩形形状。将门放在items层上,并设置其遮罩,使其只扫描player层。

这个场景不需要脚本,因为你只是要在关卡脚本中使用它的body_entered信号。

要在关卡中放置门,你可以使用tiles_items图块集中的门对象,你正在使用它来放置樱桃和宝石的Items图块。在你的关卡中放置一个门并打开level.gd

level.gd顶部定义门场景:

var door_scene = load("res://items/door.tscn")

然后,更新spawn_items()以便它也能实例化门:

func spawn_items():
    var item_cells = $Items.get_used_cells(0)
    for cell in item_cells:
        var data = $Items.get_cell_tile_data(0, cell)
        var type = data.get_custom_data("type")
        if type == "door":
            var door = door_scene.instantiate()
            add_child(door)
            door.position = $Items.map_to_local(cell)
            door.body_entered.connect(_on_door_entered)
        else:
            var item = item_scene.instantiate()
            add_child(item)
            item.init(type, $Items.map_to_local(cell))
            item.picked_up.connect(self._on_item_picked_up)

添加当玩家触摸门时将被调用的函数:

func _on_door_entered(body):
    GameState.next_level()

玩游戏并尝试走进门。如果你在game_state.gd中将num_levels设置为大于 1 的数字,当你触摸门时,游戏将尝试加载level_02.tscn

屏幕设置

回想一下,在本章开始时,你分别设置了canvas_itemsexpand。运行游戏,然后尝试调整游戏窗口的大小。注意,如果你使窗口变宽,玩家可以看到更多游戏世界在左侧/右侧。这就是expand值的作用。

如果你想要防止这种情况发生,你可以将其设置为keep,这样就会始终显示与摄像机显示相同数量的游戏世界。然而,这也意味着如果你将窗口形状调整为与游戏不同,你将得到黑色条带来填充额外的空间。

或者,设置ignore将不会显示黑色条带,但游戏内容将被拉伸以填充空间,从而扭曲图像。

抽出一些时间来尝试不同的设置,并决定你更喜欢哪一个。

最后的修饰

现在你已经完成了游戏的主要结构,并且希望为玩家设计几个关卡来享受,你可以考虑添加一些功能来改善游戏体验。在本节中,你将找到一些额外的建议功能——直接添加或根据你的喜好进行调整。

音效

与之前的项目一样,你可以添加音效和音乐来提升体验。在res://assets/audio/中,你可以找到用于不同游戏事件(如玩家跳跃、敌人击中和物品拾取)的音频文件。还有两个音乐文件:Intro Theme用于标题屏幕,Grasslands Theme用于关卡场景。

将这些添加到游戏中将由你来决定,但这里有一些提示:

  • 你可能会发现调整单个声音的音量很有帮助。这可以通过Volume dB属性来设置。设置负值将降低声音的音量。

  • 您可以将音乐附加到主 level.tscn 场景;该音乐将用于所有关卡。如果您想设定某种氛围,也可以为单个关卡附加单独的音乐。

  • 您的第一个想法可能是将 AudioStreamPlayer 放在 Item 场景中以播放拾取声音。然而,由于拾取物在玩家触摸时被删除,这不会很好地工作。相反,将音频播放器放在 Level 场景中,因为那里处理了拾取物的结果(增加分数)。

双重跳跃

双重跳跃是流行的平台游戏功能。如果玩家在空中按下跳跃键第二次,他们将获得第二次,通常是较小的向上提升。要实现此功能,您需要向玩家脚本中添加一些内容。

首先,您需要变量来跟踪跳跃次数并确定第二次提升的大小:

@export var max_jumps = 2
@export var double_jump_factor = 1.5
var jump_count = 0

当进入 JUMP 状态时,重置跳跃次数:

JUMP:
    $AnimationPlayer.play("jump_up")
    jump_count = 1

get_input() 中,如果满足条件,允许跳跃。将此放在检查玩家是否在地板上的 if 语句之前:

if jump and state == JUMP and jump_count < max_jumps and jump_count > 0:
    $JumpSound.play()
    $AnimationPlayer.play("jump_up")
    velocity.y = jump_speed / double_jump_factor
    jump_count += 1

_physics_process() 中,当您落地时,重置跳跃计数:

if state == JUMP and is_on_floor():
    change_state(IDLE)
    jump_count = 0

玩您的游戏并尝试双跳。请注意,此代码使第二次跳跃的大小为初始跳跃向上速度的 2/3。您可以根据您的喜好进行调整。

灰尘颗粒

在角色的脚下生成灰尘颗粒是一种低成本的特效,可以为玩家的动作增添很多特色。在本节中,您将为玩家落地时发出的少量灰尘添加一个轻微的喷溅效果。这为玩家的跳跃增添了重量感和冲击感。

CPUParticles2D 节点添加到 Player 场景,并将其命名为 Dust。设置以下属性:

属性
数量 20
寿命 0.45
一次性 On
速度缩放 2
爆炸性 0.7
发射形状 Rectangle
矩形范围 1, 6
初始速度最大 10
最大缩放量 3
位置 -``2, 0
旋转 -``90

默认的颗粒颜色是白色,但灰尘效果在棕褐色中看起来会更好。它还应该逐渐消失,以便看起来像是在消散。这可以通过 Gradient 实现。在 颜色/颜色渐变 区域,选择 新建渐变

Gradient 有两种颜色:左侧的起始颜色和右侧的结束颜色。这些可以通过渐变两端的矩形选择。点击右侧的大方块允许您为选定的矩形设置颜色:

图 4.27:颜色渐变

图 4.27:颜色渐变

将起始颜色设置为棕褐色,并将结束颜色设置为相同的颜色,但将透明度值设置为 0。您应该看到一个持续冒烟的效果。在检查器中,将 一次性 设置为开启。现在,每次您勾选 发射 复选框时,颗粒只会发射一次。

随意更改这里提供的属性。实验粒子效果可以非常有趣,而且通常,你只需稍微调整就能发现一个非常棒的效果。

一旦你对它的外观满意,将以下内容添加到玩家的_physics_process()代码中:

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

运行游戏并观察每次你的角色落地时都会出现的灰尘。

梯子

玩家精灵图包括攀爬动画的帧,而瓦片集包含梯子图像。目前,梯子瓷砖没有任何作用——在TileSet中,它们没有分配任何碰撞形状。这是可以的,因为你不希望玩家与梯子碰撞——你希望他们能够在上面上下移动。

玩家代码

首先,选择玩家的AnimationPlayer节点,并添加一个名为climb的新动画。它的0.4应该设置为Sprite2D,其动画顺序为0 -> 1 -> 0 -> 2

前往player.gd并添加一个新的状态,CLIMB,到state枚举中。此外,在脚本顶部添加两个新的变量声明:

@export var climb_speed = 50
var is_on_ladder = false

你将使用is_on_ladder来跟踪玩家是否在梯子上。使用这个,你可以决定上下动作是否应该有任何效果。

change_state()中,为新的状态添加一个条件:

CLIMB:
    $AnimationPlayer.play("climb")

get_input()中,你需要检查输入动作,然后确定它们是否改变状态:

var up = Input.is_action_pressed("climb")
var down = Input.is_action_pressed("crouch")
if up and state != CLIMB and is_on_ladder:
    change_state(CLIMB)
if state == CLIMB:
    if up:
        velocity.y = -climb_speed
        $AnimationPlayer.play("climb")
    elif down:
        velocity.y = climb_speed
        $AnimationPlayer.play("climb")
    else:
        velocity.y = 0
        $AnimationPlayer.stop()
if state == CLIMB and not is_on_ladder:
    change_state(IDLE)

在这里,你有三个新的条件需要检查。首先,如果玩家不在CLIMB状态但站在梯子上,按下向上键应该使玩家开始攀爬。其次,如果玩家正在攀爬,上下输入应该使他们在梯子上上下移动,但如果没有任何动作被按下,则停止动画播放。最后,如果玩家在攀爬时离开梯子,他们将离开CLIMB状态。

你还需要确保当玩家站在梯子上时,重力不会将他们向下拉。在_physics_process()中的重力代码中添加一个条件:

if state != CLIMB:
    velocity.y += gravity * delta

现在,玩家已经准备好攀爬,这意味着你可以在关卡中添加一些梯子。

关卡设置

将名为LaddersArea2D节点添加到Level场景中,但暂时不要给它添加碰撞形状。连接其body_enteredbody_exited信号,并设置其碰撞itemsplayer。这确保了只有玩家可以与梯子交互。这些信号是你让玩家知道他们是否在梯子上的方式:

func _on_ladders_body_entered(body):
    body.is_on_ladder = true
func _on_ladders_body_exited(body):
    body.is_on_ladder = false

现在,关卡需要查找任何梯子瓷砖,并在找到时将碰撞形状添加到Ladders区域。将以下函数添加到level.gd中,并在_ready()中调用它:

func create_ladders():
    var cells = $World.get_used_cells(0)
    for cell in cells:
        var data = $World.get_cell_tile_data(0, cell)
        if data.get_custom_data("special") == "ladder":
            var c = CollisionShape2D.new()
            $Ladders.add_child(c)
            c.position = $World.map_to_local(cell)
            var s = RectangleShape2D.new()
            s.size = Vector2(8, 16)
            c.shape = s

注意,你添加的碰撞形状只有8像素宽。如果你使形状与梯子瓷砖的整个宽度相同,那么玩家看起来就像是在攀爬,即使他们悬挂在一边,这看起来有点奇怪。

尝试一下 - 前往您的关卡场景之一,并将一些梯子瓦片放置在您想要的 World 瓦片地图上的任何位置。播放场景并尝试爬梯子。

注意,如果您在梯子的顶部并踩到它,您会掉到下面而不是爬下来(尽管在掉落时按上键会使您抓住梯子)。如果您希望自动过渡到攀爬状态,您可以在 _physics_process() 中添加一个额外的坠落检查。

移动平台

移动平台是关卡设计工具包中的一项有趣的功能。在本节中,您将制作一个可以在关卡上的任何位置放置的移动平台,并设置其移动和速度。

使用 Node2D 节点创建一个新场景,并将其命名为 MovingPlatform。保存场景并将 TileMap 添加为子节点。由于您的平台艺术全部在精灵图中,并且它们已经被切割成瓦片并添加了碰撞,这将使您的平台易于绘制。将 tiles_world.tres 添加为瓦片集。您还需要勾选可动画碰撞框,这将确保即使在移动时碰撞也能正常工作。

TileMap 中绘制一些瓦片,但请确保从原点 (0, 0) 开始,以便事物可以整齐排列。这些瓦片非常适合浮动平台:

图 4.28:浮动平台

图 4.28:浮动平台

将脚本添加到根节点,并从以下变量开始:

@export var offset = Vector2(320, 0)
@export var duration = 10.0

这些将允许您设置移动量和速度。offset 是相对于起始点的,由于它是一个 Vector2 节点,您可以有水平、垂直或对角线移动的平台。duration 以秒为单位,表示完整周期将花费多长时间。

平台将始终在移动,因此您可以在 _ready() 中开始动画。它将使用 tween 方法通过两步来动画化位置:从起始位置到偏移位置,然后反过来:

func _ready():
    var tween = create_tween().set_process_mode(
        Tween.TWEEN_PROCESS_PHYSICS)
    tween.set_loops().set_parallel(false)
    tween.tween_property($TileMap, "position", offset,
        duration / 2.0).from_current()
    tween.tween_property($TileMap, "position",
        Vector2.ZERO, duration / 2.0)

这里有一些关于缓动使用的小贴士:

  • 您需要设置进程模式,以便移动将与物理同步,玩家将能够正确地与平台碰撞(即站在上面)。

  • set_loops() 告诉 tween 在完成后重复一次。

  • set_parallel(false) 告诉 tween 按顺序执行两个属性缓动,而不是同时执行。

  • 您还可以尝试其他缓动曲线。例如,添加 tween.set_trans(Tween.TRANS_SINE) 将使平台在移动的末端减速,以获得更自然的外观。尝试使用其他过渡类型进行实验。

现在,您可以将 MovingPlatform 的实例添加到关卡场景中。为了确保一切排列正确,请确保您已启用网格吸附:

图 4.29:启用网格吸附

图 4.29:启用网格吸附

默认值是 (8, 8),但您可以通过点击图标旁边的三个点并选择配置吸附来更改它。

当你现在运行游戏时,你将有更多可以与之互动的内容。梯子和移动平台给你的关卡设计提供了更多可能性。但你不一定要止步于此!考虑到本章中你所做的一切,还有很多其他功能你可以添加。玩家的动画包括一个“蹲下”动画——如果敌人能向玩家投掷可以被躲过的东西会怎样?许多平台游戏包括额外的移动机制,如沿着斜坡滑动、墙壁跳跃、改变重力等等。选择一个,看看你是否可以添加它。

摘要

在本章中,你学习了如何使用 CharacterBody2D 节点为玩家移动创建街机风格的物理效果。这是一个功能强大的节点,可以用于各种游戏对象——而不仅仅是平台角色。

你学习了关于用于关卡设计的 TileMap 节点的知识——这是一个功能强大的工具,甚至比你在本项目中使用的功能还要多。关于你可以用它做的一切,可以写整整一章。更多信息,请参阅 Godot 文档网站上的 使用 TileMaps 页面:https://docs.godotengine.org/en/latest/tutorials/2d/using_tilemaps.html。

Camera2DParallaxBackground 也是任何希望在世界中移动的游戏中的关键工具,这个世界比屏幕大小还要大。特别是相机节点,你将在大多数 2D 项目中使用它。

你还广泛地使用了在早期项目中学到的知识来将所有内容串联起来。希望到这一点,你已经很好地掌握了场景系统以及 Godot 项目的结构。

在继续之前,花几分钟时间玩你的游戏,浏览其各种场景和脚本,以回顾你是如何构建它的。回顾任何你觉得特别棘手的章节内容。最重要的是,在继续之前,尝试对项目进行一些修改。

在下一章,你将进入 3D 世界!

第五章:3D 迷你高尔夫:通过构建迷你高尔夫球场深入 3D

本书前面的项目都是设计在 2D 空间中的。这是故意的,为了在保持项目范围有限的同时介绍 Godot 的功能和概念。在这一章中,你将进入游戏开发的 3D 领域。对于一些人来说,3D 开发感觉管理起来要困难得多。对于其他人来说,它可能更直接。无论如何,你确实需要理解一个额外的复杂层。

如果你之前从未使用过任何类型的 3D 软件,你可能会发现自己遇到了许多新概念。这一章将尽可能多地解释它们,但请记住,在需要更深入理解特定主题时,务必参考 Godot 文档。

你在本章中将要制作的游戏叫做3D 迷你高尔夫。在其中,你将构建一个小型迷你高尔夫球场、一个球和一个瞄准并射击球向洞的方向的界面。

本章中你将学习到的一些内容如下:

  • 导航 Godot 的 3D 编辑器

  • Node3D及其属性

  • 导入 3D 网格和使用 3D 碰撞形状

  • 如何使用 3D 相机

  • 设置灯光和环境

  • PBR 和材质简介

在深入之前,简要介绍 Godot 中的 3D。

技术要求

从以下链接下载游戏资源,并将其解压到你的新项目文件夹中:

github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Downloads

你也可以在 GitHub 上找到本章的完整代码:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Chapter05%20-%203D%20Minigolf

3D 简介

Godot 的一个优势是它能够处理 2D 和 3D 游戏。你在本书前面学到的许多内容在 3D 中同样适用——节点、场景、信号等。但是,从 2D 转换到 3D 也带来了一整个新的复杂性和功能层。首先,你会发现 3D 编辑器窗口中有一些额外的功能可用,熟悉如何导航是个好主意。

在 3D 空间中的定位

打开一个新的项目,然后在编辑器窗口顶部点击3D按钮,以查看 3D 项目视图:

图 5.1:3D 工作区

图 5.1:3D 工作区

你首先应该注意到的中心的三条彩色线条。这些是x(红色)、y(绿色)和z(蓝色)轴。它们相交的点就是(0, 0, 0)

3D 坐标

正如你使用Vector2(x, y)来表示 2D 空间中的位置一样,你将使用Vector3(x, y, z)来描述三维空间中的位置。

在 3D 工作时经常出现的一个问题是,不同的应用程序使用不同的方向约定。Godot 使用 x 指向左/右,然后 y 是上/下,z 是前/后。如果您使用其他流行的 3D 软件,您可能会发现其中一些使用 Z-Up。了解这一点是好的,因为它可以在在不同程序之间移动时导致混淆。

另一个需要注意的重要事项是度量单位。在 2D 中,Godot 以像素为单位测量一切,这在屏幕上绘制时作为测量的自然基础是有意义的。然而,当在 3D 空间中工作时,像素并不太有用。两个相同大小的对象将根据它们与摄像机的距离不同而占据屏幕上的不同区域(关于摄像机的更多信息即将揭晓)。因此,在 3D 空间中,Godot 中的所有对象都使用通用单位进行测量。虽然通常将它们称为“米”,但您可以根据游戏世界的比例自由命名这些单位:英寸、毫米,甚至光年。

Godot 的 3D 编辑器

在深入构建游戏之前,回顾如何在 3D 空间中导航将是有用的。视图摄像机使用鼠标和键盘控制:

  • 鼠标滚轮上下滚动: 在当前目标上放大/缩小

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

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

  • 右键按钮 + 拖动: 在原地旋转摄像机

注意,其中一些动作是基于摄像机目标或焦点的。要聚焦于空间中的某个对象,您可以选中它并按 F 键。

Freelook 导航

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

您还可以通过点击视口左上角的透视标签来影响摄像机的视图。在这里,您可以快速将摄像机定位到特定的方向,例如俯视图前视图

图 5.2:透视菜单

图 5.2:透视菜单

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

键盘快捷键

注意,这些菜单选项中的每一个都与一个键盘快捷键相关联。您可以通过点击 编辑器 -> 编辑器设置 -> 3D 来查看并调整您喜欢的键盘快捷键。

添加 3D 对象

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

图 5.3:带有操纵杆的 Node3D

图 5.3:带有 gizmo 的 Node3D

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

花几分钟时间进行实验,熟悉 gizmo。如果你发现自己迷失方向,可以删除节点并添加另一个。

有时候 gizmo 会碍事。你可以点击模式图标来限制自己只进行一种类型的变换:移动旋转缩放

图 5.4:选择模式图标

图 5.4:选择模式图标

Q/W/E/R键是这些按钮的快捷键,允许你快速在模式之间切换。

全局空间与局部空间

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

图 5.5:切换局部空间模式

图 5.5:切换局部空间模式

观察 gizmo 箭头的指向。现在它们沿着对象的局部轴而不是世界轴指向。当你点击并拖动箭头时,它们会相对于对象的自身旋转移动对象。你可以再次点击按钮切换回全局空间。在这些两种模式之间切换可以使放置对象到你想要的位置变得容易得多。

变换

查看 Inspector 中的Node3D。在变换部分,你会看到节点的位置旋转缩放属性。当你移动对象时,你会看到这些值发生变化。就像在 2D 中一样,这些值是相对于节点的父节点相对的。

这三个量共同构成了节点的transform属性,这是一个 Godot 的Transform3D对象。Transform3D有两个子属性:originbasisorigin属性表示物体的位置,而basis属性包含三个向量,这些向量定义了物体的局部坐标轴。当你处于局部空间模式时,想想 gizmo 中的三个轴箭头。

你将在本节后面了解如何使用这些属性。

网格

就像Node2D一样,Node3D节点没有自己的大小或外观。在 2D 中,你添加Sprite2D来显示节点上的纹理。在 3D 中,你通常想要添加一个网格。网格是三维形状的数学描述。它由称为顶点的点集合组成。这些顶点通过称为的线连接,多个边(至少三个)共同构成一个

例如,一个立方体由八个顶点、十二条边和六个面组成:

图 5.6:顶点、边和面

图 5.6:顶点、边和面

如果你曾经使用过 3D 设计软件,这对你来说可能已经熟悉了。如果你还没有,并且你对学习 3D 建模感兴趣,Blender是一个非常流行的开源工具,用于设计 3D 对象。你可以在互联网上找到许多教程和课程,帮助你开始使用 Blender。

原始形状

如果你还没有创建或下载 3D 模型,或者你只需要快速创建一个简单形状,Godot 有直接创建某些 3D 网格的能力。将一个MeshInstance3D节点作为你的Node3D节点的子节点,然后在检查器中查找网格属性:

图 5.7:添加新的网格

图 5.7:添加新的网格

这些预定义的形状被称为原始形状,它们代表了一组常用的有用形状。选择新建 BoxMesh,你将在屏幕上看到一个立方体出现。

导入网格

无论你使用什么建模软件,你都需要将你的模型导出为 Godot 可读的格式。Godot 支持多种文件格式用于导入:

  • glTF – 支持文本(.gltf)和二进制(.glb)版本

  • DAE (COLLADA) – 尽管是旧格式,但仍然受到支持

  • OBJ (Wavefront) – 受支持,但由于格式限制而有限

  • ESCN – Blender 可以导出的 Godot 特定文件格式

  • FBX – 一个具有有限支持的商业格式

推荐的格式是.gltf。它具有最多的功能,并且在 Godot 中得到了非常好的支持。有关从 Blender 导出.gltf文件以供 Godot 使用的详细信息,请参阅附录。

你将在本章后面看到如何导入一些预构建的.gltf场景。

摄像机

尝试运行带有你的立方体网格的场景。它在哪?在 3D 中,除非场景中有Camera3D摄像机,否则你不会在游戏视图中看到任何东西。添加一个,你将看到一个看起来像这样的新节点:

图 5.8:摄像机小部件

图 5.8:摄像机小部件

使用摄像机的辅助工具将其放置在稍微高于位置并指向立方体:

图 5.9:调整摄像机方向

图 5.9:调整摄像机方向

那个粉紫色、金字塔形状的对象被称为摄像机的视锥体。它表示摄像机的视角,可以变窄或变宽以影响摄像机的视野。视锥体顶部的三角形形状表示摄像机的“向上”方向。

当您在周围移动摄像机时,您可以在视口的右上角按下预览按钮来检查摄像机看到的内容。您可以尝试调整摄像机的位置并调整其FOV

方向

注意,摄像机的视锥体沿着transform.basis方向是物体的局部坐标轴集:

position += -transform.basis.z * speed * delta

这些新的概念和编辑器功能将帮助您在 3D 空间中导航和工作。如果您需要提醒某个特定 3D 相关术语的含义,请参考本节。在下一节中,您将开始设置您的第一个 3D 项目。

项目设置

现在您已经学会了如何在 Godot 的 3D 编辑器中导航,您就可以开始制作迷你高尔夫游戏了。与其他项目一样,从以下链接下载游戏资源,并将其解压到您的项目文件夹中。解压后的assets文件夹包含您完成游戏所需的图像、3D 模型和其他项目。

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

您会注意到assets中有几个不同的文件夹。courses文件夹包含一些预构建的迷你高尔夫洞,您可以尝试并比较您自己制作的洞。现在不要看它们——尝试按照步骤制作您自己的第一个。

这个游戏将使用左键点击作为输入。打开click,然后点击加号将左鼠标按钮输入添加到其中:

图 5.10:分配鼠标按钮输入

图 5.10:分配鼠标按钮输入

当您在项目设置中时,您还可以设置当游戏窗口大小调整时游戏的行为。在游戏过程中,用户可能会选择调整窗口大小,这可能会破坏您的 UI 布局或显示扭曲的游戏视图。为了防止这种情况,导航到显示/窗口部分并找到拉伸/模式设置。将其更改为视口

图 5.11:设置窗口拉伸模式

图 5.11:设置窗口拉伸模式

这就完成了项目的设置。现在,您可以继续构建游戏的第一个部分:迷你高尔夫球场。

创建课程

对于第一个场景,添加一个名为HoleNode3D节点并保存场景。就像在Jungle Jump中做的那样,您将创建一个通用的场景,包含任何洞所需的节点和代码,然后从这个场景继承以创建游戏中您想要的任意数量的单独洞。

接下来,向场景中添加一个GridMap节点。

理解网格地图

GridMap是您在本书早期使用的TileMap节点的 3D 等价物。它允许您使用一个由MeshLibrary集合(类似于TileSet)包含的网格(网格)并按网格排列。因为它在 3D 中操作,所以您可以按任意方向堆叠网格,尽管在这个项目中您将坚持一个平面。

创建网格库集合

res://assets/ 文件夹中,你可以找到一个预先生成的 MeshLibrary 功能 golf_tiles.tres,其中包含所有必要的课程部分以及它们的碰撞形状。

要创建自己的 MeshLibrary 函数,你需要制作一个包含你想要使用的单个网格的 3D 场景,为它们添加碰撞,然后将该场景导出为 MeshLibrary 集合。如果你打开 golf_tiles.tscn,你会看到用于创建 golf_tiles.tres 的原始场景。

在这个场景中,你会看到所有从 Blender 导入的单独高尔夫球场瓦片网格,它们最初是在 Blender 中建模的。为了给每个瓦片添加碰撞形状,Godot 提供了一个方便的快捷方式:选择一个网格,你会在视口顶部的工具栏中看到一个 网格 菜单:

图 5.12:网格菜单

图 5.12:网格菜单

使用网格的数据选择 StaticBody3D 节点和 CollisionShape3D 节点。

一旦所有碰撞都添加完毕,你可以选择 GridMap 可以使用。

绘制第一个洞

MeshLibrary 文件拖入 GridMap 节点。你会在编辑器视口的右侧看到一个可用的瓦片列表。

为了匹配瓦片的大小,设置 (1, 1, 1)

为了确保球与碰撞看起来很好,找到 0.5

图 5.13:使用物理材质

图 5.13:使用物理材质

尝试通过从列表中选择瓦片块并将其通过左键点击放置在场景中来绘制。你可以通过按 S 键在 y 轴周围旋转一个块。要删除瓦片,右键点击它。

现在,坚持简单的布局。当一切正常工作时,你可以变得复杂一些:

图 5.14:示例课程布局

图 5.14:示例课程布局

你可以查看游戏运行时的样子。将 Camera3D 功能添加到场景中,并将其移动到一个可以俯瞰课程的位置。记住,你可以按 预览 按钮检查相机看到的画面。

播放场景。你会注意到一切都非常暗,与编辑器窗口中的样子不同。默认情况下,3D 场景没有配置 环境光照

环境和照明

照明是一个复杂的主题。选择放置光源的位置以及它们的配置可以显著影响场景的外观。

Godot 在 3D 中提供了三个光照节点:

  • OmniLight3D:用于从所有方向发射的光,例如来自灯泡

  • DirectionalLight3D:来自远处的光源,例如阳光

  • SpotLight3D:从一个点投射出的锥形光,类似于手电筒或灯笼

除了放置单个光源外,你还可以使用 WorldEnvironment 节点设置 环境 光 – 由环境产生的光。

而不是从头开始,Godot 允许你使用工具栏中的按钮从编辑器窗口中看到的默认照明设置开始:

图 5.15:照明和环境设置

图 5.15:照明和环境设置

前两个按钮允许你切换预览太阳(方向光)和环境。请注意,环境不仅影响照明,还会生成天空纹理。

如果你点击三个点,你可以看到这些的默认设置。点击场景中的 WorldEnvironment 节点和 DirectionalLight3D 节点。

如果你放大你的网格,你可能会注意到阴影看起来不太好。默认的阴影设置需要调整,所以选择 DirectionalLight3D 并将 100 改为 40

添加球洞

现在你已经布置了球场,你需要一种方法来检测球是否掉入球洞。

添加一个名为 HoleArea3D 节点。这个节点与其 2D 版本完全一样——它可以在物体进入其定义的形状时发出信号。将一个 CollisionShape3D 子节点添加到区域中。在 0.250.08

Hole 放置在你为球场放置的球洞瓷砖的位置。确保圆柱形状不会投影到球洞顶部以上,否则球在还没有掉入时会被计为“在洞内”。你可能发现使用 Perspective 按钮并切换到 Top View 来确保它正确居中很有用:

图 5.16:定位球洞

图 5.16:定位球洞

你还需要标记球的起始位置,因此将一个名为 TeeMarker3D 节点添加到场景中。将其放置在你希望球开始的位置。确保将其放置在表面之上,这样球就不会在地面内部生成。

这样,你就完成了第一轮次的制作。花几分钟时间四处看看,确保你对布局满意。记住,这不应该是一个复杂或具有挑战性的布局。它将向玩家介绍游戏,你也会用它来测试一切是否正常工作。为此,你接下来需要创建高尔夫球。

制作球

由于球需要物理特性——重力、摩擦、与墙壁的碰撞等——因此 RigidBody3D 将是节点选择的最佳选择。刚体在 3D 中的工作方式与你在 2D 中使用的方式相似,你将使用相同的方法与它们交互,例如 _integrate_forces()apply_impulse()

创建一个新的场景,并添加一个名为 BallRigidBody3D 节点,然后保存它。

由于你需要一个简单的球体形状,而 Godot 包含原始形状,因此这里不需要复杂的 3D 模型。添加一个 MeshInstance3D 子节点,并在检查器中选择 New SphereMesh 作为 Mesh 属性。

默认大小太大,所以点击 0.050.1

添加一个 CollisionShape3D 节点,并给它一个 SphereShape3D。将其 0.05 设置与网格匹配。

测试球

Ball 场景的一个实例添加到你的课程中。将其放置在某个瓦片上方并播放场景。你应该看到球落下并落在地面上。

你也可以通过设置 y 轴向上来暂时给球一些运动。不要忘记在继续之前将其设置回 (0, 0, 0)

改善球碰撞

你可能已经注意到,在调整速度时,球有时会穿过墙壁和/或以奇怪的方式弹跳,尤其是如果你选择了一个高速值。你可以做几件事情来改善球的行为。

首先,你可以使用连续碰撞检测CCD)。使用 CCD 改变了物理引擎计算碰撞的方式。通常,引擎通过首先移动对象,然后测试和解决碰撞来运行。这很快,并且适用于大多数常见情况。当使用 CCD 时,引擎会沿着对象的路径预测其移动,并尝试预测碰撞可能发生的位置。这比默认行为(在计算上)要慢,尤其是在模拟许多对象时,但它要准确得多。由于你只有一个球,且环境非常小,因此 CCD 是一个好的选择,因为它不会引入任何明显的性能惩罚。你可以在检查器中找到它作为连续 CD

图 5.17:CCD 开关

图 5.17:CCD 开关

球也需要一点额外的动作,所以在 0.25。这个属性决定了碰撞会有多“弹跳”。值可以从 0(完全没有弹跳)到 1.0(最弹跳):

图 5.18:物理材质弹跳设置

图 5.18:物理材质弹跳设置

你也可能已经注意到球需要很长时间才能完全停下来。设置 0.51。这些值可以被认为是与空气阻力相似——使物体减速,无论是否与表面相互作用。增加这些值意味着玩家不需要等待那么长时间球才会停止移动,而且球在停止滚动后不会看起来像是在原地旋转。

你已经完成了球体的设置,但这里是一个好的地方可以暂停一下,确保在继续之前一切如你所愿。球感觉像是在弹跳和滚动吗?当它撞到墙壁时,弹跳是否过多或过少?当你对球的动作调整满意后,继续到下一部分,在那里你将设置如何发射球。

添加用户界面

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

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

  2. 射击:一个力量条上下移动。点击鼠标设置力量并发射球。

对准箭头

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

制作自己的模型

如果你熟悉使用单独的 3D 建模程序,如 Blender,请随意使用它来创建箭头网格而不是遵循以下步骤。只需将导出的模型放入你的 Godot 项目文件夹中,并用 MeshInstance3D 节点加载它。请参阅最后一章中有关直接从 Blender 导入模型的详细信息。

通过添加一个名为 ArrowNode3D 节点并为其添加一个 MeshInstance3D 子节点来开始一个新的场景。给这个网格赋予一个 BoxMesh 函数并设置盒子的 (0.5, 0.2, 2)。这将成为箭头的主体,但在继续之前,有一个问题。如果你旋转父节点,网格将围绕其中心旋转。你需要它围绕其末端旋转,所以将 MeshInstance3D 节点更改为 (0, 0, -1)。记住,这个属性是相对于节点的父节点测量的,所以这是将网格从 Node3D 节点偏移:

图 5.19:偏移基础

图 5.19:偏移基础

尝试使用 gizmo 旋转根节点(Arrow)以确认形状现在已正确偏移。

当它在游戏中查看时,箭头应该是半透明的。你也可以给它一个颜色,使其更加突出。要更改网格的视觉属性,你需要使用 材质

在网格属性(设置大小的地方)下,你会在该框中看到一个 StandardMaterial3D 节点:

图 5.20:偏移基础

图 5.20:偏移基础

如果你点击这个新的材质对象以展开它,你会看到一个长长的属性列表。别担心,你只需要更改其中两个。

首先,展开 透明度 部分,并将 透明度 设置为 Alpha。此属性告诉渲染引擎该对象可以允许光线通过。

接下来,对象的颜色在 128 中设置。

现在,为了创建箭头的尖端,添加另一个 MeshInstance3D 节点,这次选择一个 PrismMesh 网格。将其设置为 (1.5, 1.5, 0.2) 以获得一个平坦的三角形形状。为了将其放置在矩形的末端,将其更改为 (0, 0, -2.75)(-90, 0, 0)

最后,通过设置根节点的 0.25, 0.25, 0.25) 将整个箭头缩小。

你还需要像对待其他部分一样设置棱镜的材质。为此,快速选择盒子形状并再次找到其材质属性。在材质下拉菜单中选择 复制。然后你可以转到棱镜网格并将相同的材质粘贴到它上面。请注意,由于它们具有相同的材质,对其中一个形状所做的任何更改都将应用于两个形状:

图 5.21:定位箭头

图 5.21:定位箭头

你的瞄准箭头已完成。保存场景并将其实例化到你的 Hole 场景中。

UI 显示

使用名为 UICanvasLayer 层创建一个新的场景。在这个场景中,你将显示电力条以及玩家的得分次数。就像在 2D 中一样,这个节点将导致其内容被绘制在主场景之上。

添加一个 Label 节点,然后一个 MarginContainer 节点。在其中,添加一个 VboxContainer 节点,并在其中添加两个 Label 节点和一个 TextureProgressBar 节点。按所示命名:

图 5.22:UI 节点布局

图 5.22:UI 节点布局

MarginContainer 部分,设置 20。将 Xolonium-Regular.ttf 字体添加到两个 Label 节点中,并将它们的字体大小设置为 30。将 Shots 设置为 PowerLabelPower

使用更大的字体大小 80Message 标签添加字体,并将其文本设置为 Get Ready!。从 锚点预设 菜单中选择 居中,然后点击消息旁边的眼睛符号以隐藏它。

res://assets 中的一个彩色条纹理拖放到 PowerBar 中。默认情况下,TextureProgressBar 从左向右增长,因此对于垂直方向,将 填充模式 更改为 从下到上。将 设置为几个不同的值以查看结果。

完成的 UI 布局应如下所示:

图 5.23:UI 预览

图 5.23:UI 预览

Hole 场景中添加 UI 的一个实例。因为它是 CanvasLayer,它将被绘制在 3D 摄像机视图之上。

现在你已经完成了课程的绘制并添加了 UI,你拥有了玩家在游戏过程中将看到的全部视觉元素。你的下一个任务将通过添加一些代码使这些部分协同工作。

游戏脚本编写

在本节中,你将创建使一切协同工作的脚本。游戏流程如下:

  1. 将球放置在 Tee 上。

  2. 切换到 Aim 模式并动画化箭头,直到玩家点击。

  3. 切换到 Power 模式并动画化电力条,直到玩家点击。

  4. 发射球。

  5. 重复从 步骤 2 开始的过程,直到球落入洞中。

UI 代码

将此脚本添加到 UI 实例中,以更新 UI 元素:

extends CanvasLayer
@onready var power_bar = $MarginContainer/VBoxContainer/PowerBar
@onready var shots = $MarginContainer/VBoxContainer/Shots
var bar_textures = {
    "green": preload("res://assets/bar_green.png"),
    "yellow": preload("res://assets/bar_yellow.png"),
    "red": preload("res://assets/bar_red.png")
}
func update_shots(value):
    shots.text = "Shots: %s" % value
func update_power_bar(value):
    power_bar.texture_progress = bar_textures["green"]
    if value > 70:
        power_bar.texture_progress = bar_textures["red"]
    elif value > 40:
        power_bar.texture_progress = bar_textures["yellow"]
    power_bar.value = value
func show_message(text):
    $Message.text = text
    $Message.show()
    await get_tree().create_timer(2).timeout
    $Message.hide()

这些功能提供了一种在需要显示新值时更新 UI 元素的方法。正如你在 Space Rocks 中所做的那样,根据进度条的值更改纹理,为电力水平提供了良好的低/中/高感觉。

主脚本

Hole 场景添加脚本,并从以下变量开始:

extends Node3D
enum {AIM, SET_POWER, SHOOT, WIN}
@export var power_speed = 100
@export var angle_speed = 1.1
var angle_change = 1
var power = 0
var power_change = 1
var shots = 0
var state = AIM

enum 列出了游戏可能处于的状态,而 powerangle 变量将用于设置它们各自的价值并在时间上改变它们。你可以通过调整两个导出变量来控制动画速度(因此难度)。

接下来,在开始游戏之前设置初始值:

func _ready():
    $Arrow.hide()
    $Ball.position = $Tee.position
    change_state(AIM)
    $UI.show_message("Get Ready!")

球被移动到球座位置,然后你切换到 AIM 状态开始。

对于每个游戏状态,需要发生以下情况:

func change_state(new_state):
    state = new_state
    match state:
        AIM:
            $Arrow.position = $Ball.position
            $Arrow.show()
        SET_POWER:
            power = 0
        SHOOT:
            $Arrow.hide()
            $Ball.shoot($Arrow.rotation.y, power / 15)
            shots += 1
            $UI.update_shots(shots)
        WIN:
            $Ball.hide()
            $Arrow.hide()
            $UI.show_message("Win!")

AIM 将瞄准箭头放置在球的位置并使其可见。回想一下,你偏移了箭头,所以它看起来是从球向外指。当你旋转箭头时,你将在 y 轴周围旋转它,使其保持与地面平行。

此外,请注意,在进入 SHOOT 状态时,你在球上调用 shoot() 函数,但你还没有定义它。你将在下一节中添加它。

下一步是检查用户输入:

func _input(event):
    if event.is_action_pressed("click"):
        match state:
            AIM:
                change_state(SET_POWER)
            SET_POWER:
                change_state(SHOOT)

游戏的唯一输入(到目前为止)是点击左鼠标按钮。根据你当前的状态,点击它将过渡到下一个状态。

_process() 中,你将根据状态确定要动画化的内容。目前,它只是调用动画化适当属性的函数:

func _process(delta):
    match state:
        AIM:
            animate_arrow(delta)
        SET_POWER:
            animate_power(delta)
        SHOOT:
            pass

这两个函数都很相似。它们在两个极端值之间逐渐改变一个值,当达到极限时反转方向。注意,箭头在 180° 范围内动画化(+90° 到 -90°):

func animate_arrow(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
func animate_power(delta):
    power += power_speed * power_change * delta
    if power >= 100:
        power_change = -1
    if power <= 0:
        power_change = 1
    $UI.update_power_bar(power)

要检测球掉入洞中,选择你放置在洞中的 Area3D 节点并连接其 body_entered 信号:

func _on_hole_body_entered(body):
    if body.name == "Ball":
        print("win!")
        change_state(WIN)

最后,当球停止时,玩家将需要能够重新开始整个过程。

球脚本

在球的脚本中,需要两个函数。首先,必须对球施加一个冲量以启动其运动。其次,当球停止运动时,它需要通知主场景,以便玩家可以进行下一次投篮。

确保将此脚本添加到 Ball 场景中,而不是 Hole 场景中的球实例:

extends RigidBody3D
signal stopped
func shoot(angle, power):
    var force = Vector3.FORWARD.rotated(Vector3.UP, angle)
    apply_central_impulse(force * power)
func _integrate_forces(state):
    if state.linear_velocity.length() < 0.1:
        stopped.emit()
        state.linear_velocity = Vector3.ZERO
    if position.y < -20:
        get_tree().reload_current_scene()

正如你在 Space Rocks 游戏中看到的,你可以在 _integrate_forces() 中使用物理状态安全地停止球,如果速度变得非常低。由于浮点数问题,速度可能不会自行减慢到 0。它的 linear_velocity 值可能在它看起来停止后的一段时间内仍然是 0.00000001。与其等待,你可以在速度低于 0.1 时停止球。

还有可能发生球意外地弹过墙壁并掉出赛道的情况。如果发生这种情况,你可以重新加载场景,让玩家重新开始。

返回到 Hole 场景并连接 Ball 实例的 stopped 信号:

func _on_ball_stopped():
    if state == SHOOT:
        change_state(AIM)

测试它

尝试播放场景。你应该看到箭头在球的位置旋转。当你点击鼠标按钮时,箭头停止,力量条开始上下移动。当你再次点击时,球被发射出去。

如果这些步骤中的任何一个不起作用,不要继续前进。返回并尝试在上一个部分中找到你遗漏的内容。

一切正常后,你会注意到一些需要改进的区域。首先,当球停止移动时,箭头可能不会指向你想要的方向。这是因为起始角度始终是 0,它沿着 z 轴指向,然后箭头从那里摆动 +/-90°。在接下来的两个部分中,你将有两个选项来改进瞄准。

提高瞄准的选项 1

瞄准可以通过在开始时直接将 180° 弧指向洞来改进。

在脚本顶部添加一个名为 hole_dir 的变量。你可以通过一些向量数学找到这个方向:

func set_start_angle():
    var hole_position = Vector2($Hole.position.z,
        $Hole.position.x)
    var ball_position = Vector2($Ball.position.z,
        $Ball.position.x)
    hole_dir = (ball_position - hole_position).angle()
    $Arrow.rotation.y = hole_dir

记住,球的位置是其中心,所以它略微高于表面,而洞的中心则略低于它。因此,从球到洞的向量也会指向地面的向下角度。为了防止这种情况并保持箭头水平,你可以只使用 position 中的 xz 值来生成 Vector2

现在,可以在启动 AIM 状态时设置初始角度:

func change_state(new_state):
    state = new_state
    match state:
        AIM:
            $Arrow.position = $Ball.position
            $Arrow.show()
            set_start_angle()

箭头的动画可以使用这个初始方向作为 +/-90° 摆动的基准:

func animate_arrow(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

再次尝试玩游戏。现在箭头应该总是指向洞的大致方向。这更好,但你仍然可能难以瞄准。

提高瞄准的选项 2

如果你更喜欢对瞄准有更多控制,那么你可以直接通过移动鼠标左右来控制箭头,而不是通过动画箭头和点击来设置瞄准。

为了实现这一点,你可以使用 Godot 的 InputEvent 类型之一:InputEventMouseMotion。这个事件在鼠标移动时发生,并包括一个 relative 属性,表示鼠标在上一个帧中移动的距离。你可以使用这个值来旋转箭头一个小量。

首先,通过从 _process() 中移除 AIM 部分来禁用箭头动画。

添加一个变量,以便你可以根据鼠标移动来控制箭头的旋转程度:

@export var mouse_sensitivity = 150

然后,在 _input() 中写入以下代码以检查鼠标移动并旋转箭头:

func _input(event):
    if event is InputEventMouseMotion:
        if state == AIM:
            $Arrow.rotation.y -= event.relative.x / mouse_sensitivity

捕捉鼠标

你可能已经注意到,当你移动鼠标时,它可能会离开游戏窗口,当你点击时,你不再与游戏交互。大多数 3D 游戏通过 捕捉 鼠标来解决这个问题——将鼠标锁定在窗口上。当你这样做时,你还需要为玩家提供一个释放鼠标的方法,以便他们可以关闭程序或点击其他窗口,以及一个重新捕捉鼠标的方法来回到游戏。

对于这款游戏,你首先需要捕捉鼠标,然后如果玩家按下 Esc,释放鼠标并暂停游戏。在游戏窗口中点击将取消暂停并继续。

所有这些功能都通过 Input.mouse_mode 属性控制。然后,mouse_mode 可以设置为以下值之一:

  • MOUSE_MODE_VISIBLE: 这是默认模式。鼠标可见,可以自由地在窗口内外移动。

  • MOUSE_MODE_HIDDEN: 鼠标光标被隐藏。

  • MOUSE_MODE_CAPTURED: 鼠标被隐藏,并且其位置被锁定到窗口。

  • MOUSE_MODE_CONFINED: 鼠标可见,但被限制在窗口内。

首先在_ready()中捕获鼠标:

Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

_process()中,当鼠标释放时,你不想对事物进行动画处理:

func _process(delta):
    if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE:
        return

要释放鼠标,在_input()中添加以下条件:

if event.is_action_pressed("ui_cancel") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
    Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

然后,当窗口被点击时,为了重新捕获鼠标,在match_state之前添加以下内容:

if event.is_action_pressed("click"):
    if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE:
        Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
        return

播放场景以尝试它。

相机改进

另一个问题,特别是如果你布置了一个相对较大的赛道,那就是如果你将相机放置在发球台附近,它将无法很好地显示赛道上的其他部分,甚至根本不显示。你需要你的相机移动,显示赛道的其他部分,以便玩家可以舒适地瞄准。

解决这个问题的主要有两种方法:

  1. 多个相机:在赛道周围的不同位置放置几个相机。将Area3D节点附加到它们上,当球进入一个相机的区域时,通过将其current属性设置为true来激活该相机。

  2. 移动相机:坚持使用一个相机,但让它随着球移动,这样玩家的视角总是基于球的位置。

这两种方案都有优点和缺点。选项 1 需要更多的规划,决定确切的位置放置相机,以及使用多少个。因此,本节将重点介绍选项 2。

在许多 3D 游戏中,玩家可以控制一个旋转和移动的相机。通常,这种控制方案使用鼠标和键盘的组合。由于你已经在瞄准时使用了鼠标移动(如果你选择了该选项),W/A/S/D键是一个不错的选择。鼠标滚轮可以用来控制相机的缩放。

输入 映射属性中添加这些新动作:

图 5.24:输入映射

图 5.24:输入映射

创建万向节

相机移动需要有一定的限制。一方面,它应该始终保持水平,不要向两侧倾斜。尝试这样做:拿一个相机,围绕y(工具箱的绿色环)旋转一小部分,然后围绕x(红色环)旋转一小部分。现在,反转y旋转并点击预览按钮。看看相机是否已经倾斜了吗?

解决这个问题的方法是将相机放置在Node3D节点上,这将分别控制相机的左右和上下移动。

首先,确保从场景中移除任何其他Camera3D节点,以免在哪个相机被使用上产生冲突。

创建一个新的场景并添加两个Node3D节点和一个Camera3D节点,命名如图 5**.25所示:

图 5.25:相机万向节节点设置

图 5.25:相机万向节节点设置

Camera3D设置为(0, 0, 10),使其偏移并朝向原点。

这是万向节的工作原理:外节点允许仅在y轴上旋转,而内节点仅在x轴上旋转。您可以亲自尝试,但请确保开启使用局部空间(见3D 空间简介部分)。记住,只移动外万向节节点的绿色环,以及内节点的红色环。不要改变相机。一旦您完成实验,将所有旋转重置为零。

要控制游戏中的这种运动,将脚本附加到根节点并添加以下内容:

extends Node3D
@export var cam_speed = PI / 2
@export var zoom_speed = 0.1
var zoom = 0.2
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.0)
    scale = Vector3.ONE * zoom
    var y = Input.get_axis("cam_left", "cam_right")
    rotate_y(y * cam_speed * delta)
    var x = Input.get_axis("cam_up", "cam_down")
    $GimbalInner.rotate_x(x * cam_speed * delta)
    $GimbalInner.rotation.x = clamp($GimbalInner.rotation.x,         -PI / 2, -0.2)

如您所见,左右动作旋转根Node3D节点绕其y轴,上下动作旋转GimbalInner绕其x轴。整个万向节系统的scale属性用于处理缩放。最后,通过使用clamp()限制旋转和缩放,防止用户将相机翻转过来或缩放得太近或太远。

Hole场景中添加一个CameraGimbal实例。

下一步是让相机跟随球。您可以在_process()中通过设置相机的位置为球的位置来实现这一点:

if state != WIN:
    $CameraGimbal.position = $Ball.position

播放场景并测试您是否可以旋转和缩放相机,以及当您射击时球会移动。

设计完整课程

一旦球落入洞中,玩家应前进到下一个洞进行游戏。在hole.gd顶部添加此变量:

@export var next_hole : PackedScene

这将让您设置将要加载的下一个洞。在检查器中,选择下一个洞属性以选择下一个要加载的场景。

WIN状态中添加加载代码:

WIN:
$Ball.hide()
$Arrow.hide()
    await get_tree().create_timer(1).timeout
    if next_hole:
        get_tree().change_scene_to_packed(next_hole)

您的Hole场景旨在成为构建多个玩家可以玩过的通用脚手架。现在您已经让它工作,您可以使用它通过场景 -> 新建 继承场景来创建多个场景。

使用这种技术,您可以创建尽可能多的洞,并将它们连接成完整的高尔夫球场。以下是在示例项目中的第二个洞:

图 5.26:示例课程布局

图 5.26:示例课程布局

视觉效果

球和其他网格在场景中的外观被有意地留得很简单。您可以将扁平的白色球视为空白画布,准备上色。首先,一些词汇:

  • 纹理:纹理是围绕 3D 对象包裹的平面、2D 图像。想象一下包裹礼物:扁平的纸张折叠在包裹上,适应其形状。纹理可以是简单的,也可以是复杂的,这取决于它们设计要应用的形状。

  • StandardMaterial3D.

  • 材料: Godot 使用一种名为 基于物理的渲染PBR)的图形渲染模型。PBR 的目标是渲染图形,使其能够准确模拟现实世界中光的行为。这些效果通过使用其材料属性应用于网格。材料本质上是一个纹理和着色器的容器。材料的属性决定了纹理和着色器效果的应用方式。使用 Godot 内置的材料属性,你可以模拟各种物理材料,如石头、布料或金属。如果内置属性不足以满足你的需求,你可以编写自己的着色器代码来添加更多效果。

添加材料

在“球”场景中,选择MeshInstance,并在其StandardMaterial3D节点中。

展开材料,你会看到大量的属性,远超过这里所能涵盖的范围。本节将重点介绍一些制作球体更具吸引力的最有用属性。我们鼓励你访问 docs.godotengine.org/en/latest/tutorials/3d/standard_material_3d.html 以获得所有设置的完整解释。

首先,尝试对这些参数进行实验:

  • 反照率: 这个属性设置了材料的基色。更改它可以使球体呈现你想要的任何颜色。如果你正在处理需要纹理的对象,这里也是添加纹理的地方。

  • 01金属值控制光泽度。更高的值会反射更多光线,就像金属物质一样。粗糙度值对反射应用一定程度的模糊。较低的值更具有反射性,例如镜子的抛光表面。通过调整这两个属性,你可以模拟各种材料。图 5.27 是一个关于 粗糙度金属 属性如何影响物体外观的指南。请记住,照明和其他因素也会改变外观。理解光和反射如何与表面属性相互作用是设计有效 3D 对象的重要组成部分:

图 5.27:金属和粗糙度值

图 5.27:金属和粗糙度值

  • 法线贴图: 法线贴图是一种 3D 图形技术,用于模拟表面上的凹凸效果。在网格本身中建模这些效果会导致组成对象的三角形或面数大量增加,从而导致性能降低。相反,使用一个 2D 纹理来映射这些小表面特征会产生光和影的模式。然后,照明引擎使用这些信息来改变反射,就像这些细节实际上存在一样。一个正确构建的法线贴图可以为原本看起来平淡无奇的对象添加大量细节。

球体是一个很好的正常映射用例的例子,因为真实的高尔夫球在其表面有数百个凹坑,但你使用的球体原语是一个光滑的表面。使用常规纹理可能会添加斑点,但它们看起来会像涂在表面上的,显得很平。模拟这些凹坑的正常映射看起来会是这样:

图 5.28:正常映射

图 5.28:正常映射

红色和蓝色的图案包含信息,告诉引擎在那个点表面应该朝向哪个方向,因此光线应该在那个位置反射的方向。注意顶部和底部的拉伸——这是因为这张图片是为了包裹球形形状而制作的。

res://assets/ball_normal_map.png启用到-0.5-1.0,效果最佳:

图 5.29:带有正常映射的球体

图 5.29:带有正常映射的球体

花些时间实验这些设置,找到你喜欢的组合。别忘了尝试播放场景,因为WorldEnvironment功能的周围照明会影响最终结果。

在下一节中,你将学习如何调整WorldEnvironment设置以改变场景的外观。

照明和环境

你一直在使用默认的照明设置,这是你在第一部分中添加到场景中的。虽然你可能对这个照明设置感到满意,但你也可以调整它以显著改变游戏的外观。

WorldEnvironment节点包含一个Environment属性,用于控制场景的背景、天空、环境光和其他外观方面。选择节点并点击属性以展开它:

图 5.30:环境属性

图 5.30:环境属性

这里有很多设置,其中一些只在特定的高级情况下真正有用。然而,这些是你最常使用的:

  • Sky材质。天空材质可以是围绕场景包裹的特殊纹理(参见下一节中的示例)或者由引擎自动生成的材质。你现在使用的默认天空材质是后者:ProceduralSkyMaterial。展开它查看属性——你可以配置天空的颜色和太阳的外观。

  • 环境光:这是一种影响所有网格的全局光,强度相同。你可以设置其颜色以及由天空生成多少。为了看到效果,尝试将颜色设置为白色并稍微减少天空贡献

  • 屏幕空间反射SSR)、屏幕空间环境遮挡SSAO)、屏幕空间间接照明SSIL)和有符号距离场全局照明SDFGI)。

这些选项提供了更高级的控制,以处理光照和阴影。关于良好光照的艺术可以写一本书,但为了本节的目的,你应该知道每个这些功能都引入了真实渲染和性能之间的权衡。大多数高级光照功能在低端设备上根本不可用,例如移动设备或较旧的 PC 硬件。如果你想了解更多,Godot 文档提供了这些光照功能的广泛介绍。

Glow 灯光功能模拟了光线“渗透”到周围环境中的电影效果,使物体看起来像是在发光。请注意,这与材料的发射属性不同,后者会使物体实际上发出光线。Glow 默认启用,但设置非常微妙,在明亮的光照下可能不明显。

随意尝试各种环境设置。如果你完全迷失方向并想恢复默认设置,删除WorldEnvironment节点,然后你可以从菜单中再次添加默认版本。

摘要

本章介绍了 3D 开发。Godot 的一个巨大优势是,2D 和 3D 中使用的工具和工作流程相同。你关于创建场景、实例化和使用信号的过程所学的所有内容在 3D 中同样适用。例如,你为 2D 游戏使用控制节点构建的界面可以放入 3D 游戏中,并且会以相同的方式工作。

在本章中,你学习了如何使用操纵杆在 3D 编辑器中导航以查看和放置节点。你了解了网格以及如何使用 Godot 的原生功能快速创建自己的对象。你使用了GridMap来布置你的迷你高尔夫球场。你学习了如何使用相机、光照和世界环境来设计你的游戏在屏幕上的外观。最后,你尝试了通过 Godot 的SpatialMaterial资源使用 PBR 渲染。

在下一个项目中,你将继续在 3D 环境中工作,并扩展你对变换和网格的理解。

第六章:无限飞行者

在本章中,你将构建一个 3D 无限跑酷游戏(或者更准确地说,无限飞行者),类似于神庙逃亡地铁跑酷。玩家的目标是尽可能飞远,穿过漂浮的圆环来收集分数,同时避开障碍物。通过构建这个游戏,你会了解如何使 3D 对象交互,以及如何自动生成 3D 世界,而不是像在早期的迷你高尔夫丛林跳跃等游戏中那样逐块构建。

在本章中,你将学习到一些新内容:

  • 使用变换在 3D 空间中旋转和移动

  • 加载和卸载游戏世界的“块”

  • 如何随机生成游戏环境和游戏对象

  • 保存和加载文件以进行持久数据存储

  • 使用CharacterBody3D和检测碰撞

完成后,游戏将看起来像这样:

图 6.1:完成的游戏截图

图 6.1:完成的游戏截图

技术要求

从以下链接下载游戏资源,并将其解压到你的新项目文件夹中:

github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Downloads

你也可以在 GitHub 上找到本章的完整代码:github.com/PacktPublishing/Godot-4-Game-Development-Projects-Second-Edition/tree/main/Chapter06%20-%20Infinite%20Flyer

项目设置

在 Godot 中创建一个新的项目开始。像之前一样,下载项目资源并将其解压到新的项目文件夹中。一旦创建项目,你将开始配置游戏所需的输入和 Godot 设置。

输入

你将使用上、下、左、右输入来控制飞机。你可以在pitch_uppitch_downroll_leftroll_right中添加它们。你可以添加箭头键和/或WASD键,但如果你有游戏控制器,你也可以使用摇杆进行更精确的控制。要添加摇杆输入,你可以在按下+按钮后选择Joypad Axes。这些值都有标签,例如Left Stick Up,这样你可以轻松跟踪它们:

图 6.2:输入配置

图 6.2:输入配置

这个设置的优点是,你的代码对于不同类型的输入不需要做任何改变。通过使用Input.get_axis()并传入四个输入事件,无论玩家是按下了键还是移动了摇杆,你都会得到一个结果。按下键等同于将摇杆推到一端。

现在项目已经设置好了,你可以开始创建你的游戏对象,从玩家控制的飞机开始。

飞机场景

在本节中,您将创建玩家将控制的飞机。当玩家可以上下左右移动时,飞机将向前飞行。

使用名为PlaneCharacterBody3D节点开始您的新飞机场景,并保存它。

您可以在assets文件夹中找到飞机的 3D 模型,命名为cartoon_plane.glb。这个名字表明该模型以二进制 .gltf文件格式存储(由 Blender 导出)。Godot 将.gltf文件导入为包含网格、动画、材质和其他可能已导出在文件中的对象的场景。点击Node3D,但它的方向是错误的。选择它,并在检查器功能中设置180,使其指向z轴,这是 Godot 的“前进”方向。请注意,直接输入值比尝试使用鼠标精确旋转节点要容易。

模型方向

如前一章所述,不同的 3D 设计程序使用不同的轴方向。导入模型时,其前进方向不匹配 Godot 的情况非常常见。如果您自己制作模型,您可以在导出时纠正这一点,但使用他人制作的模型时,通常需要在 Godot 中重新定位它。

如果您在cartoon_plane节点上右键单击并选择AnimationPlayer

图 6.3:飞机网格

图 6.3:飞机网格

AnimationPlayer包含一个使螺旋桨旋转的动画,因此选择它,并将prop_spin动画设置为加载时自动播放功能:

图 6.4:自动播放动画

图 6.4:自动播放动画

碰撞形状

CollisionShape3D节点添加到Plane,并选择90以使其与飞机的机身对齐。您可以使用 gizmo(别忘了使用“使用智能吸附”图标打开吸附以使其完美对齐)或直接在检查器中输入值。

翼也需要被覆盖,因此添加第二个CollisionShape3D节点。这次,使用BoxShape3D。将其调整到覆盖翼的尺寸:

图 6.5:飞机碰撞形状

图 6.5:飞机碰撞形状

编写飞机脚本

您可以从飞机的控制开始。有两个移动轴:“抬头”和“低头”将抬起或降低飞机的机头(绕其x轴旋转),使其向上或向下移动。roll_leftroll_right函数将飞机绕其z轴旋转,使其向左或向右移动。

对于任何输入,您都希望旋转平滑,当玩家松开按钮或将操纵杆返回中心时,飞机应平滑地旋转回其原始位置。您可以通过插值旋转而不是直接设置旋转来实现这一点。

关于插值

线性插值,通常缩写为 lerp,是你在游戏开发中经常会遇到的一个术语。这意味着使用直线函数计算两个给定值之间的中间值。在实践中,它可以用来在一段时间内平滑地从一个值变化到另一个值。

首先,将脚本附加到 Plane 节点并定义一些变量:

extends CharacterBody3D
@export var pitch_speed = 1.1
@export var roll_speed = 2.5
@export var level_speed = 4.0
var roll_input = 0
var pitch_input = 0

导出的变量让你可以设置飞机旋转的速度,无论是哪个方向,以及它自动返回水平飞行的速度。

在你的 get_input() 函数中,你将检查来自 输入映射 的输入值,以确定旋转的方向:

func get_input(delta):
    pitch_input = Input.get_axis("pitch_down", "pitch_up")
    roll_input = Input.get_axis("roll_left", "roll_right")

Input.get_axis() 函数根据两个输入返回一个介于 -11 之间的值。当使用只能按下或未按下的按键时,这意味着当按下其中一个键时,你会得到 -1,另一个键为 1,当两个键都未按下或都按下时,为 0。然而,当使用类似摇杆轴这样的模拟输入时,你可以得到完整的值范围。这允许更精确的控制,例如,将摇杆稍微向右倾斜只会给出小的 roll_input 值,例如 0.25

_physics_process() 中,你可以根据俯仰输入在 x 轴上旋转飞机:

func _physics_process(delta):
    get_input(delta)
    rotation.x = lerpf(rotation.x, pitch_input,
        pitch_speed * delta)
    rotation.x = clamp(rotation.x, deg_to_rad(-45),
        deg_to_rad(45))

使用 clamp() 也很重要,以限制旋转,这样飞机就不会完全翻转过来。

你可以通过创建一个新的测试场景并添加飞机和 Camera3D 来测试这一点,如下所示:

图 6.6:测试场景

图 6.6:测试场景

将摄像机放置在飞机后面,运行场景以测试按下俯仰向上和俯仰向下输入是否能够正确地使飞机上下倾斜。

对于滚转,你可以在 z 轴上旋转机身,但这样两次旋转会相加,你会发现很难将飞机恢复到水平飞行。由于在这个游戏中,你希望飞机继续向前飞行,旋转子网格会更简单。在 _physics_process() 中添加此行:

$cartoon_plane.rotation.z = lerpf($cartoon_plane.rotation.z, roll_input, roll_speed * delta)

再次在测试场景中测试它,并确保所有控制都按预期工作。

为了完成移动,在脚本顶部添加两个更多变量。你的飞机飞行速度将是 forward_speed。你将在以后调整它以改变游戏的难度。你可以使用 max_altitude 来防止飞机飞出屏幕:

@export var forward_speed = 25
var max_altitude = 20

get_input() 中,检查输入后,添加以下内容以使飞机在达到最大高度时水平:

if position.y >= max_altitude and pitch_input > 0:
    position.y = max_altitude
    pitch_input = 0

然后,将此行添加到 _physics_process() 中以处理移动。前进速度将是 forward_speed 的量:

velocity = -transform.basis.z * forward_speed

对于侧向移动(在 x 方向上),你可以乘以旋转量以使其更快或更慢,这取决于飞机滚转了多少。然后,根据前进速度(除以二以使其稍微慢一点——在这里进行实验以改变感觉)来调整速度:

velocity += transform.basis.x * $cartoon_plane.rotation.z / deg_to_rad(45) * forward_speed / 2.0
move_and_slide()

你的飞机现在应该正在向前飞行,并且控制应该按预期工作。在检查飞机行为正确之前,不要进行到下一步。在下一节中,你将为飞机飞行创建环境。

构建世界

因为这是一个无限风格的游戏,玩家将尽可能长时间地飞越世界。这意味着你需要不断地为他们创建更多的世界,以便他们可以看到——随机建筑物、要收集的项目等等。如果玩家不会看到大部分游戏世界,那么提前创建所有这些将是不切实际的。此外,如果玩家不会看到大部分游戏世界,那么加载一个巨大的游戏世界也将是不高效的。

因此,使用分块策略更为合理。你将随机生成世界的较小部分,或者称为块。你可以在需要时创建这些块——当玩家向前移动时。一旦它们被通过,当游戏不再需要跟踪它们时,你也可以移除它们。

世界对象

每次生成世界的新块时,它将包含多个不同的世界对象。你可以从两个开始:建筑物,它们将是障碍物,以及玩家通过飞行尝试收集的环。

建筑物

对于第一座建筑物,使用一个StaticBody3D节点开始一个新的场景,并将其命名为Building1。添加一个MeshInstance3D节点,并将res://assets/building_meshes/Build_01.obj拖入.glTF文件中,建筑物的网格存储在OBJ格式中。还有一个单独的.mtl文件,其中包含网格的材料——Godot 将其隐藏在文件系统面板中,但它将被用于网格实例中的纹理。

你会注意到建筑物以原点为中心。由于你的建筑物大小不一,这将使它们难以全部放置在地面上——它们将具有不同的偏移量。如果您的建筑物在事先都保持一致偏移,那么它们可以更容易地放置。

要定位建筑网格,将MeshInstance3D节点更改为(0, 6, -8),这将将其向上移动并将其边缘放置在原点上。通过选择网格并选择网格 -> 创建三角形网格 碰撞兄弟来添加碰撞形状。

在名为res://buildings/的新文件夹中保存场景,并使用其他建筑物重复此过程,每个场景都从StaticBody3D节点开始,添加网格,偏移它,然后创建碰撞形状。由于每座建筑的大小不同,以下是将它们完美定位的偏移量:

建筑 偏移
1 (0, 6, -8)
2 (0, 8, -4)
3 (0, 10, -6)
4 (0, 10, -6)
5 (0, 11, -4)

现在块可以随机加载和实例化这些建筑物,以创建多样化的城市天际线。

环将出现在玩家前方,飞机需要飞过它们才能得分。如果飞机非常接近环的中心,玩家将获得加分。随着游戏的进行,环可能会变得难以捕捉 – 改变大小,来回移动等等。

在开始之前,不要提前看,想想哪种类型的节点最适合环对象。

你是否选择了 Area3D?由于你想要检测飞机是否飞过环,但又不想与之碰撞,因此区域 body_entered 检测将是理想的解决方案。

使用 Area3D 开始新的 Ring 场景并添加一个 MeshInstance3D 子节点。对于 TorusMesh,在网格属性中设置 3.54,这样你就有了一个窄环。

添加一个 CollisionShape3D 节点并选择 .53

之后,你将想要让环上下移动。一个简单的方法是将碰撞形状相对于根节点的位置移动。由于你希望网格也移动,将网格拖动使其成为 CollisionShape3D 的子节点。将碰撞形状绕 x 轴旋转 90 度使其站立。

一个普通的白色戒指并不十分吸引人,所以你可以添加一些纹理。在 MeshInstance3D 中添加 res://assets/textures/texture_09.png。你会注意到,这个纹理,由交替的亮暗方格组成的网格,在环面周围看起来非常拉伸。你可以通过改变 (12, 1, 1) 的起始值来调整纹理如何包裹网格,并调整到你喜欢的样子。在 Shading 下,将 Shading Mode 设置为 Unshaded – 这样可以确保戒指忽略光照和阴影,始终保持明亮和可见。

接下来,将一个 Label3D 节点添加到 Ring 节点。你将使用它来显示玩家为戒指获得的分数以及是否获得了中心加分。将 100 设置为可以看到一些内容以进行测试。从资产文件夹中的 Baloo2-Medium.ttf 设置字体大小为 720。为了使文本始终面向相机,将 Flags/Billboard 设置为 Enabled

将脚本添加到戒指并连接 body_entered 信号。最初,Label3D 函数应该是隐藏的,并且当飞机接触戒指时,戒指将被隐藏。但是,有一个问题:如果戒指生成并重叠在建筑物上怎么办?body_entered 信号仍然会被触发,但你不想让建筑物收集戒指!

你可以通过设置碰撞层来解决这个问题。在 Plane 场景中,将其 2(移除 1),然后回到 Ring 节点并设置其 2。现在,你可以确信如果环看到有物体进入,那只能是飞机:

extends Area3D
func _ready():
    $Label3D.hide()

之后,你需要找到飞机到环中心的距离,以查看玩家是否得分并设置 text 属性为正确的值。如果飞机直接在环的中心(小于 2.0 单位)击中,你也可以将文本颜色设置为黄色以表示完美击中:

func _on_body_entered(body):
    $CollisionShape3D/MeshInstance3D.hide()
    var d = global_position.distance_to(body.global_position)
    if d < 2.0:
        $Label3D.text = "200"
        $Label3D.modulate = Color(1, 1, 0)
    elif d > 3.5:
        $Label3D.text = "50"
    else:
        $Label3D.text = "100"
    $Label3D.show()

继续编写_on_body_entered()函数,给标签添加一些动画,使其移动并淡出:

var tween = create_tween().set_parallel()
tween.tween_property($Label3D, "position",
    Vector3(0, 10, 0), 1.0)
tween.tween_property($Label3D, "modulate:a", 0.0, 0.5)

最后,给环添加一个漂亮的旋转效果:

func _process(delta):
    $CollisionShape3D/MeshInstance3D.rotate_y(deg_to_rad(50) * delta)

现在你已经拥有了块的基本构建块,你可以制作块场景本身。这是游戏在需要玩家前方有更多世界时实例化的场景。当你实例化一个新的块时,它将在左右两侧随机放置建筑物,并在其长度上随机生成环。

使用Node3D节点和名为GroundMeshInstance3D子节点启动Chunk场景。将PlaneMesh设置为(50, 200)。这是单个块的大小:

图 6.7:飞机大小设置

图 6.7:飞机大小设置

通过将其设置为-100来定位它以从原点开始:

图 6.8:定位飞机

图 6.8:定位飞机

添加材质并使用texture_01.png作为(2, 10, 2)。默认情况下,Godot 会将三个比例值链接起来以保持它们相同,因此你需要取消选中链接按钮以允许它们不同:

图 6.9:调整 UV 比例

图 6.9:调整 UV 比例

选择Ground节点,并选择与地面大小匹配的StaticBody3D节点和CollisionShape3D节点。

当飞机移动到块末尾时,你会在前方生成一个新的块,并且一旦旧块通过,你也可以移除它们。为了辅助后者,添加一个VisibleOnScreenNotifier3D节点并将其设置为(0, 0, -250),这样它就会位于地面平面的末端之外。

你现在可以向Chunk节点添加一个脚本,并将通知器的screen_exited信号连接起来,以便移除块:

func _on_visible_on_screen_notifier_3d_screen_exited():
    queue_free()

在脚本顶部,加载需要实例化的场景:

extends Node3D
var buildings = [
    preload("res://buildings/building_1.tscn"),
    preload("res://buildings/building_2.tscn"),
    preload("res://buildings/building_3.tscn"),
    preload("res://buildings/building_4.tscn"),
    preload("res://buildings/building_5.tscn"),
]
var ring = preload("res://ring.tscn")
var level = 0

加载许多场景

在一个更大的游戏中,如果你有更多的建筑物和其他场景,你不想像这里一样在脚本中逐个写出它们。另一个解决方案是在这里编写代码,以加载特定文件夹中保存的每个场景文件。

level变量可以在块加载时由主场景设置,以便通过生成具有不同行为的环来增加难度(关于这一点稍后介绍)。

_ready()中,块需要做三件事:

  • 在地面平面的两侧生成建筑物

  • 不时在中间生成建筑物作为障碍物

  • 生成环

这些步骤中的每一个都会涉及一些代码,因此你可以通过创建三个单独的函数来保持所有内容的组织:

func _ready():
    add_buildings()
    add_center_buildings()
    add_rings()

第一步是生成侧建筑物。由于它们需要位于块的两侧,你需要重复循环两次——一次用于正x方向,一次用于负方向。每次,你都会沿着块的长边生成随机的建筑物:

func add_buildings():
    for side in [-1, 1]:
        var zpos = -10
        for i in 18:
            if randf() > 0.75:
                zpos -= randi_range(5, 10)
                continue
            var nb = buildings[randi_range(0,
                buildings.size()-1)].instantiate()
            add_child(nb)
            nb.transform.origin.z = zpos
            nb.transform.origin.x = 20 * side
            zpos -= nb.get_node("MeshInstance3D").mesh.get_aabb().size.z

randf() 函数是一个常见的随机函数,它返回一个介于 01 之间的浮点数,这使得它很容易用于计算百分比。检查随机数是否大于 0.75,以有 25% 的几率在特定位置没有建筑物。

通过使用 get_aabb() 获取建筑物网格的大小,你可以确保建筑物不会相互重叠。下一个建筑物的位置将正好位于前一个建筑物的边缘。

接下来,中间建筑物的生成不会在游戏开始时发生,但在游戏后期,它们将以 20% 的概率开始出现:

func add_center_buildings():
    if level > 0:
        for z in range(0, -200, -20):
            if randf() > 0.8:
                var nb = buildings[0].instantiate()
                add_child(nb)
                nb.position.z = z
                nb.position.x += 8
                nb.rotation.y = PI / 2

第三步是生成环形。目前,它只是在随机固定的位置放置了一些环形。随着游戏的进行,你将在这里添加更多变化:

func add_rings():
    for z in range(0, -200, -10):
        if randf() > 0.76:
            var nr = ring.instantiate()
            nr.position.z = z
            nr.position.y = randf_range(3, 17)
            add_child(nr)

你已经完成了块设置的配置。当它加载时,它会随机填充建筑物和环形,并在稍后它离开屏幕时将其删除。在下一节中,你将在场景中实例化块,当飞机向前移动时。

主场景

在本节中,你将创建主场景,在这个游戏中,它将负责加载世界块、显示游戏信息和开始及结束游戏。

使用名为 MainNode3D 开始一个新的场景。添加一个 Plane 实例和一个 Chunk 实例以开始。

你还需要一些照明,因此请在工具栏中选择“编辑太阳和环境设置”下拉菜单,并将太阳和环境添加到场景中:

图 6.10:添加环境和太阳

图 6.10:添加环境和太阳

你可以选择不使用生成的天空纹理,而是使用在资产文件夹中找到的 styled_sky.hdr。选择 WorldEnvironment 并展开其 ProceduralSkyMaterial。点击向下箭头并选择 styled_sky.hdr

图 6.11:WorldEnvironment 天空设置

图 6.11:WorldEnvironment 天空设置

在你可以测试之前,你还需要一个相机。添加一个 Camera3D 并将其添加到脚本中。由于它是一个没有子节点的独立节点,你不需要将其作为单独保存的场景:

extends Camera3D
@export var target_path : NodePath
@export var offset = Vector3.ZERO
var target = null
func _ready():
    if target_path:
        target = get_node(target_path)
        position = target.position + offset
        look_at(target.position)
func _physics_process(_delta):
    if !target:
        return
    position = target.position + offset

这个相机脚本是一般的,可以在其他项目中使用,其中你希望相机跟随一个移动的 3D 对象。

选择 Camera3D 节点,并在检查器中点击 Plane 节点。设置 (7, 7, 15),这将使相机位于平面的后方、上方和右侧。

图 6.12:相机跟随设置

图 6.12:相机跟随设置

播放 Main 场景,你应该能够沿着块飞行,收集环形。如果你撞到建筑物,什么也不会发生,当你到达块的尽头时,你将看不到另一个块。

生成新的块

每个块的长度是 200,所以当飞机行驶了半段距离时,一个新的块应该在之前块的末端位置生成。max_position 设置将跟踪下一个块前方的中间位置,这是飞机需要达到以生成新块的位置。

你还将跟踪已生成的块的数量,这样你可以用它来确定何时游戏应该变得更难。

将脚本添加到 Main 中并添加以下内容:

extends Node3D
var chunk = preload("res://chunk.tscn")
var num_chunks = 1
var chunk_size = 200
var max_position = -100

记住,一切都在 -z 方向上前进,所以第一个块中心的 z 值将是 -100。随着它向前移动,平面的 z 坐标将继续减小。

_process() 中,你将检查飞机的位置,如果它超过了 max_position,那么就是时候实例化一个新的块并更新 max_position 为下一个块的中心:

func _process(delta):
    if $Plane.position.z  < max_position:
        num_chunks += 1
        var new_chunk = chunk.instantiate()
        new_chunk.position.z = max_position – chunk_size / 2
        new_chunk.level = num_chunks / 4
        add_child(new_chunk)
        max_position -= chunk_size

这里是块生成发生的地方。新的块被放置在之前的块末尾。记住,max_position 是块的中心,所以你还需要添加 chunk_size / 2

然后,为了得到等级数,除以 4 得到 55/4 只是 1。等级将在块编号 8 时达到 2,在块编号 12 时达到 3,以此类推。这将逐渐增加难度。

播放场景。现在你应该会看到随着飞机向前移动,新的块出现在飞机前方。

增加难度

现在你正在生成块,它们被赋予一个逐渐增加的等级值。你可以使用这个值来开始使环更难以收集。例如,目前,它们正好放置在中心,所以玩家根本不需要左右转向。你可以开始随机化环的 x 坐标。你也可以开始使环来回或上下移动。

将以下变量添加到 ring.gd 的顶部:

var move_x = false
var move_y = false
var move_amount = 2.5
var move_speed = 2.0

这两个布尔变量将允许你在 xy 方向上开启移动,而 move_amountmove_speed 将允许你控制你想要的移动量。

当这些值设置好后,你可以检查 _ready(),开始移动,然后使用补间动画:

func _ready():
    $Label3D.hide()
    var tween = create_tween().set_loops()
        .set_trans(Tween.TRANS_SINE)
    tween.stop()
    if move_y:
        tween.tween_property($CollisionShape3D,
            "position:y", -move_amount, move_speed)
        tween.tween_property($CollisionShape3D,
            "position:y", move_amount, move_speed)
        tween.play()
    if move_x:
        tween.tween_property($CollisionShape3D,
            "position:x", -move_amount, move_speed)
        tween.tween_property($CollisionShape3D,
            "position:x", move_amount, move_speed)
        tween.play()

注意,默认情况下,补间动画会自动播放。由于你可能或可能不在实际动画化一个属性,这取决于玩家所在的等级,你可以使用 stop() 来最初停止补间动画,然后使用 play() 来启动它,一旦你设置了想要影响的属性。通过使用 set_loops(),你是在告诉补间动画无限重复两个移动,来回移动。

现在,环已经准备好移动,你的块可以在生成环时设置这些值。转到 chunk.gd 并更新生成环的部分以使用 level

func add_rings():
    for z in range(0, -200, -10):
        var n = randf()
        if n > 0.76:
            var nr = ring.instantiate()
            nr.position.z = z
            nr.position.y = randf_range(3, 17)
            match level:
                0: pass
                1:
                    nr.move_y = true
                2:
                    nr.position.x = randf_range(-10, 10)
                    nr.move_y = true
                3:
                    nr.position.x = randf_range(-10, 10)
                    nr.move_x = true
            add_child(nr)

如你所见,一旦等级达到 1,环将开始上下移动。在等级 2 时,它们将开始具有随机的 x 位置,而在等级 3 时,它们将开始水平移动。

你应该将此视为可能性的一个示例。请随意创建自己的难度递增模式。

碰撞

下一步是让飞机在遇到任何东西,如地面或建筑物时爆炸。如果它真的爆炸了,你将播放一个爆炸动画,游戏也就结束了。

爆炸

前往你的 Plane 场景并添加一个 AnimatedSprite3D 子节点。将其命名为 Explosion

AnimatedSprite3D 节点的工作方式与你在书中早期使用的 2D 版本非常相似。在 res://assets/smoke/ 中添加一个新的 SpriteFrames 资源到 10 FPS,并关闭 Loop

图 6.13:爆炸精灵帧

图 6.13:爆炸精灵帧

你可能会注意到你无法在视图中看到精灵。当在 3D 中显示以像素绘制的 2D 图像时,引擎需要知道 3D 空间中像素的大小。为了使爆炸与飞机的大小相匹配,在检查器中将 0.5 设置为大小。在 Flags 下,将 Billboard 设置为启用。这确保了精灵始终面向相机。你现在应该看到一个大云(动画的第一帧)叠加在你的飞机上。

图 6.14:爆炸精灵

图 6.14:爆炸精灵

你不希望看到爆炸,所以点击眼睛图标来隐藏 Explosion

编写碰撞脚本

plane.gd 的顶部添加一个新的信号,该信号将通知游戏玩家已经坠毁:

signal dead

_physics_process() 中,你使用 move_and_slide() 来移动飞机。每当使用此方法移动 CharacterBody3D 节点时,它可以检查 move_and_slide()

if get_slide_collision_count() > 0:
    die()

你可以定义 die() 函数来处理飞机坠毁时应该发生的事情。首先,它将停止向前移动。然后,你可以隐藏飞机并显示爆炸,播放动画。一旦动画结束,你可以重置游戏。由于你还没有制作标题屏幕,现在你可以简单地重新开始:

func die():
    set_physics_process(false)
    $cartoon_plane.hide()
    $Explosion.show()
    $Explosion.play("default")
    await $Explosion.animation_finished
    $Explosion.hide()
    dead.emit()
    get_tree().reload_current_scene()

在游戏设置完成后,你将删除最后一行。

现在播放 Main 场景并尝试撞到某个东西以验证爆炸是否播放并且场景是否重新启动。

燃料和得分

下一步是跟踪收集环时获得的分数。你还将为飞机添加一个燃料组件。这个值将稳步下降,如果燃料耗尽,游戏将结束。玩家通过收集环来获得燃料。

plane.gd 的顶部添加两个新的信号:

signal score_changed
signal fuel_changed

这些将通知 UI 显示得分和燃料值。

然后,添加这些新变量:

@export var fuel_burn = 1.0
var max_fuel = 10.0
var fuel = 10.0:
    set = set_fuel
var score = 0:
    set = set_score

这些变量的设置函数将更新它们并发出信号:

func set_fuel(value):
    fuel = min(value, max_fuel)
    fuel_changed.emit(fuel)
    if fuel <= 0:
        die()
func set_score(value):
    score = value
    score_changed.emit(score)

为了随着时间的推移减少燃料,将此行添加到 _physics_process()

fuel -= fuel_burn * delta

尝试播放主场景,你会看到大约 10 秒后燃料耗尽并爆炸。

现在,你可以让环形更新分数,并根据玩家距离环形中心的远近给予一些燃料。你已经在设置环的标签,你可以在ring.gd的同一部分做剩下的工作:

if d < 2.0:
    $Label3D.text = "200"
    $Label3D.modulate = Color(1, 1, 0)
    body.fuel = 10
    body.score += 200
elif d > 3.5:
    $Label3D.text = "50"
    body.fuel += 1
    body.score += 50
else:
    $Label3D.text = "100"
    body.fuel += 2.5
    body.score += 100

如果你再次测试,你应该能够飞得更久,只要你继续收集环形。然而,很难判断你剩下多少燃料,所以你应该添加一个 UI 叠加层来显示燃料和分数。

UI

创建一个新的场景,包含一个名为“UI”的CanvasLayer层。添加两个子元素:TextureProgressBarFuelBar)和LabelScore)。

Score框中设置文本为0,并添加字体,就像你之前做的那样,将其设置为48。使用工具栏菜单将布局设置为右上角

对于FuelBar,在assets文件夹中有两个纹理。你可以使用bar_red.png用于bar_glass.png,对于100.01

你可以将条形放置在左下角,但如果你想调整大小,你需要更改更多设置。勾选标有6的框。你会看到,无论你如何调整条形的大小,边框都不会拉伸:

图 6.15:九宫格拉伸设置

图 6.15:九宫格拉伸设置

将条形设置为舒适的大小,然后向UI添加一个脚本:

extends CanvasLayer
func update_fuel(value):
    $FuelBar.value = value
func update_score(value):
    $Score.text = str(value)

将 UI 场景的一个实例添加到Main中。将飞机的score_changed信号和fuel_changed信号连接到你刚刚在 UI 上制作的函数:

图 6.16:将飞机的信号连接到 UI

图 6.16:将飞机的信号连接到 UI

再次播放场景,并验证条形是否显示燃料变化,并且在收集环形时分数是否正确更新。

你几乎完成了!到目前为止,你有一个基本可以工作的游戏。花点时间玩几次,确保你没有错过任何交互。随着你飞得更远,块是否在增加难度?你应该看到移动的环形,然后是中心左右生成的环形。如果有任何你不清楚的地方,请确保复习前面的部分。当你准备好了,继续制作标题屏幕。

标题屏幕

标题屏幕的目的是介绍游戏,并提供一个按钮来开始游戏。本节不会详细介绍样式 – 你应该尝试不同的设置,并尝试让它看起来令人愉悦。

使用Control节点开始TitleScreen场景,并添加一个Label、一个TextureButton以及一个用于背景的TextureRect

你可以使用styled_sky.hdr作为TextureRect纹理属性。它比屏幕尺寸大得多,所以你可以随意缩放和/或定位它。

对于TextureButton,在res://assets/buttons/文件夹中有三个图像用于正常按下悬停纹理。图像相当大,允许调整大小,所以你可以勾选忽略纹理大小,并将拉伸模式设置为保持纵横比以允许你调整大小。

Label节点用于显示游戏标题。设置字体为大号,例如128。将LabelTextureButton放置在屏幕上。将它们的布局都设置为居中,然后上下移动以定位它们。

所需的唯一代码是确定按钮按下时应该执行的操作,因此向场景添加一个脚本并将按钮的pressed信号连接起来。当按钮被按下时,它应该加载主场景:

extends Control
func _on_texture_button_pressed():
    get_tree().change_scene_to_file("res://main.tscn")

要在游戏结束时返回到标题屏幕,从飞机的die()函数中移除get_tree().reload_current_scene(),然后转到Main场景并连接飞机实例的dead信号:

var title_screen = "res://title_screen.tscn"
func _on_plane_dead():
    get_tree(). change_scene_to_file(title_screen)

现在当你崩溃时,你应该立即返回到标题屏幕,在那里你可以再次按下Play

音频

assets文件夹中有两个声音效果文件:impact.wav用于飞机爆炸和three_tone.wav用于收集环圈的声音。你可以在PlaneRing场景中添加AudioStreamPlayer节点,在适当的时间播放它们。

对于背景音乐,应在游戏过程中循环播放,将AudioStreamPlayer添加到Main场景中,使用Riverside Ride Short Loop.wav作为。由于它需要在开始时自动播放,你可以勾选自动播放框。

这款游戏的音频故意保持简单和欢快。虽然每个主要游戏事件(如飞过环圈、碰撞)都有声音效果,但你也可以尝试添加额外的声音,如飞机引擎、奖励或燃油低时的警告。尝试看看什么对你有效。

保存高分

保存玩家的最高分是许多游戏中的另一个常见功能(并且你可以将其添加到本书中的其他游戏中)。由于分数需要在游戏会话之间保存,因此你需要将其保存到一个外部文件,以便游戏在下次打开时可以读取它。

这里是过程:

  1. 当游戏启动时,检查是否有保存文件。

  2. 如果存在保存文件,则从其中加载分数,否则使用0

  3. 当游戏结束时,检查分数是否高于当前高分。如果是,将其保存到文件中。

  4. 在标题屏幕上显示高分。

由于你需要从游戏的不同部分访问最高分变量,因此使用自动加载是有意义的。在global.gd中,首先你需要两个变量:

extends Node
var high_score = 0
var score_file = "user://hs.dat"

关于文件位置

您会注意到保存文件的路径不像您一直在使用的其他所有文件一样以res://开头。res://指定代表您的游戏项目文件夹——所有脚本、场景和资源都位于该位置。但是,当您导出游戏时,该文件夹变为只读。为了存储持久数据,您使用设备上为游戏写入而预留的位置:user://。此文件夹的实际位置取决于您使用的操作系统。例如,在 Windows 中,它将是%APPDATA%\Godot\app_userdata\[project_name]。您可以在以下位置找到其他支持的操作系统的路径:

https://docs.godotengine.org/en/stable/tutorials/io/data_paths.html

访问文件

在 Godot 中,通过FileAccess对象访问文件。此对象处理打开、读取和写入文件。将这些函数添加到global.gd

func _ready():
    load_score()
func load_score():
    if FileAccess.file_exists(score_file):
        var file = FileAccess.open(score_file,
            FileAccess.READ)
        high_score = file.get_var()
    else:
        high_score = 0
func save_score():
    var file = FileAccess.open(score_file, FileAccess.WRITE)
    file.store_var(high_score)

如您所见,脚本在_ready()中调用load_score(),因此它在游戏启动时立即执行。load_score()函数使用FileAccess检查保存文件是否存在,如果存在,则打开它并使用get_var()检索其中存储的数据。

save_score()函数执行相反的操作。请注意,您不需要检查文件是否存在——如果您尝试写入一个不存在的文件,它将被创建。

保存此脚本并将其添加到项目设置中的自动加载:

图 6.17:添加全局脚本

图 6.17:添加全局脚本

前往您的标题场景,并添加另一个标签节点以显示高分。设置其字体并在屏幕上排列它——底部中间可能是一个不错的选择。将此添加到脚本中,以便在标题屏幕加载时显示分数:

func _ready():
    $Label2.text = "High Score: " + str(Global.high_score)

最后,在游戏结束时,您需要检查是否有新的高分。score变量保存在飞机上,因此打开plane.gd并找到在游戏结束时被调用的die()函数。添加分数检查并在需要时调用save_score()

if score > Global.high_score:
    Global.high_score = score
    Global.save_score()

运行游戏以测试高分是否在您下次运行游戏时显示、保存并重新加载。

这种技术可以用于您想要在游戏运行之间保存的任何类型的数据。这是一个有用的技术,所以请确保将来在自己的项目中尝试它。重用代码是加速开发的好方法,所以一旦您对保存系统满意,就坚持使用它!

额外功能的建议

为了增加额外的挑战,尝试通过添加更多功能来扩展游戏。以下是一些启动建议:

  • 跟踪玩家在每局游戏中飞行的距离,并将最大值保存为高分。

  • 随着时间的推移逐步增加速度或包括增加飞机速度的加速物品。

  • 需要躲避的飞行障碍物,例如其他飞机或鸟类。

  • (高级)除了直线外,还可以添加曲线块。玩家将需要操控方向,摄像机也需要移动以保持在玩家后面。

这也是一个非常适合你尝试为移动平台构建游戏的绝佳机会。下一章将提供有关导出游戏的信息。

摘要

在本章中,你通过学习更多 Godot 的 3D 节点,如CharacterBody3D,扩展了你的 3D 技能。你应该对 3D 变换及其在空间中移动和旋转对象的方式有了很好的理解。在这个游戏中随机生成块虽然相对简单,但你可以将其扩展到更大型的游戏和更复杂的环境中。

恭喜你,你已经完成了最后一个项目!但有了这五个游戏,你成为游戏开发者的旅程才刚刚开始。

在下一章中,你可以了解一些不适合示例游戏的其他主题,以及一些关于如何进一步提升你的游戏开发技能的指导。

第七章:下一步和额外资源

恭喜!你在本书中构建的项目已经让你走上了成为 Godot 专家的道路。然而,你只是刚刚触及了 Godot 可能性的表面。随着你技能的提高和项目规模的扩大,你需要知道如何找到解决问题的方法,如何分发你的游戏以便它们可以被玩,甚至如何自己扩展引擎。

在本章中,你将学习以下主题:

  • 如何有效地使用 Godot 的内置文档

  • 使用Git备份和管理你的项目文件

  • 概述你在大多数游戏项目中会遇到的一些矢量数学概念

  • 使用开源的 3D 建模应用程序Blender来制作可以在 Godot 中使用的 3D 对象

  • 将项目导出以在其他平台上运行

  • 着色器简介

  • 在 Godot 中使用其他编程语言

  • 你可以在其中获得帮助的社区资源

  • 成为 Godot 的贡献者

本章将帮助你从本书的项目中前进,并开始制作你自己的游戏。你可以使用这里的信息来查找额外的资源和指导,以及一些更高级的主题,这些主题不适合之前涵盖的初学者项目。

使用 Godot 的文档

最初学习 Godot 的 API 可能会感到令人不知所措。你如何了解所有不同的节点以及每个节点包含的属性和方法?幸运的是,Godot 的内置文档就在那里帮助你。养成经常使用它的习惯:它将帮助你学习时找到东西,但这也是在你熟悉了之后快速查找方法或属性进行参考的好方法。

提升你的技能水平

学习有效地使用 API 文档是你可以做的第一件事,以显著提高你的技能水平。在你工作时,保持网页浏览器中的文档标签页打开,并经常参考它,查找你正在使用的节点和/或函数。

当你在编辑器的脚本标签页时,你会在右上角看到以下按钮:

图 7.1:文档按钮

图 7.1:文档按钮

对于position,你可以查看Vector2文档,看看该数据类型所有可用的函数。

另一个按钮允许你直接在 Godot 编辑器中查看文档。点击搜索帮助可以让你搜索任何方法或属性名称。搜索是智能的,这意味着你可以输入单词的一部分,随着你输入,结果会缩小。看看下面的截图:

图 7.2:搜索帮助

图 7.2:搜索帮助

当你找到你正在寻找的属性或方法时,点击打开,该节点的文档引用将出现。

阅读 API 文档

当你找到了你想要的节点文档时,你会发现它遵循一个常见的格式,顶部是节点的名称,然后是几个信息子节,如下面的截图所示:

图 7.3:API 文档

图 7.3:API 文档

页面顶部有一个名为Object的列表,这是 Godot 的基本对象类。例如,Area2D有以下的继承链:

  CollisionObject2D < Node2D < CanvasItem < Node < Object

这让你可以快速查看此类对象可能具有的其他属性。例如,Area2D节点有一个position属性,因为该属性由Node2D定义——任何从Node2D继承的节点也将具有 2D 空间中的位置。你可以点击任何节点名称来跳转到该节点的文档。

你还可以看到继承自该特定节点的节点类型列表(如果有的话),以及节点的一般描述。下面,你可以看到节点的成员变量和方法。大多数方法和类型名称是链接,因此你可以点击任何项目来了解更多信息。请注意,这些名称和描述与你在检查器上悬停时显示的相同。

在工作过程中养成定期查阅 API 文档的习惯。你会发现你将很快开始更深入地理解所有这些是如何协同工作的。

版本控制——使用 Git 与 Godot 一起使用

这对每个人来说都是常态——在某个时刻,你会犯错误。你可能会不小心删除一个文件,或者以某种方式更改代码,导致一切崩溃,但你无法找出如何回到工作版本。

这个问题的解决方案是版本控制软件VCS)。全球开发者普遍使用的最流行的 VCS 是 Git。当你使用 Git 与你的项目一起工作时,你做的每一个更改都会被跟踪,这让你可以在任何时间点“倒退”时间并从不受欢迎的更改中恢复。

幸运的是,Godot 非常友好地支持 VCS。你的游戏内容全部保存在项目文件夹中。场景、脚本和资源都以人类可读的文本格式保存,这对于 Git 跟踪来说很容易。

Git 通常通过命令行界面使用,但你也可以使用图形客户端。Godot 的AssetLib中也有一个 Git 插件,你可以尝试使用。

无论如何,基本的工作流程可以分解为两个步骤:

  1. 添加你想要跟踪的文件。

  2. 提交你所做的更改。

此外,你还可以使用 GitHub 或 GitLab 等网站来存储和分享你的基于 Git 的项目。这是开发者协作项目的常见方式——实际上,Godot 的整个源代码都存储和管理在 GitHub 上。如果你这样做,你将有一个第三步:推送你提交的更改到远程仓库。

大多数开发者使用 Git 的命令行版本,你可以从你的操作系统包管理器安装或直接从 git-scm.com/downloads 下载。还有许多 GUI 界面,如 Git Kraken 或 GitHub Desktop。

Git 的使用细节超出了本书的范围,但这里有一个最基本使用的例子:创建和更新仓库以保存你的更改。所有这些步骤都将使用你的计算机终端或命令行界面完成:

  1. 在你的项目文件夹中创建一个新的 Git 仓库。导航到该文件夹,并输入以下命令:

    ~/project_folder/$ git init
    
  2. 在完成你的项目后,通过输入以下命令将新文件或更新后的文件添加到仓库中:

    ~/project_folder/$ git add *
    
  3. 提交你的更改,创建一个“时间点”的“检查点”,如果需要,可以回滚到该点:

    ~/project_folder/$ git commit -m "short description"
    

每次添加新功能或对项目进行更改时,都要重复步骤 2 和 3。

确保在提交信息中输入一些描述性的内容。如果你需要回滚到项目历史中的某个特定点,这将帮助你识别你正在寻找的更改。

Git 的内容远不止上述内容。你可以创建分支——游戏代码的多个版本,与他人协作同时进行更改,等等。以下是一些关于如何使用 Git 与你的项目学习的建议:

起初可能看起来很难——Git 有一个难以学习的曲线——但它是一项将为你服务的技能,你会在第一次从灾难中幸存时真正感激它!你甚至可能会发现 Git 对你的非游戏项目也有帮助。

在下一节中,你将了解如何使用流行的 Blender 建模工具创建 3D 对象,并在 Godot 中使用它们。

使用 Blender 与 Godot

Blender 是一个非常流行的开源 3D 建模和动画程序(它还能做很多其他事情)。如果你计划制作 3D 游戏,并且需要为你的游戏制作物品、角色和环境,Blender 可能是你实现这一目标的最佳选择。

最常见的流程是从 Blender 导出 glTF 文件并将其导入到 Godot 中。这是一个稳定且可靠的流程,在大多数情况下都能很好地工作。

当你导出 glTF 文件时,你有两种选择:glTF 二进制格式(.glb)和 glTF 文本格式(.gltf)。二进制版本更紧凑,因此是首选格式,但任何一种格式都可以正常工作。

导入提示

从 Blender 导入网格并进行修改,如添加碰撞或删除不必要的节点,这是很常见的。为了简化这个过程,你可以在对象的名称后添加后缀,以给 Godot 提供关于导入时如何处理对象的提示。以下是一些示例:

  • -noimp – 这些对象将从导入的场景中移除。

  • -col-convcol-colonly – 这些选项告诉 Godot 从命名的网格中创建一个碰撞形状。前两个选项分别创建一个子三角形网格或凸多边形形状。-colonly 选项将完全删除网格,并用 StaticBody3D 碰撞体替换它。

  • -rigid – 此对象将以 RigidBody3D 的形式导入。

  • -loop – 使用此后缀的 Blender 动画将以启用循环选项的方式导入。

请参阅文档以获取有关所有可能导入后缀的更多详细信息。

使用 blend 文件

在 Godot 4 中,你还有一个额外的选项:直接将 .blend 文件导入到你的 Godot 项目中。为了使用此功能,你需要在同一台安装 Godot 的计算机上安装 Blender。

要设置它,请打开 编辑器设置 并在 文件系统 | 导入 下查找。在这里,你可以设置 Blender 的安装路径。

图 7.4:设置 Blender 支持

图 7.4:设置 Blender 支持

点击文件夹图标浏览到你的 Blender 位置。一旦设置了这个值,你就可以直接将 .blend 文件拖放到你的 Godot 项目文件夹中。这可以使原型设计和迭代设计变得更快。你可以打开 Blender,保存对设计的更改,然后当你切换回 Godot 时,你会立即看到它已更新。

如果你计划制作 3D 游戏,学习 Blender 是一个重要的工具。由于其开源性质,它非常适合与 Godot 一起工作。虽然它的学习曲线可能具有挑战性,但投入时间学习它将在设计和构建 3D 游戏时给你带来巨大的好处。

现在你已经了解了如何将外部内容导入到你的游戏项目中,下一节将解释如何将你的游戏导出到其他系统运行,例如移动设备、PC 或网页。

导出项目

最终,你的项目将达到你想要与世界分享的阶段。导出你的项目意味着将其转换为没有 Godot 编辑器的用户可以运行的包。你可以将你的项目导出到多个流行的平台。

Godot 支持以下目标平台:

  • Android (移动设备)

  • iOS (移动设备)

  • Linux

  • macOS

  • HTML5 (网页)

  • Windows 桌面

  • UWP (Windows 全平台)

导出项目的需求根据目标平台的不同而有所差异。例如,要导出到 iOS,你必须在一个安装了 Xcode 的 macOS 计算机上运行。

每个平台都是独特的,并且由于硬件限制、屏幕尺寸或其他因素,你的游戏的一些功能可能在某些平台上无法工作。例如,如果你想要将 Coin Dash 游戏导出到 Android 手机上,你的玩家将无法移动,因为用户没有键盘!对于该平台,你需要在游戏代码中包含触摸屏控制(关于这一点稍后会有更多介绍)。

每个平台都是独特的,在配置项目以进行导出时需要考虑许多因素。请参阅官方文档,获取有关导出到您希望的平台的最新说明。

导出至游戏机

虽然 Godot 游戏在 Switch 或 Xbox 等游戏机上运行是完全可能的,但这个过程更复杂。任天堂和微软等游戏机公司要求开发者签署包含保密条款的合同。这意味着,虽然你可以让你的游戏在游戏机上运行,但你不能公开分享使它工作的代码。如果你计划在游戏机平台上发布你的游戏,你可能需要自己完成这项工作或与已经签订此类协议的公司合作。

获取导出模板

导出模板是针对每个目标平台编译的 Godot 版本,但不包括编辑器。你的项目将与目标平台的模板结合,以创建一个独立的应用程序。

首先,你必须下载导出模板。从编辑器菜单中选择管理导出模板

图 7.5:管理导出模板

图 7.5:管理导出模板

在此窗口中,你可以点击下载并安装以获取与您使用的 Godot 版本匹配的导出模板。如果你出于某种原因正在运行多个版本的 Godot,你将在窗口中看到其他版本。

导出预设

当你准备好导出你的项目时,点击项目 | 导出

图 7.6:导出设置

图 7.6:导出设置

在此窗口中,你可以通过点击添加…并从列表中选择平台来为每个平台创建预设。你可以为每个平台创建尽可能多的预设。例如,你可能想为你的项目创建“调试”和“发布”版本。

每个平台都有自己的设置和选项——太多无法在此描述。默认值通常很好,但在分发项目之前应该彻底测试它们。有关详细信息,请参阅官方文档docs.godotengine.org/

导出

在导出窗口的底部有两个导出按钮。第一个按钮,导出 PCK/ZIP…,将仅创建你项目数据的 PCK 或打包版本。这不包括可执行文件,因此游戏不能独立运行。如果你需要为你的游戏提供附加组件、更新或可下载内容(DLC),这种方法很有用。

第二个按钮,Windows 上的exe或 Android 上的.apk

图 7.7:导出对话框

图 7.7:导出对话框

在下一个对话框中,你可以选择保存导出项目的位置。请注意,默认勾选的导出时包含调试复选框。当导出游戏的最终发布版本时,你将想要禁用此选项。

为特定平台导出

导出的确切步骤和要求取决于你的目标平台。例如,导出到桌面平台(Windows、MacOS、Linux)非常直接,无需任何额外配置。

在移动平台上导出可能更加复杂。例如,为了导出到 Android,你需要安装 Google 的 Android Studio 并正确配置它。由于移动平台会定期更新,详细要求可能会发生变化,因此你应该在此链接查看 Godot 文档以获取最准确的信息:docs.godotengine.org/en/latest/tutorials/export/

一旦你配置了你希望导出的平台,窗口将看起来像这样:

图 7.8:准备导出

图 7.8:准备导出

Godot 的导出系统全面且稳健。你可以管理多个版本,为不同平台导出不同的功能,以及许多其他选项。虽然一开始可能看起来很复杂,但记住,这种复杂性主要来自特定平台的规则。最好先在桌面平台上练习,然后再尝试与移动平台合作。

在下一节中,你将了解如何使用一种称为着色器的特殊程序类型来实现视觉效果。

着色器简介

着色器是一个设计在 GPU 上运行的程序,它改变了物体在屏幕上的显示方式。着色器在 2D 和 3D 开发中被广泛使用,以创建各种视觉效果。它们被称为着色器,因为它们最初用于着色和光照效果,但今天它们被用于广泛的视觉效果。由于它们在 GPU 上并行运行,因此它们非常快,但也带来了一些限制。

学习更多

本节是对着色器概念的简要介绍。要深入了解,请参阅thebookofshaders.com/和 Godot 的着色器文档。

在本书的早期,当你向网格添加StandardMaterial3D时,你实际上是在添加一个着色器——一个预先配置并内置到 Godot 中的着色器。这对于许多常见情况来说很棒,但有时你需要更具体的东西,为此你需要编写着色器代码。

在 Godot 中,你将使用与 GLSL ES 3.0 非常相似的语言编写着色器。如果你熟悉 C 风格的语言,你会发现语法非常相似。如果不熟悉,一开始可能会觉得有些奇怪。请参阅本节末尾的链接,以获取更多学习资源。

Godot 中的着色器有几种类型:

  • 空间(用于 3D 渲染)

  • canvas_item(用于 2D 渲染)

  • 粒子(用于渲染粒子效果)

  • 天空(用于渲染 3D 天空材质)

  • (用于渲染体积雾效果)

你的着色器第一行必须声明你正在编写哪种类型。通常,当你将着色器添加到特定类型的节点时,这会自动为你填写。

确定着色器类型后,你可以选择你想要影响的渲染过程的哪个阶段:

  • 片段着色器用于设置所有受影响像素的颜色

  • 顶点着色器可以修改形状或网格的顶点,改变其外观形状

  • 光照着色器用于改变对象处理光照的方式

对于这三种着色器类型中的每一种,你将编写将在每个受影响的项上同时运行的代码。这就是着色器的真正威力所在。例如,当使用片段着色器时,代码将在对象的每个像素上同时运行。这与使用传统语言时你可能习惯的过程非常不同,在传统语言中,你会逐个像素地循环。这种顺序代码的速度根本不够快,无法处理现代游戏需要处理的庞大像素数量。

GPU 的重要性

考虑一个以相对较低的分辨率 480 x 720 运行的游戏——这是一个典型的手机分辨率。屏幕上的总像素数接近 350,000。任何在代码中对这些像素的操作都必须在不到 1/60 秒内完成,以避免延迟——当你考虑到还需要在每一帧上运行的其余代码:游戏逻辑、动画、网络和所有其他内容时,这一点尤为重要。这就是为什么 GPU 如此重要的原因,尤其是对于可能每帧处理数百万像素的高端游戏。

创建 2D 着色器

为了演示一些着色器效果,创建一个包含 Sprite2D 节点的场景,并选择你喜欢的任何纹理。这个演示将使用来自 Coin Dash 的玩家图像:

图 7.9:玩家精灵

图 7.9:玩家精灵

着色器可以添加到任何由 CanvasItem 派生的节点——在这个例子中,通过其 材质 属性添加到 Sprite2D。在这个属性中,选择 新着色器材质,然后点击新创建的资源。

图 7.10:添加着色器材质

图 7.10:添加着色器材质

第一个属性是着色器,在这里你可以选择新着色器。当你这样做时,会出现一个创建着色器面板。

图 7.11:创建着色器选项

图 7.11:创建着色器选项

注意 .gdshader。点击 创建,然后你可以点击你新创建的着色器,在底部面板中编辑它。

你的新着色器默认有以下代码:

shader_type canvas_item;
void fragment() {
    // Place fragment code here.
}

着色器函数有几个内置的 TEXTURE 输入,包含对象的纹理像素数据,而 COLOR 输出内置用于设置像素颜色。记住,片段着色器中的代码将影响每个处理的像素的颜色。

当在TEXTURE属性中处理着色器时,例如,坐标是在一个归一化(即,范围从 0 到 1)的坐标空间中测量的。这个坐标空间被称为UV,以区别于 x/y 坐标空间。

图 7.12:UV 坐标空间

图 7.12:UV 坐标空间

作为一个非常小的例子,我们的第一个着色器将根据图像中每个像素的UV位置改变图像中每个像素的颜色。

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

shader_type canvas_item;
void fragment() {
COLOR = vec4(UV.x, UV.y, 0.0, 1.0);
}

图 7.13:颜色渐变

图 7.13:颜色渐变

一旦这样做,你就会看到整个图像变成红色和绿色的渐变。发生了什么?看看前面的 UV 图像——当我们从左到右移动时,红色值从 0 增加到 1,绿色值从底部到顶部同样增加。

让我们再举一个例子。这次,为了让你可以选择颜色,你可以使用一个uniform变量。

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

shader_type canvas_item;
uniform vec4 fill_color : source_color;
void fragment() {
    COLOR = fill_color;
}

你会看到在检查器的着色器参数下出现了填充颜色,你可以更改其值。

图 7.14:着色器参数

图 7.14:着色器参数

为什么在这些例子中整个图像的矩形都改变了颜色?因为输出的COLOR被应用到每个像素上。我们的玩家图像周围有透明的像素,所以我们可以通过不改变像素的a值来忽略它们:

COLOR.rgb = fill_color.rgb;

现在我们可以改变物体的颜色。让我们将其变成一个“击中”效果,这样我们就可以在物体被击中时使其闪烁:

shader_type canvas_item;
uniform vec4 fill_color : source_color;
uniform bool active = false;
void fragment() {
    if (active == true) {
        COLOR.rgb = fill_color.rgb;
    }
}

注意现在你可以通过点击在AnimationPlayer中出现的uniform变量来切换颜色开和关,这些变量为你的视觉效果动画这些值。

这里还有一个例子。这次,我们将围绕图像创建一个轮廓:

shader_type canvas_item;
uniform vec4 line_color : source_color;
uniform float line_thickness : hint_range(0, 10) = 0.5;
void fragment() {
    vec2 size = TEXTURE_PIXEL_SIZE * line_thickness;
    float outline = texture(TEXTURE, UV + vec2(-size.x,
        0)).a;
    outline += texture(TEXTURE, UV + vec2(0, size.y)).a;
    outline += texture(TEXTURE, UV + vec2(size.x, 0)).a;
    outline += texture(TEXTURE, UV + vec2(0, -size.y)).a;
    outline = min(outline, 1.0);
    vec4 color = texture(TEXTURE, UV);
    COLOR = mix(color, line_color, outline - color.a);
}

在这个着色器中,有很多事情在进行。我们使用内置的TEXTURE_PIXEL_SIZE来获取每个像素的归一化大小(其大小与图像大小的比较)。然后,我们得到一个浮点值,它“累加”图像所有四边像素的透明度。最后,我们使用mix()函数根据轮廓值将原始像素的颜色与线条颜色混合。

图 7.15:轮廓着色器

图 7.15:轮廓着色器

需要注意的一个重要事项——你注意到轮廓没有延伸到角色的脚下吗?这是因为一个对象的着色器只能影响该图像的像素。由于这个图像中角色的脚在边缘,下面没有像素可供着色器影响。在处理 2D 着色器效果时,这一点很重要。如果你正在创建 2D 艺术作品,请在图像周围留出几个像素的边框,以防止边缘裁剪。

3D 着色器

让我们尝试一个 3D 着色器,这样你就可以看到 vertex() 着色器是如何工作的。在一个新的场景中,添加一个具有 PlaneMesh 形状的 MeshInstance3D。为了更好地看到顶点,从 Perspective 菜单中选择 Display Wireframe

点击 Mesh 资源以展开它,并在 Material 属性中添加一个新的着色器,就像你之前做的那样。

图 7.16:向平面添加着色器

图 7.16:向平面添加着色器

由于我们使用的是平面形状,所以我们有四个顶点:形状的四个角。vertex() 函数将对这些顶点中的每一个应用一个效果。例如,向它们的 y 值添加将会使它们都向上移动。

让我们从这段代码开始:

shader_type spatial;
void vertex() {
    VERTEX.y += sin(10.0 * UV.x) * 0.5;
}

注意,我们现在使用的是 spatial 类型的着色器,因为我们的节点是 Node3D

图 7.17:移动顶点

图 7.17:移动顶点

看起来似乎没有太多变化——+X 方向的两个顶点稍微向下移动了一点。但是 UV.x 只能是 01,所以 sin() 函数没有太多作用。为了看到更多的变化,我们需要添加更多的顶点。在网格属性中,将两个 32 都修改一下。

图 7.18:处理更多顶点

图 7.18:处理更多顶点

现在,我们可以看到效果中出现了更多的变化,因为沿着 x 轴的不同顶点在平滑的正弦波中上下移动。

为了增加一个有趣的特效,让我们使用内置的 TIME 来动画化这个效果。将代码修改为如下:

VERTEX.y += sin(TIME + 10.0 * UV.x) * 0.5;

花些时间来实验一下。不要害怕尝试新事物——实验是了解着色器工作原理的好方法。

学习更多

着色器能够实现令人惊叹的范围的效果。在 Godot 的着色器语言中进行实验是学习基础的好方法。开始的最佳地方是 Godot 文档中的着色器部分:

docs.godotengine.org/en/latest/tutorials/shaders/

互联网上还有大量资源可以帮助你学习更多。在学习着色器时,你可以使用不特定于 Godot 的资源,并且你不太可能遇到在 Godot 中使用它们的问题。这个概念在所有类型的图形应用程序中都是相同的。

此外,Godot 的文档还包括一个页面,介绍如何将其他流行来源的着色器转换为 Godot 版本的 GLSL。

要了解着色器可以多么强大,请访问 www.shadertoy.com/

这一节只是对着色器和着色器效果的深入主题的简要介绍。虽然掌握它可能是一个非常具有挑战性的主题,但它赋予你的力量使得付出努力是值得的。

在下一节中,你将看到如何使用其他编程语言与 Godot 一起使用。

在 Godot 中使用其他编程语言

本书中的项目都是使用 GDScript 编写的。GDScript 有许多优点,使其成为构建游戏的最佳选择。它与 Godot 的 API 集成非常紧密,其 Python 风格的语法使其适用于快速开发,同时也非常适合初学者。

然而,这并非唯一的选择。Godot 还支持两种其他“官方”脚本语言,并提供使用各种其他语言集成代码的工具。

C#

C# 在游戏开发中非常流行,Godot 版本基于 .NET 6.0 框架。由于其广泛的使用,有许多资源可用于学习 C#,以及大量用于实现各种游戏相关功能的现有代码。

在撰写本文时,Godot 版本 4.0 仍然相对较新。功能正在添加,错误正在不断修复,因此请参阅此链接的 C# 文档以获取最新信息:docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/index.html

如果你想要尝试 C# 实现,首先需要确保你已经安装了 .NET SDK,你可以从 dotnet.microsoft.com/download 获取。你还需要下载包含 C# 支持的 Godot 版本,你可以在 godotengine.org/download 找到它,那里标记为 Godot Engine - .****NET

你还需要使用外部编辑器——例如 Visual Studio Code 或 MonoDevelop——它提供的调试和语言功能比 Godot 内置编辑器更丰富。你可以在 编辑器设置 下的 Dotnet 部分中设置此选项。

要将 C# 脚本附加到节点,请从 附加节点 脚本 对话框中选择语言:

图 7.19:创建脚本对话框

图 7.19:创建脚本对话框

通常,C# 的脚本编写与你在 GDScript 中已经做过的非常相似。主要区别是 API 函数的命名改为 PascalCase 以遵循 C# 标准,而不是 GDScript 标准的 snake_case。

此外,还有一些现有的 C# 库,你可能发现在构建游戏时它们很有用。例如,过程生成、人工智能或其他密集型主题可能更容易通过可用的 C# 库实现。

这里是一个 C# 中 CharacterBody2D 移动的示例。将其与 Jungle Jump 中的移动脚本进行比较:

using Godot;
public partial class MyCharacterBody2D : CharacterBody2D
{
    private float _speed = 100.0f;
    private float _jumpSpeed = -400.0f;
    // Get the gravity from the project settings so you can
       sync with rigid body nodes.
    public float Gravity = ProjectSettings.GetSetting(
        "physics/2d/default_gravity").AsSingle();
    public override void _PhysicsProcess(double delta)
    {
        Vector2 velocity = Velocity;
        // Add the gravity.
        velocity.Y += Gravity * (float)delta;
        // Handle jump.
        if (Input.IsActionJustPressed("jump") &&
        IsOnFloor())
            velocity.Y = _jumpSpeed;
        // Get the input direction.
        Vector2 direction = Input.GetAxis("ui_left",
            "ui_right");
        velocity.X = direction * _speed;
        Velocity = velocity;
        MoveAndSlide();
    }
}

关于设置和使用 C# 的更多详细信息,请参阅上面链接的文档中的 脚本 部分。

其他语言 - GDExtension

有许多编程语言可供选择。每种语言都有其优势和劣势,以及那些更喜欢使用它的粉丝。虽然直接在 Godot 中支持每种语言都没有意义,但有时 GDScript 并不足以解决特定问题。也许您想使用现有的外部库,或者您正在进行一些计算密集型的工作,例如 AI 或程序化世界生成,这在 GDScript 中编写是不合理的。

由于 GDScript 是一种解释型语言,它以灵活性为代价换取性能。这意味着对于一些处理器密集型代码,它可能运行得非常慢,无法接受。在这种情况下,通过运行用编译语言编写的本地代码可以获得最高的性能。在这种情况下,您可以将该代码移动到 GDExtension。

GDExtension 是一种技术,它为 GDScript 和 C# 提供了相同的 API,使得使用其他语言编写与 Godot 通信的代码成为可能。默认情况下,它与 C 和 C++ 直接工作,但通过使用 第三方绑定,您可以使用许多其他语言。

在撰写本文时,有几个项目可以使用 GDExtension,允许您使用其他语言进行脚本编写。这些包括 C、C++、Rust、Python、Nim 以及其他语言。尽管这些额外的语言绑定在撰写本文时仍然相对较新,但每个语言都有专门的开发者团队在开发它们。如果您对在 Godot 中使用特定语言感兴趣,使用“godot + <语言名称>”进行 Google 搜索将帮助您找到可用的资源。

与其他编程语言一起工作对于您可能遇到的任何游戏项目来说都不是必需的,所以如果您对它不熟悉,请不要觉得您需要学习它。这里提供它是为了那些可能有用的人,如果您有一个希望与之工作的首选语言,这也是您需要记住的事情。

在下一节中,您可以探索可用的社区资源,以了解更多关于 Godot 如何工作、查找示例以及甚至获得您自己项目的帮助。

获得帮助 – 社区资源

Godot 的在线社区是其优势之一。由于其开源特性,有各种各样的人们在共同努力改进引擎、编写文档以及互相帮助解决问题。

您可以在 godotengine.org/community 找到官方社区资源的列表。这些链接可能会随时间变化,但以下是一些您应该了解的主要社区资源:

Godot 的 GitHub 仓库是 Godot 开发者工作的地方。如果您需要编译用于您自己使用的自定义版本或只是好奇底层是如何工作的,您可以在那里找到 Godot 的源代码。

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

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

Godot 引擎的 Discord 是一个非常活跃且欢迎的社区,你可以在这里获得帮助,找到问题的答案,并与他人讨论你的项目。你甚至可能会在#beginner 频道遇到本书的作者,他在那里回答问题!

Godot 菜谱

我还创建了godotrecipes.com/上的Godot 菜谱网站。这是一个收集解决方案和示例的集合,帮助你制作你可能需要的任何游戏系统。你可以看到如何制作 FPS 角色、处理复杂的动画状态,或者为你的敌人添加 AI。

此外,还有额外的教程和已完成游戏的示例,你可以尝试使用。

图 7.20:Godot 菜谱网站

图 7.20:Godot 菜谱网站

如本节所示,Godot 引擎的一个巨大优势是其社区。这里列出的资源,以及许多其他资源,都是由对 Godot 引擎充满热情并乐于助人的 Godot 用户社区所构建。在下一节中,你可以了解如何为 Godot 做出贡献。

为 Godot 贡献力量

Godot 是一个开源、社区驱动的项目。构建、测试、编写文档以及支持 Godot 的所有工作主要是由充满热情的个人贡献他们的时间和技能完成的。对于大多数贡献者来说,这是一项充满爱心的劳动,他们为帮助构建人们喜欢使用的优质产品而感到自豪。

为了让 Godot 持续成长和改进,总需要更多社区成员站出来贡献力量。无论你的技能水平如何,或者你能投入多少时间,都有很多方式你可以提供帮助。

为引擎贡献力量

你可以直接以两种主要方式为 Godot 的开发做出贡献。如果你访问github.com/godotengine/godot,你可以看到 Godot 的源代码,以及了解正在进行的具体工作。点击克隆下载按钮,你将获得最新的源代码,并可以测试最新的功能。你需要构建引擎,但不要感到害怕:Godot 是你能找到的最容易编译的开源项目之一。有关说明,请参阅docs.godotengine.org/en/latest/contributing/development/compiling/index.html

如果你无法实际贡献 C++代码,请转到问题标签页,在那里你可以报告或阅读有关错误和建议改进的内容。总是需要有人确认错误报告、测试修复并就新功能提出意见。

编写文档

Godot 的官方文档的质量取决于其社区的贡献。从纠正一个错别字到编写整个教程,所有级别的帮助都非常受欢迎。官方文档的家园是github.com/godotengine/godot-docs

希望到现在为止,你已经花了一些时间浏览官方文档,并对可用的内容有了大致的了解。如果你发现有什么错误或遗漏,请在上文提到的 GitHub 链接处打开一个问题。如果你熟悉使用 GitHub,甚至可以自己提交一个 pull request。只是确保你首先阅读所有指南,以确保你的贡献会被接受。你可以在docs.godotengine.org/en/latest/contributing/ways_to_contribute.html找到指南。

如果你说的不是英语,翻译也非常需要,并且会受到 Godot 的非英语用户的极大欢迎。有关如何在你的语言中做出贡献的说明,请参阅docs.godotengine.org/en/latest/contributing/documentation/editor_and_docs_localization

捐赠

Godot 是一个非营利项目,用户的捐赠在很大程度上有助于支付托管费用和开发资源,例如硬件。财务捐助还允许项目支付核心开发者的工资,使他们能够全职或兼职致力于引擎的开发工作。

为 Godot 做出贡献最简单的方式是通过godotengine.org/donate的捐赠页面。

摘要

在本章中,您学习了一些额外的主题,这些主题将帮助您继续提升您的 Godot 技能。除了本书中探索的功能之外,Godot 还拥有许多其他功能。当您开始独立项目时,您需要知道该往哪里寻找信息,以及在哪里寻求帮助。

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

此外,由于 Godot 是由其社区构建的,您还了解到您如何参与其中,并成为使其成为同类项目中最快速增长的团队之一的一部分。

最后一句话

感谢您抽出时间阅读这本书。我希望您觉得它对您使用 Godot 开始游戏开发之旅有所帮助。本书的目标并不是为您提供制作游戏的“复制粘贴”解决方案,而是帮助您培养对游戏开发过程的直觉。正如您在探索其他资源时会看到的那样,解决问题往往有多种方法,可能没有唯一的“正确”答案。作为开发者,您需要评估并确定在您的情况下什么才是最适合您的。我祝愿您在未来的游戏项目中好运,并希望将来能有机会玩到它们!

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