Godot4-游戏开发项目第二版-全-
Godot4 游戏开发项目第二版(全)
原文:
zh.annas-archive.org/md5/1c4ab12fac2648c1de4cd3b35a7e3832
译者:飞龙
前言
本书是 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 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限
按照以下简单步骤获取这些好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781804610404
-
提交您的购买证明
-
就这些!我们将直接将您的免费 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 下载页面
解压下载的文件,你将拥有 Godot 应用程序。如果你有“程序”或“应用程序”文件夹,可以选择将其拖放到那里。双击应用程序以启动它,你将看到 Godot 的项目管理器窗口,你将在下一节中了解它。
其他安装方法
除了从 Godot 网站下载之外,还有其他几种方法可以将 Godot 安装到你的电脑上。请注意,以这种方式安装时功能没有差异。以下是一些下载应用程序的替代方法:
- Steam:如果你在 Steam 上有账户,可以通过 Steam 桌面应用程序安装 Godot。在 Steam 商店中搜索 Godot,并按照说明进行安装。你可以从 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:项目管理器
首次打开 Godot
第一次打开 Godot 时,您还没有任何项目。您会看到一个弹出窗口询问您是否想要在资源库中探索官方示例项目。选择取消,您将看到项目管理器,如图中所示。
在此窗口中,您可以看到您现有的 Godot 项目列表。您可以选择一个现有项目,点击运行来玩游戏或编辑在 Godot 编辑器中工作。您还可以通过点击新建项目来创建新项目:
图 1.4:新项目设置
在这里,你可以为项目命名并创建一个文件夹来存储它。注意警告信息——Godot 项目在计算机上作为独立的文件夹存储。项目使用的所有文件都必须位于此文件夹中。这使得共享 Godot 项目变得方便,因为你只需要压缩项目文件夹,并且可以确信另一个 Godot 用户能够打开它,而不会缺少任何必要的数据。
渲染器
在创建新项目时,您还可以选择渲染器。这三个选项代表了在需要现代桌面 GPU 的高级、高性能图形和与移动和较老桌面等不太强大的平台兼容性之间的平衡。如果您需要,您可以在以后更改此选项,所以将其保留为默认设置是可以的。如果您以后决定为移动平台构建游戏,Godot 文档提供了大量关于性能和渲染选项的信息。参见第七章以获取链接和更多信息。
选择文件名
当您为新项目命名时,有一些简单的规则您应该尝试遵循,这可能会在将来为您节省一些麻烦。为您的项目起一个描述性的名字——巫师战斗竞技场比游戏 #2是一个更好的项目名称。在未来,您将永远无法记住哪个是游戏编号二,所以尽可能描述得详细。
你还应该考虑如何命名你的项目文件夹和其中的文件。一些操作系统区分大小写,区分 My_Game
和 my_game
,而其他则不区分。如果你将项目从一个计算机移动到另一个计算机,这可能会导致问题。因此,许多程序员为他们的项目开发了一个标准化的命名方案,例如不在文件名中使用空格,并在单词之间使用 _
。无论你采用什么命名方案,最重要的是要保持一致性。
一旦你创建了项目文件夹,test_project
。
控制台窗口
如果你正在使用 Windows 操作系统版本,当你运行 Godot 时,你也会看到一个控制台窗口打开。在这个窗口中,你可以看到由引擎和/或你的项目产生的警告和错误。在 macOS 或 Linux 上,这个窗口不会出现,但如果你使用终端程序从命令行启动应用程序,你可以看到控制台输出。
编辑器窗口
下面的图是 Godot 主编辑窗口的截图。当你使用 Godot 构建项目时,你将在这里花费大部分时间。编辑器界面被分为几个部分,每个部分提供不同的功能。每个部分的特定术语将在 图 1.5 之后描述:
图 1.5:Godot 编辑器窗口
编辑器窗口的主要部分是 视口。这是你在工作时将看到你的游戏部分的地方。
在窗口的顶部中央是一个你可以切换的 工作空间 列表,当你在游戏的不同部分工作时,你可以在这之间切换。你可以切换到 2D 和 3D 模式,以及 脚本 模式,在那里你将编辑你的游戏代码。AssetLib 是你可以下载由 Godot 社区贡献的插件和示例项目的地方。参见 第七章 了解有关使用资产库的更多信息。
图 1.6 展示了你当前工作空间使用的 工具栏。这里的图标将根据你正在处理的对象类型而变化:
图 1.6:工具栏图标
上右角的 游戏测试 区域中的按钮用于启动游戏并在游戏运行时与之交互:
图 1.7:游戏测试按钮
在左侧和右侧是你可以用来查看和选择游戏项目并设置其属性的 坞 或 标签。在左侧坞的底部,你会找到 res://
路径,这是项目的根文件夹。例如,文件路径可能看起来像这样:res://player/player.tscn
。这指的是 player
文件夹中的一个文件:
图 1.8:FileSystem 选项卡
在左侧工具栏的顶部是场景标签,它显示了你在视图中正在工作的当前场景(关于场景的更多内容请参阅图 1.9后的内容):
图 1.9:场景标签
在右侧,你会找到一个标记为检查器的框,在那里你可以查看和调整游戏对象的属性。
随着你在这本书中处理游戏项目,你将了解这些项目的功能,并熟悉导航编辑器界面。
在阅读本节之后,你应该对 Godot 编辑器窗口的布局以及你在本书中将要看到的元素名称感到舒适。你离完成这个介绍并开始制作游戏又近了一步。不过,你注意到图 1.9中的那些项目了吗?那些被称为节点,你将在下一节中了解到它们的所有内容。
了解节点和场景
节点是创建 Godot 游戏的基石。节点是一个可以提供各种专业游戏功能的对象。给定类型的节点可能显示图像、播放动画或表示 3D 模型。节点包含一系列属性,允许你自定义其行为。你添加到项目中的节点取决于你需要的功能。这是一个模块化系统,旨在在构建游戏对象时为你提供灵活性。
你添加的节点组织成一个树状结构。在树中,节点被添加为其他节点的子节点。一个特定的节点可以有任意数量的子节点,但只有一个父节点。当一组节点被收集到一个树中时,它被称为场景:
图 1.10:以树状结构排列的节点
Godot 中的场景通常用于创建和组织项目中的各种游戏对象。你可能有一个包含所有使玩家角色工作的节点和脚本的玩家场景。然后,你可能创建另一个场景来定义游戏的地图:玩家必须导航的障碍和物品。然后,你可以将这些不同的场景组合成最终的游戏。
虽然节点自带各种属性和功能,但任何节点的行为和能力都可以通过附加一个脚本到节点来扩展。这允许你编写代码,使节点能够执行其默认状态之外的功能。例如,你可以添加一个Sprite2D
节点来显示图像,但如果你想让该图像在点击时移动或消失,你需要添加一个脚本来创建这种行为。
节点是强大的工具,理解它们是有效构建 Godot 中游戏对象的关键。然而,仅凭节点本身,它们能做的有限。游戏逻辑——即你的游戏中的对象将遵循的规则——还需要你来提供。在下一节中,你可以通过使用 Godot 的脚本语言编写代码来了解如何实现这一点。
Godot 中的脚本编写
Godot 为节点脚本提供了两种官方语言:GDScript 和 C#。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:完成的游戏
在本章中,我们将涵盖以下主题:
-
设置新项目
-
创建角色动画
-
移动角色
-
使用
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:新项目窗口
在这个项目中,您将创建三个独立的场景——玩家角色、金币和一个显示得分和计时的显示界面——所有这些都将组合到游戏的“主”场景中(见第一章)。在一个更大的项目中,创建单独的文件夹来组织每个场景的资产和脚本可能很有用,但在这个相对较小的游戏中,您可以将所有场景和脚本保存在根文件夹中,该文件夹被称为res://
(res 是资源的缩写)。您项目中的所有资源都将位于res://
文件夹的相对位置。您可以在icon.svg
中看到项目文件,这是 Godot 的图标。
您可以在此处下载游戏的艺术和声音(统称为资产)的 ZIP 文件:github.com/PacktPublishing/Godot-Engine-Game-Development-Projects-Second-Edition/tree/main/Downloads
。将此文件解压到您创建的新项目文件夹中。
图 2.3:文件系统选项卡
例如,金币的图像位于res://assets/coin/
。
由于这款游戏将以竖屏模式(高度大于宽度)运行,我们首先需要设置游戏窗口。
从顶部菜单中选择项目 -> 项目设置。设置窗口看起来像这样:
图 2.4:项目设置窗口
查找前文图示中的480
和720
。在此部分,在拉伸选项下,将模式设置为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:二维坐标系
这不是我的数学老师教给我的!
如果你刚开始接触计算机图形学或游戏开发,可能会觉得正 y 轴向下而不是向上,这在你的数学课上可能学过,这看起来很奇怪。然而,这种方向在计算机图形学应用中非常常见。
向量
你也可以将 (4, 3)
位置视为从 (0, 0)
点或 原点 的偏移,或者称为 偏移量。想象一下从原点指向该点的箭头:
图 2.6:二维向量
这支箭是一个 向量。它代表了许多有用的信息,包括点的位置、其距离或 长度 (m
),以及其相对于 x 轴的 角度 (θ
)。更具体地说,这种类型的向量被称为 位置向量——即描述空间中位置的向量。向量还可以表示运动、加速度或任何具有大小和方向的量。
在 Godot 中,向量有广泛的应用,你将在本书的每个项目中都会用到它们。
现在,你应该已经了解了二维坐标空间的工作原理以及向量如何帮助定位和移动对象。在下一节中,你将创建玩家对象并使用这些知识来控制其移动。
第一部分——玩家场景
你将制作的第一个场景是玩家对象。为玩家(和其他对象)创建单独的场景的一个好处是,你可以在创建游戏的其他部分之前独立测试它。随着你的项目规模和复杂性的增长,这种游戏对象的分离将变得越来越有帮助。将单个游戏对象与其他对象保持分离,使得它们更容易调试、修改,甚至完全替换而不影响游戏的其它部分。这也意味着你的玩家对象可以重复使用——你可以将这个玩家场景放入一个完全不同的游戏中,它将正常工作。
你的玩家场景需要完成以下事情:
-
显示你的角色及其动画
-
通过移动角色来响应用户输入
-
检测与其他游戏对象(如金币或障碍物)的碰撞
创建场景
首先点击 Area2D
。然后,点击节点的名称并将其更改为 Player
。点击 场景 -> 保存场景 (Ctrl + S) 来保存场景。
图 2.7:添加节点
现在查看 player.tscn
文件。每次你在 Godot 中保存场景时,它都会使用 .tscn
扩展名——这是 Godot 场景的文件格式。"t" 在名称中的含义是 "text",因为这些是文本文件。如果你好奇,可以自由地在外部文本编辑器中查看它,但你不应该手动编辑它;否则,你可能会不小心损坏文件。
现在你已经创建了场景的 Area2D
节点,因为它是一个 2D 节点,所以它可以在 2D 空间中移动,并且可以检测与其他节点的重叠,因此我们可以检测到硬币和其他游戏对象。在设计游戏对象时,选择用于特定游戏对象的节点是你的第一个重要决定。
在添加任何子节点之前,确保你不小心通过点击它们来移动或调整它们的大小是一个好主意。选择 Player
节点并将鼠标悬停在锁旁边的图标上,选择节点分组:
图 2.8:切换节点分组
工具提示说 使选定的节点子节点不可选择,这是好的——它将有助于避免错误。点击按钮,你会在玩家节点名称旁边看到相同的图标:
图 2.9:节点分组图标
在创建新场景时始终这样做是个好主意。如果一个对象的子节点发生偏移或缩放,可能会导致意外的错误并且难以调试。
精灵动画
使用 Area2D
,你可以检测其他对象是否与玩家重叠或碰撞,但 Area2D
本身没有外观。你还需要一个可以显示图像的节点。由于角色有动画,请选择玩家节点并添加一个 AnimatedSprite2D
节点。此节点将处理玩家的外观和动画。注意节点旁边有一个警告符号。AnimatedSprite2D
需要一个 SpriteFrames
资源,其中包含它可以显示的动画。要创建一个,找到 Inspector 窗口中的 Frames 属性并点击
图 2.10:添加 SpriteFrames 资源
接下来,在相同的位置,点击那里出现的 SpriteFrames
标签以在屏幕底部打开一个新面板:
图 2.11:SpriteFrames 面板
在左侧是动画列表。点击 default
并将其重命名为 run
。然后,点击 idle
和第三个名为 hurt
。
在 res://assets/player/
文件夹中,并将它们拖动到相应的动画中:
图 2.12:设置玩家动画
每个新的动画都有一个默认的每秒5
帧的速度设置。这有点太慢了,所以选择每个动画并将速度设置为8
。
要查看动画的实际效果,请点击播放按钮 ()。您的动画将出现在检查器窗口中动画属性的下拉菜单中。选择一个来查看其效果:
图 2.13:动画属性
您还可以选择一个默认播放的动画。选择idle
动画并点击加载时自动播放按钮。
图 2.14:设置动画自动播放
然后,您将编写代码来根据玩家的行为在这些动画之间进行选择。然而,首先,您需要完成玩家节点的设置。
玩家图像有点小,所以将AnimatedSprite2D
的缩放设置为(2, 2)
以增加其大小。您可以在检查器窗口的变换部分找到此属性。
图 2.15:设置缩放属性
碰撞形状
当使用Area2D
或其他碰撞对象时,你需要告诉 Godot 对象的具体形状。其碰撞形状定义了它所占据的区域,并用于检测重叠和/或碰撞。形状由各种Shape2D
类型定义,包括矩形、圆形和多边形。在游戏开发中,这有时被称为击打框。
为了方便起见,当您需要向区域或物理体添加形状时,您可以添加CollisionShape2D
作为子节点。然后,您可以在编辑器中选择所需的形状类型并编辑其大小。
将CollisionShape2D
作为Player
节点的子节点添加(确保不要将其作为AnimatedSprite2D
的子节点添加)。在检查器窗口中,找到形状属性并点击
图 2.16:添加碰撞形状
拖动橙色手柄以调整形状的大小以覆盖精灵。提示 - 如果您在拖动手柄时按住Alt键,形状将对称地调整大小。您可能已经注意到碰撞形状不是在精灵上居中的。这是因为精灵图像本身在垂直方向上并不居中。您可以通过向AnimatedSprite2D
添加一个小偏移量来修复这个问题。选择节点并查找(``0, -5)
。
图 2.17:调整碰撞形状大小
完成后,您的Player场景应该看起来像这样:
图 2.18:玩家节点设置
编写玩家脚本
现在,你准备好向玩家添加一些代码了。将脚本附加到节点允许你添加节点本身不提供的额外功能。选择 Player
节点并点击 新建 脚本 按钮:
图 2.19:新建脚本按钮
在 附加节点脚本 窗口中,你可以保持默认设置不变。如果你记得保存场景,脚本将自动命名为与场景名称匹配。点击 创建,你将被带到脚本窗口。你的脚本将包含一些默认的注释和提示。
每个脚本的第一个行描述了它附加到的节点类型。就在那之后,你可以开始定义你的变量:
extends Area2D
@export var speed = 350
var velocity = Vector2.ZERO
var screensize = Vector2(480, 720)
使用 @export
注解在 speed
变量上允许你在 Player
节点中设置其值,你将看到你在脚本中编写的 350
速度值。
图 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
的参数,然后将其乘以 velocity
。delta
是什么?
游戏引擎试图以每秒 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
是一个布尔属性,这意味着它可以设置为true
或false
。布尔值也是比较的结果,例如<
。正因为如此,你可以直接将属性设置为比较的结果。
再次播放场景,并检查每种情况下动画是否正确。
开始和结束玩家的移动
主场景需要通知玩家游戏何时开始和结束。为此,向玩家添加一个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:节点的信号列表
也要在那里记录你的自定义信号。由于其他对象也将是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:组选项卡
硬币脚本
你的下一步是为 Coin
节点添加一个脚本。选择节点并点击新脚本按钮,就像你对 Player
节点所做的那样。如果你取消选择 模板 选项,你将得到一个没有注释或建议的空脚本。硬币的代码比玩家的代码要短得多:
extends Area2D
var screensize = Vector2.ZERO
func pickup():
queue_free()
请记住,pickup()
函数是由玩家脚本调用的。它定义了收集硬币时硬币将执行的操作。queue_free()
是 Godot 用于删除节点的函数。它安全地从树中删除节点并从内存中删除它及其所有子节点。稍后,你将在这里添加视觉和音频效果,但现在,让硬币消失就足够了。
删除节点
queue_free()
并不会立即删除对象,而是将其添加到队列中,在当前帧结束时删除。这比立即删除节点更安全,因为游戏中可能还有其他代码需要节点存在。通过等待到帧的末尾,Godot 可以确保所有可以访问节点的代码都已完成,节点可以安全地被移除。
你现在已经完成了这个游戏所需的两个对象中的第二个。硬币对象可以随机放置在屏幕上,并且它可以检测玩家何时触摸它,因此可以被收集。拼图的剩余部分是如何将所有这些组合在一起。在下一节中,你将创建第三个场景以随机生成硬币并允许玩家与之交互。
第三部分 – 主场景
Main
场景是将游戏的所有部分联系在一起的关键。它将管理玩家、硬币、时钟以及游戏的所有其他部分。
节点设置
创建一个新的场景并添加一个名为 Main
的 Node
。最简单的节点类型是 Node
—— 它本身几乎不做任何事情,但你会将其用作所有游戏对象的父节点,并添加一个脚本,使其具有你需要的功能。保存场景。
通过点击 player.tscn
将玩家添加为 Main
的子节点:
图 2.23:实例化场景
将以下节点作为 Main
的子节点添加:
-
一个名为
Background
的TextureRect
节点——用于背景图像 -
一个名为
GameTimer
的Timer
节点——用于倒计时计时器
确保将 Background
作为第一个子节点,通过在节点列表中将它拖到玩家上方。节点按照在树中显示的顺序绘制,所以如果 Background
是第一个,那么确保它在玩家后面绘制。通过将 assets
文件夹中的 grass.png
图像拖动到编辑器窗口顶部的布局按钮上的 Texture 属性来添加图像到 Background
节点。将 Stretch Mode 改为 Tile,然后通过点击编辑器窗口顶部的布局按钮将大小设置为 Full Rect:
图 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 将显示以下信息:
-
得分
-
剩余时间
-
一个消息,例如 游戏结束
-
一个开始按钮
节点设置
创建一个新的场景并添加一个名为 HUD
的 CanvasLayer
节点。一个 CanvasLayer
节点创建一个新的绘图层,这将允许你在游戏的其他部分之上绘制你的 UI 元素,这样它就不会被游戏对象,如玩家或金币,覆盖。
Godot 提供了各种 UI 元素,可以用来创建从指示器,如生命值条,到复杂界面,如存货界面。实际上,你用来制作这个游戏的 Godot 编辑器就是使用 Godot UI 元素构建的。UI 的基本节点都扩展自 Control
,并在节点列表中显示为绿色图标。为了创建你的 UI,你将使用各种 Control
节点来定位、格式化和显示信息。以下是完成后的 HUD 看起来的样子:
图 2.25:HUD 布局
消息标签
将一个Label
节点添加到场景中,并将其名称更改为Message
。当游戏结束时,此标签将显示游戏的标题以及游戏结束。此标签应位于游戏屏幕中央。您可以使用鼠标拖动它,或在检查器窗口中直接设置值,但使用布局菜单中提供的快捷键最简单,这将为您设置值。
从布局菜单中选择HCenter Wide:
图 2.26:消息定位
标签现在横跨屏幕宽度并垂直居中。文本属性设置标签显示的文本。将其设置为Coin Dash!,并将水平对齐和垂直对齐都设置为居中。
Label
节点的默认字体非常小且不吸引人,所以下一步是分配一个自定义字体。在标签设置属性中,选择新标签设置然后点击它以展开。
从Kenney Bold.ttf
字体文件中拖动它到字体属性,并将大小设置为48。您还可以通过添加阴影来改善外观——尝试以下截图中的设置,或尝试您自己的设置:
图 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()
在此函数中,您将显示消息并启动计时器。要隐藏消息,连接 Timer
的 timeout
信号(记住,它将自动创建新函数):
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
下一个任务是设置 Main
和 HUD
之间的通信。将 HUD
的实例添加到 Main
中。在 Main
中,连接 GameTimer
的 timeout
信号,并添加以下内容,以便每次 GameTimer
超时(每秒)时,剩余时间都会减少:
func _on_game_timer_timeout():
time_left -= 1
$HUD.update_timer(time_left)
if time_left <= 0:
game_over()
接下来,选择 Main
中的 Player
实例,并连接其 pickup
和 hurt
信号:
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
需要激活 Main
的 new_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()
这需要很多新的代码,所以让我们来分解一下:
首先,CollisionShape2D
的disabled
属性需要设置为true
。然而,如果你直接尝试设置它,Godot 会抱怨。在碰撞正在处理时,不允许更改物理属性;你必须等待当前帧的结束。这就是set_deferred()
的作用。
接下来,create_tween()
创建一个 tween 对象,set_parallel()
表示任何后续的 tween 都应该同时发生,而不是一个接一个地发生,set_trans()
将过渡函数设置为“二次”曲线。
之后是两行设置属性缓动的代码。tween_property()
函数接受四个参数——要影响的对象(self
)、要更改的属性、结束值和持续时间(以秒为单位)。
现在,当你运行游戏时,你应该看到硬币在被拾取时播放效果。
声音
声音是游戏设计中重要但常被忽视的部分。良好的声音设计可以在非常小的努力下为你的游戏增添大量活力。声音可以给玩家提供反馈,将他们与角色情感上联系起来,甚至可以是游戏玩法的一部分(“你听到背后有脚步声”)。
对于这个游戏,你将添加三个声音效果。在Main
场景中,添加三个AudioStreamPlayer
节点,并分别命名为CoinSound
、LevelSound
和EndSound
。将每个声音从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
脚本中复制代码。
接下来,添加一个名为Lifetime
的Timer
节点。这将限制对象在屏幕上停留的时间。将其2
和timeout
信号都设置为 2,以便在时间周期结束时移除加速道具:
func _on_lifetime_timout():
queue_free()
现在,转到你的Main
场景,并添加另一个名为PowerupTimer
的Timer
节点。在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:动画设置
将 Timer
节点添加到 Coin
场景中,然后将其添加到硬币的脚本中:
func _ready():
$Timer.start(randf_range(3, 8))
然后,连接 Timer
的 timeout
信号并添加以下内容:
func _on_timer_timeout():
$AnimatedSprite2d.frame = 0
$AnimatedSprite2d.play()
尝试运行游戏并观察硬币的动画。这需要非常小的努力就能产生一个很好的视觉效果,至少对程序员来说是这样——艺术家必须绘制所有这些帧!你会在专业游戏中注意到很多这样的效果。虽然很微妙,但视觉吸引力使得游戏体验更加愉悦。
障碍物
最后,通过引入玩家必须避免的障碍物,可以使游戏更具挑战性。触摸障碍物将结束游戏。
图 2.29:带有障碍物的示例游戏
创建一个新的 Area2D
场景并将其命名为 Cactus
。给它添加 Sprite2D
和 CollisionShape2D
子节点。从 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:空间岩石截图
在这个项目中,你将学习以下关键主题:
-
使用自定义输入操作
-
使用
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_left
、rotate_right
、thrust
和shoot
。将每个动作的名称输入到添加新动作框中,然后按Enter键或点击添加按钮。确保你输入的名称与显示的完全一致,因为它们将在后面的代码中使用。
然后,对于每个动作,点击其右侧的+按钮。在弹出的窗口中,你可以手动选择特定的输入类型,或者按物理按钮,Godot 将检测它。你可以为每个动作添加多个输入。例如,为了允许玩家使用箭头键和 WASD 键,设置将看起来像这样:
图 3.2:输入动作
如果你将游戏手柄或其他控制器连接到你的电脑,你也可以以相同的方式将其输入添加到动作中。
注意
在这个阶段,我们只考虑按钮式输入,所以虽然你将能够在这个项目中使用 D-pad,但使用模拟摇杆将需要更改项目的代码。
刚体物理
在游戏开发中,你经常需要知道游戏空间中的两个物体是否相交或接触。这被称为碰撞检测。当检测到碰撞时,你通常希望发生某些事情。这被称为碰撞响应。
Godot 提供了三种类型的物理体,它们被归类在PhysicsBody2D
节点类型下:
-
StaticBody2D
:静态体是指不会被物理引擎移动的物体。它参与碰撞检测,但不会移动响应。这种类型的物体通常用于环境的一部分或不需要任何动态行为的物体,例如墙壁或地面。 -
RigidBody2D
:这是提供模拟物理的物理体。这意味着你不会直接控制RigidBody2D
物理体的位置。相反,你对其施加力(重力、冲量等),然后 Godot 的内置物理引擎计算结果运动,包括碰撞、弹跳、旋转和其他效果。 -
CharacterBody2D
:这种类型的物体提供碰撞检测,但没有物理属性。所有运动都必须在代码中实现,你必须自己实现任何碰撞响应。运动学体通常用于玩家角色或其他需要街机风格物理而不是真实模拟的演员,或者当你需要更精确地控制物体移动时。
了解何时使用特定的物理体类型是构建游戏的重要组成部分。使用正确的类型可以简化你的开发,而试图强制错误的节点执行任务可能会导致挫败感和不良结果。随着你与每种类型的物体一起工作,你会了解它们的优缺点,并学会何时它们可以帮助构建你需要的东西。
在这个项目中,你将使用RigidBody2D
节点来控制船只以及岩石本身。你将在后面的章节中学习其他类型的物体。
单个 RigidBody2D
节点有许多你可以用来自定义其行为的属性,例如 质量、摩擦 或 弹跳。这些属性可以在检查器中设置。
刚体也受到全局属性的影响,这些属性可以在 项目设置 下的 物理 | 2D 中设置。这些设置适用于世界中的所有物体。
图 3.3:项目物理设置
在大多数情况下,你不需要修改这些设置。但是,请注意,默认情况下,重力值为 980
,方向为 (0, 1)
,即向下。如果你想改变世界的重力,你可以在这里进行更改。
如果你点击 项目设置 窗口右上角的 高级设置 切换按钮,你将看到许多物理引擎的高级配置值。你应该特别注意其中的两个:默认线性阻尼 和 默认角阻尼。这些属性分别控制物体失去前进速度和旋转速度的快慢。将它们设置为较低的值会使世界感觉没有摩擦,而使用较大的值会使物体移动时感觉像穿过泥浆。这可以是一种很好的方式,将不同的运动风格应用于各种游戏对象和环境。
区域物理覆盖
Area2D
节点也可以通过使用它们的 Space Override 属性来影响刚体物理。然后,将应用自定义的重力和阻尼值到进入该区域的任何物体上。
由于这款游戏将在外太空进行,因此不需要重力,所以设置为 0
。你可以保留其他设置不变。
这就完成了项目设置任务。回顾这一节并确保你没有遗漏任何内容是个好主意,因为你在这里所做的更改将影响许多游戏对象的行为。你将在下一节中看到这一点,那时你将制作玩家的飞船。
玩家的飞船
玩家的飞船是这款游戏的核心。你将为这个项目编写的绝大部分代码都将关于使飞船工作。它将以经典的“小行星风格”进行控制,包括左右旋转和前进推进。玩家还将能够发射激光并摧毁漂浮的岩石。
图 3.4:玩家的飞船
身体和物理设置
创建一个新的场景,并添加一个名为 Player
的 RigidBody2D
作为根节点,带有 Sprite2D
和 CollisionShape2D
子节点。将 res://assets/player_ship.png
图像添加到 Sprite2D
。飞船图像相当大,所以将 Sprite2D
设置为 (0.5, 0.5)
和 90
。
图 3.5:玩家精灵设置
精灵方向
飞船的图像是向上绘制的。在 Godot 中,0
度的旋转指向右侧(沿 x
轴)。这意味着你需要旋转精灵,使其与身体的朝向相匹配。如果你使用正确方向的绘画艺术,你可以避免这一步。然而,发现向上方向的绘画艺术是非常常见的,所以你应该知道该怎么做。
在 CollisionShape2D
中添加一个 CircleShape2D
并将其缩放以尽可能紧密地覆盖图像。
图 3.6:玩家碰撞形状
玩家飞船以像素艺术风格绘制,但如果你放大查看,可能会注意到它看起来非常模糊和“平滑”。Godot 默认的纹理绘制过滤器设置使用这种平滑技术,这对于某些艺术作品来说看起来不错,但对于像素艺术通常是不想要的。你可以在每个精灵(在 CanvasItem 部分中)上单独设置过滤器,或者你可以在 项目设置 中全局设置。
打开 项目设置 并检查 高级设置 开关,然后找到 渲染/纹理 部分。在底部附近,你会看到两个 Canvas Textures 设置。将 默认纹理过滤器 设置为 最近邻。
图 3.7:默认纹理过滤器设置
保存场景。在处理更大规模的项目时,建议根据每个游戏对象将场景和脚本组织到文件夹中,而不是将它们全部保存在根项目文件夹中。例如,如果你创建一个“玩家”文件夹,你可以将所有与玩家相关的文件保存在那里。这使得查找和修改你的各种游戏对象变得更加容易。虽然这个项目相对较小——你将只有几个场景——但随着项目规模和复杂性的增加,养成这种习惯是很好的。
状态机
玩家飞船在游戏过程中可以处于多种不同的状态。例如,当 存活 时,飞船是可见的,并且可以被玩家控制,但它容易受到岩石的撞击。另一方面,当 无敌 时,飞船应该看起来半透明,并且对伤害免疫。
程序员处理此类情况的一种常见方式是在代码中添加布尔变量,或 标志。例如,当玩家首次生成时,将 invulnerable
标志设置为 true
,或者当玩家死亡时,将 alive
设置为 false
。然而,当由于某种原因同时将 alive
和 invulnerable
都设置为 false
时,这可能会导致错误和奇怪的情况。在这种情况下,如果一块石头撞击玩家会发生什么?如果飞船只能处于一个明确定义的状态,那就更好了。
解决这个问题的方法之一是使用有限状态机(FSM)。当使用 FSM 时,实体在给定时间只能处于一个状态。为了设计你的 FSM,你定义了多个状态以及什么事件或动作可以导致从一个状态转换到另一个状态。
以下图显示了玩家飞船的 FSM:
图 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_power
和spin_power
控制飞船加速和转向的速度。thrust
代表引擎施加的力:当滑行时为(0, 0)
,当引擎开启时为一个指向前方的向量。rotation_dir
表示飞船转向的方向,以便你可以施加一个扭矩或旋转力。
如我们之前在1
和5
中看到的。你可以稍后调整它们以改变飞船的处理方式。
下一步是检测输入并移动飞船:
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 的position
或linear_velocity
。如果你需要直接影响物体的状态,请使用_integrate_forces
,这允许你直接访问物理状态。
并且在_integrate_forces()
的描述中:
(它)允许你读取并安全地修改对象的模拟状态。如果你需要直接更改物体的位置或其他物理属性,请使用此方法代替_physics_process
。
因此,答案是当你想直接影响刚体的位置时使用这个单独的函数。使用_integrate_forces(
)让你可以访问物体的PhysicsDirectBodyState2D
– 一个包含大量关于物体当前状态的有用信息的 Godot 对象。由于你想改变物体的位置,这意味着你需要修改它的Transform2D
。
Transform2D
的origin
属性。
使用这些信息,你可以通过添加以下代码来实现环绕效果:
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.png
的 Sprite2D
和一个 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
)的子节点是完全可以的。
在玩家上添加一个名为 Muzzle
的 Marker2D
节点。这将标记枪的枪口——子弹将生成的位置。将 (50, 0)
设置为将其直接放置在船的前方。
接下来,添加一个Timer
节点并将其命名为GunCooldown
。这将给枪提供冷却时间,防止在经过一定时间后发射新的子弹。勾选One Shot和Autostart复选框以“开启”。
将以下新变量添加到玩家的脚本中:
@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)
),因此子弹会在完全错误的位置生成。这是理解局部和全局坐标之间区别重要性的另一个例子。
为了允许枪再次射击,连接GunCooldown
的timeout
信号:
func _on_gun_cooldown_timeout():
can_shoot = true
测试玩家的飞船
创建一个新的场景,使用名为Main
的Node
,并添加一个名为Background
的Sprite2D
作为子节点。在Player
场景中使用res://assets/space_background.png
。
播放主场景并测试是否可以飞行和射击。
现在玩家的飞船工作正常,是时候暂停并检查你的理解了。与刚体一起工作可能会有点棘手;花几分钟时间实验本节中的一些设置和代码。只是确保在进入下一节之前将它们改回原样,下一节你将添加小行星到游戏中。
添加岩石
游戏的目标是摧毁漂浮的太空岩石,所以现在你可以射击了,是时候添加它们了。像飞船一样,岩石将使用RigidBody2D
,这将使它们以恒定的速度直线运动,除非受到干扰。它们还会以逼真的方式相互弹跳。为了使事情更有趣,岩石将开始时很大,当你射击它们时,会分裂成多个更小的岩石。
场景设置
创建一个新的场景,使用名为Rock
的RigidBody2D
节点,并添加一个使用res://assets/rock.png
纹理的Sprite2D
子节点。添加一个CollisionShape2D
,但不要设置其形状。因为你会生成不同大小的岩石,所以碰撞形状需要在代码中设置并调整到正确的大小。
你不希望岩石滑行到停止,所以它们需要忽略默认的线性和角阻尼。将两个都设置为0
和New 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
计算正确碰撞大小的地方。请注意,由于position
和size
已经被用作类变量,你可以使用下划线作为函数的参数以防止冲突。
岩石也需要像玩家一样绕屏幕滚动,所以使用相同的技术与_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.10:启用网格吸附
按照以下截图所示的顺序绘制点。点击第四个点后,点击关闭曲线按钮(截图中标为5),你的路径将完成:
图 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”组
现在,如果你运行游戏并射击一块石头,你会看到一个错误消息,因为子弹正在尝试调用石头的 explode()
方法,但你还没有定义它。此方法需要做三件事:
-
移除石头
-
播放爆炸动画
-
通知
Main
生成新的、更小的石头
爆炸场景
爆炸将是一个独立的场景,你可以将其添加到 Rock
,然后添加到 Player
。它将包含两个节点:
-
Sprite2D
命名为Explosion
-
AnimationPlayer
对于 Sprite2D
节点的 res://assets/explosion.png
。你会注意到这是一个 Sprite2D
节点,它支持使用它们。
在检查器中,找到精灵的 8
。这将把精灵图集切成 64 个单独的图像。你可以通过更改 0
和 63
来验证这一点。确保在继续之前将其设置回 0
。
图 3.13:精灵动画设置
AnimationPlayer
节点可以用来动画化任何节点的任何属性。你将使用它来随时间改变 Frame 属性。首先选择节点,你会在底部打开 Animation 面板:
图 3.14:动画面板
点击 explosion
。设置 0.64
和 0.01
。选择 Sprite2D
节点,你会注意到检查器中现在每个属性旁边都有一个键符号。点击一个键将在当前动画中创建一个 关键帧。
图 3.15:动画时间设置
点击 Explosion
节点的 AnimationPlayer
旁边的键,在时间 0
时,你想让精灵的 0
。
滑动刮擦器到时间0.64
(如果看不到,可以使用滑块调整缩放)。设置63
并再次点击键。现在动画知道在动画的最终时间使用最后一张图像。然而,你还需要让AnimationPlayer
知道你希望在两个点之间的时间使用所有中间值。在动画轨道的右侧有一个更新模式下拉菜单。它目前设置为离散,你需要将其更改为连续:
图 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:爆炸图
再次播放游戏并检查一切是否按预期工作。
这是一个很好的地方停下来回顾你已经做了什么。你已经完成了游戏的所有基本功能:玩家可以飞行并射击;岩石漂浮、弹跳和爆炸;并且会生成新的岩石。你现在应该对使用刚体感到更加自在。在下一节中,你将开始构建界面,允许玩家开始游戏并在游戏过程中查看重要信息。
创建用户界面
为你的游戏创建 UI 可能非常复杂,或者至少很耗时。精确放置单个元素并确保它们在不同尺寸的屏幕和设备上都能正常工作,对于许多程序员来说,这是游戏开发中最不有趣的部分。Godot 提供了各种 Control
节点来协助这个过程。学习如何使用各种 Control
节点将有助于减轻创建精美 UI 的痛苦。
对于这个游戏,你不需要一个非常复杂的 UI。游戏需要提供以下信息和交互:
-
开始按钮
-
状态信息(例如“准备”或“游戏结束”)
-
得分
-
生命值计数器
这里是你将要制作的内容预览:
图 3.18:UI 布局
创建一个新的场景,并将名为 HUD
的 CanvasLayer
节点作为根节点。你将在这个层上使用 Control
节点的布局功能来构建 UI。
布局
Godot 的 Control
节点包括许多专用容器。这些节点可以嵌套在一起以创建所需的精确布局。例如,MarginContainer
会自动为其内容添加填充,而 HBoxContainer
和 VBoxContainer
分别按行或列组织其内容。
按照以下步骤构建布局:
- 首先添加
Timer
和MarginContainer
子节点,它们将包含得分和生命值计数器。在 布局 下拉菜单中,选择 Top Wide。
图 3.19:Top Wide 控件对齐
-
在检查器中,将 主题覆盖/常量 中的四个边距设置为 20。
-
将
Timer
设置为开启并设置为2
。 -
作为容器的子节点,添加一个
HBoxContainer
,它将把得分计数器放在左边,生命值计数器放在右边。在这个容器下,添加一个Label
(命名为ScoreLabel
)和一个HBoxContainer
(命名为LivesCounter
)。
将 ScoreLabel
的值设置为 0
,并在 res://assets/kenvector_future_thin.ttf
下设置字体大小为 64
。
-
选择
LivesCounter
并设置20
,然后添加一个子节点TextureRect
并命名为L1
。将res://assets/player_small.png
拖到选中的L1
节点,并按 duplicate (Ctrl + D) 键两次以创建L2
和L3
(它们将被自动命名)。在游戏过程中,HUD
将显示或隐藏这三个纹理,以指示玩家剩余的生命值。 -
在更大、更复杂的 UI 中,你可能将这一部分保存为其自己的场景,并将其嵌入 UI 的其他部分。然而,这个游戏只需要几个更多元素,所以将它们全部组合在一个场景中是完全可以的。
-
作为
HUD
的子节点,添加一个VBoxContainer
,并在其中添加一个名为Message
的Label
和一个名为StartButton
的TextureButton
。将VBoxContainer
的布局设置为100
。 -
在
res://assets
文件夹中,有两个StartButton
的纹理,一个是正常的(play_button.png
),另一个是在鼠标悬停时显示的('play_button_h.png'
)。将这些拖到检查器的Textures/Normal和Textures/Hover中。将按钮的布局/容器大小/水平设置为收缩居中,这样它就会在水平方向上居中。 -
将
Message
文本设置为“Space Rocks!”,并使用与ScoreLabel
相同的设置来设置其字体。将水平对齐设置为居中。
完成后,你的场景树应该看起来像这样:
图 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()
将StartButton
的pressed
信号和Timer
的timeout
信号连接起来:
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)
你会在每次关卡变化时调用这个函数。它宣布关卡编号,并生成与数量相匹配的岩石。注意,由于你初始化level
为0
,这会将它设置为1
,用于第一个关卡。你还应该删除_ready()
中生成岩石的代码——你不再需要它了。
为了检测关卡何时结束,你需要检查还剩下多少岩石:
func _process(delta):
if not playing:
return
if get_tree().get_nodes_in_group("rocks").size() == 0:
new_level()
接下来,你需要将HUD
的start_game
信号连接到Main
的new_game()
函数。
在Main
中选择HUD
实例,并在Main
中找到其start_game
信号,然后你可以选择new_game()
函数:
图 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
在本节中,你创建了一个比以前项目更复杂的 UI,包括一些新的Control
节点,如TextureProgressBar
,并使用信号将所有内容连接在一起。在下一节中,你将处理游戏的结束:玩家死亡时应该发生什么。
结束游戏
在本节中,你将使玩家检测它被岩石击中,添加无敌功能,并在玩家生命耗尽时结束游戏。
将Explosion
场景的一个实例添加到Player
场景中,取消选中其名为InvulnerabilityTimer
的Timer
节点,并将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.24:一个示例路径
当敌人沿着路径移动时,当它遇到尖锐的角落时,看起来不会非常平滑。为了平滑曲线,点击路径工具栏中的第二个按钮(其工具提示说 选择控制点)。现在,如果您点击并拖动曲线上的任何点,您将添加一个控制点,允许您在该点弯曲线条。平滑上面的线条会产生类似这样的效果:
图 3.25:使用控制点
向场景中添加两个或三个更多的 Path2D
节点,并按照您喜欢的样式绘制路径。添加环和曲线而不是直线会使敌人看起来更加动态(并且更难被击中)。请记住,您点击的第一个点将是路径的起点,因此请确保在不同的屏幕边缘开始,以增加多样性。以下有三个示例路径:
图 3.26:添加多个路径
保存场景。您将将其添加到敌人的场景中,以便它能够跟随这些路径。
敌人场景
为敌人创建一个新的场景,使用 Area2D
作为其根节点。添加一个 Sprite2D
子节点,并使用 res://assets/enemy_saucer.png
作为其 3
,这样您就可以在不同颜色的飞碟之间进行选择:
-
如您之前所做的那样,添加一个
CollisionShape2D
并将其缩放为CircleShape2D
以覆盖图像。添加一个EnemyPaths
场景实例和一个AnimationPlayer
。在AnimationPlayer
中,您将添加一个动画,以便在飞碟被击中时产生闪光效果。 -
添加一个名为
flash
的动画。设置为0.25
和0.01
。您将动画化的属性是Sprite2D
的0.04
并将颜色从0.04
改变回来,使其变回白色。 -
重复此过程两次,以便总共有三个闪光效果。
-
添加一个
Explosion
场景实例并将其隐藏。添加一个名为GunCooldown
的Timer
节点来控制敌人射击的频率。设置为1.5
并将 Autostart 设置为开启。 -
向敌人添加一个脚本并连接计时器的
timeout
。暂时不要向函数中添加任何内容。 -
在
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))
连接 EnemyTimer
的 timeout
信号:
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
信号和 VisibleOnScreenNotifier2D
的 screen_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
的子节点添加:TextureRect
和 TextureProgressBar
。将 TextureProgressBar
重命名为 ShieldBar
。将它们放置在 Score
标签之后和 LivesCounter
之前。你的节点设置应该看起来像这样:
图 3.27:更新后的 HUD 节点布局
将 res://assets/shield_gold.png
拖入 TextureRect
。这将是一个图标,表示这个条形图显示护盾值。将 拉伸模式 设置为 居中,这样纹理就不会扭曲。
ShieldBar
有三个 res://assets/bar_green_200.png
放入这个属性。其他两个纹理属性允许你通过设置一个图像来绘制在进度纹理之上或之下来自定义外观。将 res://assets/bar_glass_200.png
拖入 Over 属性。
在 0
和 1
中,因为这个条形图将显示护盾与其最大值的比率,而不是其数值。这意味着 0.01
到 .75
以看到条形图部分填充。此外,在 布局/容器大小 部分,勾选 扩展 复选框并将 垂直 设置为 收缩居中。
完成后,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
信号连接到 HUD
的 update_shield()
函数。
运行游戏并验证护盾是否正常工作。你可能想要增加或减少护盾再生速率以获得你满意的速度。当你准备好继续时,在下一节中,你将为游戏添加一些声音。
声音和视觉效果
游戏的结构和玩法已经完成。在本节中,你将添加一些额外的效果来提升游戏体验。
声音和音乐
在 res://assets/sounds
文件夹中包含了一些游戏音频效果。要播放声音,需要通过 AudioStreamPlayer
节点加载。将两个这样的节点添加到 Player
场景中,分别命名为 LaserSound
和 EngineSound
。将相应的声音文件拖入每个节点的 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
节点:ExplosionSound
、LevelupSound
和Music
。在它们的explosion.wav
、levelup.ogg
和Funky-Gameplay_Looping.ogg
中。
将$ExplosionSound.play()
作为_on_rock_exploded()
的第一行,并将$LevelupSound.play()
添加到new_level()
。
要开始和停止背景音乐,将$Music.play()
添加到new_game()
,将$Music.stop()
添加到game_over()
。
敌人也需要ExplosionSound
和ShootSound
节点。你可以使用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.30:颜色渐变设置
标有 1 和 2 的两个矩形滑块设置渐变的起始和结束颜色。点击任何一个都会在标有 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 节点对齐。最后,您添加了音效,并通过使用 Animation
和 CPUParticles2D
节点,第一次尝到了高级视觉效果的滋味。
您还继续使用标准的 Godot 层次结构创建游戏对象,例如将 CollisionShapes
附着到 CollisionObjects
上,以及使用信号来处理节点间的通信。到目前为止,这些做法应该开始对您熟悉了。
你准备好尝试独立重做这个项目了吗?尝试在不看书的条件下,重复所有,甚至部分,本章内容。这是一个检查你吸收了哪些信息以及需要再次复习哪些内容的不错方法。你也可以尝试加入自己的变体来重做,而不仅仅是做一个精确的复制。
当你准备好继续前进,在下一章中,你将制作另一种非常流行的游戏风格:一款遵循超级马里奥兄弟传统的平台游戏。
第四章:热带跳跃 – 在 2D 平台游戏中奔跑和跳跃
在本章中,你将按照经典游戏如超级马里奥兄弟的传统构建一个平台游戏。平台游戏是一个非常受欢迎的游戏类型,了解它们的工作原理可以帮助你制作各种不同的游戏风格。如果你之前从未尝试过制作这样的游戏,平台游戏中的玩家动作实现可能会出人意料地复杂,你将看到 Godot 的 CharacterBody2D
节点如何帮助你完成这个过程。
在这个项目中,你将学习以下内容:
-
使用
CharacterBody2D
节点 -
使用
Camera2D
节点 -
结合动画和用户输入以产生复杂的角色行为
-
使用
TileMap
设计关卡 -
使用
ParallaxLayer
创建无限滚动的背景 -
在场景之间切换
-
组织你的项目和规划扩展
这是完成游戏的截图:
图 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:纹理过滤
虽然可以在每个 Sprite2D
上设置此选项,但指定默认设置更为方便。点击右上角的高级切换按钮,然后在左侧找到渲染/纹理部分。在设置列表中滚动到最底部,找到画布纹理/默认纹理过滤设置。将其从线性更改为最近。
然后,在显示/窗口下,将拉伸/模式更改为画布项,并将纵横比更改为扩展。这些设置将允许用户在保持图像质量的同时调整游戏窗口的大小。一旦项目完成,你将能够看到此设置的成效。
接下来,你可以设置碰撞层。因为这款游戏将有几种不同类型的碰撞对象,它们需要以不同的方式交互,所以你会使用 Godot 的 碰撞层 系统来帮助组织它们。如果它们被分配了名称,那么使用起来会更方便,所以前往 层名称 | 2D 物理 部分,并将前四个层命名为如下(直接在层编号旁边的框中键入):
图 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
。
打开一个新的场景,并添加一个名为Player
的CharacterBody2D
节点作为根节点,并保存场景。别忘了点击Player
场景,你还应该创建一个新的文件夹来包含它。这有助于在你添加更多场景和脚本时保持你的项目文件夹组织有序。
查看 Inspector 中CharacterBody2D
的属性。注意运动模式和向上方向的默认值。“地面”模式意味着身体将考虑一个碰撞方向作为“地板”,相对的墙壁作为“天花板”,其他任何作为“墙壁”——哪一个由向上方向决定。
正如你在之前的项目中做的那样,你将在玩家场景中包含玩家角色需要的功能节点。对于这个游戏,这意味着处理与各种游戏对象的碰撞,包括平台、敌人和可收集物品;显示动作动画,如奔跑或跳跃;并将相机附加到跟随玩家在关卡中移动。
编写各种动画的脚本可能会很快变得难以管理,所以你需要使用一个有限状态机(FSM)来管理和跟踪玩家的状态。参见第三章回顾如何构建简化的 FSM。你将遵循类似的项目模式。
碰撞层和掩码
一个身体的Player
需要分配到“player”层(您在项目设置中命名的层)。同样,碰撞/遮罩设置身体可以“看到”或与之交互的层。如果一个对象在一个不在玩家遮罩中的层上,那么玩家根本不会与之交互。
将玩家的层设置为player,遮罩设置为环境、敌人和物品。点击右侧的三个点以打开一个复选框列表,显示您分配给层的名称:
图 4.4:设置碰撞层
这将确保玩家位于“player”层,以便其他对象可以配置为检测玩家或不检测玩家。将遮罩值设置为所有三个层意味着玩家将能够与这些层上的任何对象交互。
关于 AnimationPlayer
在本书的早期,您使用了AnimatedSprite2D
来显示角色的基于帧的动画。这是一个很好的工具,但它仅适用于动画节点的视觉纹理。如果您还想动画化节点上的其他任何属性怎么办?
这就是AnimationPlayer
发挥作用的地方。这个节点是一个非常强大的工具,可以一次性影响多个节点创建动画;你可以修改它们的任何属性。
动画
要设置角色的动画,请按照以下步骤操作:
- 将一个
Sprite2D
节点添加到Player
中。从FileSystem面板拖动res://assets/player_sheet.png
文件并将其放入Texture属性。玩家动画将以精灵图集的形式保存:
图 4.5:精灵图集
-
您将使用
AnimationPlayer
来处理动画,因此,在Sprite2D
中设置19
。然后,将7
设置为查看玩家站立。最后,通过将(0, -16)
设置为向上移动Sprite2D
,使其脚部站在地面上。这将使您在稍后编码玩家的交互时更容易,因为您将知道玩家的position
属性代表其脚部的位置。 -
将一个
AnimationPlayer
节点添加到场景中。您将使用此节点来更改每个动画的Sprite2D
的适当值。 -
在开始之前,请回顾一下动画面板的不同部分:
图 4.6:动画面板
-
点击
idle
。 -
设置其
0.4
秒。点击循环图标以使动画循环,并将轨道的更新模式设置为连续。
将Sprite2D
更改为7
,这是空闲动画的第一帧,并点击属性旁边的关键帧图标以添加一个带有新关键帧的动画轨道:
图 4.7:添加关键帧
-
将播放刮擦器滑到
0.3
(您可以在右下角的缩放滑块中调整以使其更容易找到)。为第10
帧添加一个关键帧,这是idle
的最后一帧。 -
按
7
键并结束在第10
帧。
现在,为其他动画重复此过程。以下表格列出了它们的设置:
名称 | 长度 | 帧数 | 循环 |
---|---|---|---|
idle |
0.4 |
7 → 10 |
开启 |
run |
0.5 |
13 →18 |
开启 |
hurt |
0.2 |
5 → 6 |
开启 |
jump_up |
0.1 |
11 |
关闭 |
jump_down |
0.1 |
12 |
关闭 |
精灵图中也有蹲下和攀爬的动画,但可以在基本移动完成后添加这些动画。
碰撞形状
与其他身体一样,CharacterBody2D
需要一个形状来定义其碰撞边界。添加一个 CollisionShape2D
节点并在其中创建一个新的 RectangleShape2D
。在调整形状大小时,你希望它达到图像的底部(玩家的脚),但比玩家的图像略窄。一般来说,使形状比图像略小会在游戏中产生更好的感觉,避免击中看起来不会导致碰撞的东西的经验。
你还需要稍微偏移形状以使其适合。设置 CollisionShape2D
节点的 (0, -10)
会很有效。完成时,它应该看起来大约是这样的:
图 4.8:玩家碰撞形状
多个形状
在某些情况下,根据你的角色复杂性和与其他对象的交互,你可能想向同一对象添加多个形状。你可能在玩家的脚下有一个形状来检测地面碰撞,另一个在其身体上检测伤害,还有一个覆盖玩家前部来检测与墙壁的接触。
完成玩家场景
将 Camera2D
节点添加到 Player
场景中。此节点将在玩家在关卡中移动时保持游戏窗口在玩家周围居中。你也可以用它来放大玩家,因为像素艺术相对于游戏窗口的大小来说相对较小。记住,由于你在 项目设置 中设置了过滤选项,当放大时,玩家的纹理将保持像素化和块状。
要启用相机,设置 (2.5, 2.5)
。小于 1 的值会使相机缩小,而较大的值会使相机放大。
你会看到一个围绕玩家的粉紫色矩形。那是相机的 屏幕矩形,它显示了相机将看到的内容。你可以调整 缩放 属性来增加或减少其大小,以便看到更多或更少的玩家周围的世界。
玩家状态
玩家角色有多种行为,例如跳跃、奔跑和蹲下。编码这些行为可能会变得非常复杂且难以管理。一个解决方案是使用布尔变量(例如 is_jumping
或 is_running
),但这可能导致可能令人困惑的状态(如果 is_crouching
和 is_jumping
都为 true
会怎样?)并且很快就会导致 _spaghetti_ 代码
。
解决这个问题的更好方法是使用状态机来处理玩家的当前状态并控制到其他状态的转换。这个概念在第三章中介绍过,您将在本项目中对其进行扩展。
这里是玩家状态及其之间转换的图示:
图 4.9:玩家状态图
如您所见,状态图可能会变得相当复杂,即使是在相对较少的状态下。
其他状态
注意,虽然精灵图包含它们的动画,但CROUCH
和CLIMB
状态不包括在内。这是为了在项目开始时保持状态数量可管理。稍后,您将有机会添加它们。
玩家脚本
将一个新的脚本附加到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)
这样,你已经完成了移动的添加,并且每种情况都应播放正确的动画。这是一个很好的停止点来测试玩家,以确保一切正常工作。但是,你不能只是运行场景,因为玩家会开始无任何立足之地的下落。
测试移动
创建一个新的场景,并添加一个名为 Main
的 Node
对象(稍后,这将成为你的主场景)。添加一个 Player
实例,然后添加一个具有矩形碰撞形状的 StaticBody2D
节点。将碰撞形状水平拉伸,使其足够宽,可以来回行走,并将其放置在角色下方:
图 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
节点能够收集它们,而敌人将直接穿过。
添加三个子节点:Sprite2D
、CollisionShape2D
和 AnimationPlayer
。将 res://assets/sprites/cherry.png
拖入 Sprite2D
节点的 5
位置。然后,在 CollisionShape2D
中添加一个圆形形状并适当调整其大小:
图 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
脚本需要完成两件事:
-
设置起始条件(哪个
texture
和position
) -
检测玩家何时重叠
对于第一部分,将以下代码添加到你的新物品脚本中:
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()
函数设置texture
和position
。你的关卡脚本将使用这些信息来生成你在关卡地图中放置的所有物品。
最后,连接Item
的body_entered
信号并添加以下代码:
func _on_item_body_entered(body):
picked_up.emit()
queue_free()
这个信号将允许游戏的主脚本对拾取物品做出反应。它可以增加分数,提高玩家的健康值,或者实现你希望物品产生的任何其他效果。
你可能已经注意到,这些可收集物品的设置与Coin Dash中的硬币非常相似。区域对于任何需要知道何时被触摸的物品类型都非常有用。在下一节中,你将开始布置关卡场景,以便放置这些可收集物品。
设计关卡
对于大多数人来说,这一部分将占用你大部分的时间。一旦你开始设计关卡,你会发现布置所有部件并创建挑战性跳跃、秘密路径和危险遭遇非常有趣。
首先,你将创建一个包含所有节点和代码的通用Level
场景,这些节点和代码对所有关卡都是通用的。然后你可以创建任意数量的继承自这个主级别的Level
场景。
使用TileMap
创建一个新的场景并添加一个名为LevelBase
的Node2D
节点。将场景保存在名为levels
的新文件夹中。这是你将保存所有创建的关卡的地方,它们都将继承自这个level_base.tscn
场景的功能。它们将具有相同的节点层次结构——只有布局不同。
瓦片地图是使用瓦片网格设计游戏环境的常用工具。它们允许你通过在网格上绘制瓦片来绘制关卡布局,而不是逐个放置许多单独的节点。它们也更有效率,因为它们将所有单个瓦片纹理和碰撞形状批处理到单个游戏对象中。
添加一个TileMap
节点;在编辑器窗口底部将出现一个新的TileMap面板。注意,它说编辑的 TileMap 没有 TileSet 资源。
关于TileSet
要使用TileMap
绘制地图,它必须分配了TileSet
。这个TileSet
包含所有单个瓦片纹理,以及它们可能具有的任何碰撞形状。
根据你可能拥有的瓦片数量,创建TileSet
可能很耗时,尤其是第一次。因此,assets
文件夹中包含了一些预生成的瓦片集。你可以自由使用这些瓦片集,但请阅读以下部分。它包含有用的信息,帮助你理解TileSet
的工作原理。如果你更愿意使用提供的瓦片集,请跳转到使用提供的 TileSets部分。
创建一个TileSet
在 Godot 中,TileSet
是一种Resource
类型。其他资源的例子包括Texture
、Animation
和RectangleShape2D
。它们不是节点;相反,它们是包含特定类型数据的容器,通常保存为.tres
文件。
创建TileSet
容器的步骤如下:
-
点击
TileMap
。你会看到现在有一个TileSet面板可用,你可以在编辑器窗口底部选择它。你可以点击两个向上的箭头,,使面板填满编辑器屏幕。再次点击它以缩小面板。
-
TileSet
面板是你可以放置想要切割成瓦片的纹理的地方。将res://assets/environment/tileset.png
拖入此框。将出现一个弹出窗口,询问你是否想自动创建瓦片。在图像中选择16x16
像素的瓦片:
图 4.12:添加 TileSet
- 尝试选择底部的TileMap面板,然后选择瓦片左上角的草地块图像。然后,在编辑器窗口中点击以通过在编辑器窗口中左键点击来绘制一些瓦片。你可以在瓦片上右键点击以清除它:
图 4.13:使用 TileMaps 绘制
如果你只想绘制背景,那么你就完成了。然而,你还需要将这些瓦片添加碰撞,以便玩家可以站在上面。
- 再次打开TileSet面板,在检查器中找到PhysicsLayers属性并点击添加元素:
图 4.14:向 TileSet 添加物理层
由于这些瓦片将位于环境
层,你不需要更改图层/掩码设置。
- 点击
Physics
Layer 0
:
图 4.15:向瓦片添加碰撞
- 开始点击瓦片以向它们添加默认的方形碰撞形状。如果你想编辑瓦片的碰撞形状,你可以这样做——再次点击瓦片以应用更改。如果你卡在一个你不喜欢的外形上,点击三个点并选择重置为默认 瓦片形状。
你也可以将props.png
图像拖入纹理列表,为一些装饰物品增添你的关卡色彩。
使用提供的 TileSets
预配置的瓦片集已包含在此项目的assets
下载中。有三个需要添加到三个不同的TileMap
节点:
-
世界
–tiles_world.tres
:地面和平台瓦片 -
Items
–tiles_items.tres
:生成可收集物品的标记 -
危险
–tiles_spikes.tres
:碰撞时造成伤害的物品
创建Items
和Danger
瓦片地图,并将相关的瓦片集添加到Tile Set属性。
添加一个Player
场景实例和一个名为SpawnPoint
的Marker2D
节点。你可以使用此节点来标记玩家在关卡中开始的位置。
将脚本附加到Level
节点:
extends Node2D
func _ready():
$Items.hide()
$Player.reset($SpawnPoint.position)
之后,你将扫描Items
地图以在指定位置生成可收集物品。这个地图层不应该被看到,所以你可以将其设置为场景中的隐藏。然而,这很容易忘记,所以_ready()
确保在游戏过程中它不可见。
设计第一个关卡
现在,你准备好开始绘制等级了!点击level_base.tscn
。将根节点命名为Level01
并保存(在levels
文件夹中)。注意,子节点被涂成黄色,表示它们是level_base.tscn
。如果你对原始场景进行了更改,这些更改也会出现在这个场景中。
从世界
地图开始,发挥创意。你喜欢很多跳跃,还是曲折的隧道去探索?长跑还是小心翼翼的向上攀登?
在深入进行等级设计之前,请确保你尝试了跳跃距离。你可以更改玩家的jump_speed
、run_speed
和gravity
属性来改变他们可以跳多高和多远。设置不同大小的间隙并运行场景来尝试它们。别忘了将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.png
和middle.png
,分别用于远背景和近背景。通过将这些图像放置在瓦片地图后面,并以相对于相机的不同速度滚动,你可以在背景中创建一个吸引人的深度错觉:
-
将
ParallaxBackground
节点添加到LevelBase
场景中(这样它就会出现在所有继承的级别中)。这个节点与相机一起创建滚动效果。将此节点拖到场景树的最顶部,以便它会在其他节点之后被绘制。接下来,添加一个ParallaxLayer
节点作为其子节点。ParallaxBackground
可以有任意数量的ParallaxLayer
子节点,允许你创建多个独立滚动的层。 -
将
Sprite2D
节点添加为ParallaxLayer
的子节点,并将back.png
图像拖放到其Sprite2D
节点的(``1.5, 1.5)
。 -
在
ParallaxLayer
上设置(0.2, 1)
(你需要分别点击x
和y
值来单独设置)。这个设置控制背景相对于相机移动的速度。通过将其设置为小于1
的数字,当玩家左右移动时,图像只会移动一小段距离。 -
如果你水平方向上的关卡宽度大于图像的大小,你需要确保图像重复,所以设置
(576, 0)
。这正好是图像的宽度(384
乘以1.5
),所以当图像移动了这么多像素时,它将会重复。 -
注意,这个背景图像是为宽度较宽而不是高度较高的关卡设计的。如果你跳得太高,你会看到图像的顶部。你可以通过设置摄像机的顶部限制来修复这个问题。如果你没有移动背景的位置,其左上角仍然在 (
0, 0)
,所以你可以设置0
。如果你已经移动了ParallaxLayer
或其Sprite2D
节点,你可以通过查看节点Position
的y
值来找到正确的值。 -
尝试播放关卡并左右移动。你应该会看到背景相对于你跑的距离移动了一小部分。
-
添加另一个
ParallaxLayer
(也作为ParallaxBackground
的子节点),并给它一个Sprite2D
子节点。这次,使用middle.png
图像。这个图像比天空图像窄得多,所以你需要调整一些设置来使其正确重复。这是因为ParallaxBackground
需要图像至少与视口区域一样大。 -
找到
Sprite2D
节点的Mirror
。然后,扩展(880, 368)
。880
是图像宽度 (176
) 乘以5
,所以你现在将看到五个图像的重复,每个都是上一个图像的镜像。 -
将
Sprite2D
节点移动,使图像与海洋/天空图像的下半部分重叠:
图 4.16:平行背景设置
- 设置第二个
ParallaxLayer
节点的(0.6, 1)
和880, 0)
。使用更高的缩放因子意味着这个层将比它后面的云层滚动得更快。播放场景以测试效果。
你的 Level
场景的节点树现在应该看起来像这样:
图 4.17:关卡场景节点
你的关卡场景现在拥有了创建关卡设计所需的所有部件。你希望玩家必须进行非常精确的跳跃(跑酷关卡),穿过一系列蜿蜒的通道试图找到所有物品(迷宫关卡),或者两者的组合?这是你尝试一些创意想法的机会,但请确保为下一个要创建的对象(敌人)留出一些空间。
添加敌人
你可以为敌人添加许多不同的行为。对于这个游戏,敌人将沿着一个平台直线行走,并在碰到障碍物时改变方向。
场景设置
如前所述,你需要创建一个新的场景来表示敌人:
-
从一个名为
Enemy
的CharacterBody2D
节点开始,并给它三个子节点:Sprite2D
、CollisionShape2D
和AnimationPlayer
。 -
在名为
enemies
的文件夹中保存场景。如果你决定为游戏添加更多敌人类型,你都可以在这里保存。 -
将身体的碰撞 层 设置为 敌人,其 遮罩 设置为 环境、玩家 和 敌人。与玩家一样,这决定了敌人会与哪些类型的对象发生碰撞。
-
将敌人分组在一起也很有用,所以点击
enemies
。 -
将
res://assets/sprites/opossum.png
添加到6
。 -
添加一个矩形碰撞形状,覆盖图像的大部分(但不是全部),确保碰撞形状的底部与负鼠的脚底对齐:
图 4.18:敌人碰撞形状
-
向
AnimationPlayer
添加一个新的动画,命名为walk
。设置为0.6
秒,并开启循环和加载时自动播放。 -
walk
动画需要有两个轨道:一个设置Sprite2D
节点,另一个在时间零时将其0
设置为5
。别忘了将更新模式改为连续。
完成后,你的动画应该看起来像这样:
图 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.3
和0.05
。不要为这个动画开启循环。
死亡
动画也会在动画的开始和结束时将res://assets/sprites/enemy_death.png
图像设置为精灵的Frame
的0
和5
值。请记住将更新模式设置为连续。
将以下代码添加到enemy.gd
中,以便你有触发敌人死亡动画的方法:
func take_damage():
$AnimationPlayer.play("death")
$CollisionShape2D.set_deferred("disabled", true)
set_physics_process(false)
当玩家在正确条件下击中敌人时,它会调用take_damage()
函数,播放死亡
动画,禁用碰撞,并停止移动。
当死亡动画播放完毕后,可以移除敌人,因此连接 AnimationPlayer
的 animation_finished
信号:
图 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
节点以保持对齐并给它两个子节点,分别命名为 Label
和 HBoxContainer
,分别是 Score
和 LifeCounter
。
在 Score
标签上设置 100
,并在检查器中,在 res://assets/Kenney Thick.ttf
中将其设置为 48
。在 16
和 100
显示为白色,带有黑色轮廓。
对于 LifeCounter
,添加一个 TextureRect
子节点并命名为 L1
。将 res://assets/heart.png
拖入其 L1
并复制 (Ctrl + D) 四次,以便你有一排五个心形图案:
图 4.21:HUD 节点设置
当你完成时,你的 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:连接信号
以相同的方式处理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
并将拉伸模式设置为平铺。拖动矩形的宽度直到它比屏幕宽,并调整它以覆盖下半部分。
添加两个名为Title
和Message
的Label
节点,分别设置它们的Jungle Jump
和Press Space to Play
。像之前一样为每个添加字体,将标题的大小设置为72
,将消息的大小设置为48
。将标题的布局设置为居中,将消息的布局设置为居中底部。
当你完成时,场景应该看起来像这样:
图 4.24:标题屏幕
要使标题屏幕更有趣,向其中添加一个AnimationPlayer
节点。创建一个名为intro
的新动画并将其设置为自动播放。在这个动画中,你可以动画化屏幕的元素,使它们移动、出现、淡入或任何你喜欢的效果。
例如,在时间0.5
处为当前的Title
设置关键帧。然后,在时间0
处将Title
拖离屏幕顶部并添加另一个关键帧。现在,当你播放场景时,标题将掉落到屏幕上。
随意添加可以动画化其他节点属性的轨道。例如,这里有一个动画,将标题向下移动,淡入两个纹理,然后使消息出现:
图 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.tscn
、level_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:选择主场景
在关卡之间过渡
你的关卡现在需要一种从一关过渡到下一关的方法。在res://assets/environment/props.png
精灵图中,有一个你可以用于关卡出口的门图像。找到并走进门将玩家带到下一关。
门场景
创建一个新的场景,并命名为Door
的Area2D
节点,并将其保存在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_items
和expand
。运行游戏,然后尝试调整游戏窗口的大小。注意,如果你使窗口变宽,玩家可以看到更多游戏世界在左侧/右侧。这就是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:颜色渐变
将起始颜色设置为棕褐色,并将结束颜色设置为相同的颜色,但将透明度值设置为 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
现在,玩家已经准备好攀爬,这意味着你可以在关卡中添加一些梯子。
关卡设置
将名为Ladders
的Area2D
节点添加到Level
场景中,但暂时不要给它添加碰撞形状。连接其body_entered
和body_exited
信号,并设置其碰撞items
和player
。这确保了只有玩家可以与梯子交互。这些信号是你让玩家知道他们是否在梯子上的方式:
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:浮动平台
将脚本添加到根节点,并从以下变量开始:
@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:启用网格吸附
默认值是 (8, 8)
,但您可以通过点击图标旁边的三个点并选择配置吸附来更改它。
当你现在运行游戏时,你将有更多可以与之互动的内容。梯子和移动平台给你的关卡设计提供了更多可能性。但你不一定要止步于此!考虑到本章中你所做的一切,还有很多其他功能你可以添加。玩家的动画包括一个“蹲下”动画——如果敌人能向玩家投掷可以被躲过的东西会怎样?许多平台游戏包括额外的移动机制,如沿着斜坡滑动、墙壁跳跃、改变重力等等。选择一个,看看你是否可以添加它。
摘要
在本章中,你学习了如何使用 CharacterBody2D
节点为玩家移动创建街机风格的物理效果。这是一个功能强大的节点,可以用于各种游戏对象——而不仅仅是平台角色。
你学习了关于用于关卡设计的 TileMap
节点的知识——这是一个功能强大的工具,甚至比你在本项目中使用的功能还要多。关于你可以用它做的一切,可以写整整一章。更多信息,请参阅 Godot 文档网站上的 使用 TileMaps 页面:https://docs.godotengine.org/en/latest/tutorials/2d/using_tilemaps.html。
Camera2D
和 ParallaxBackground
也是任何希望在世界中移动的游戏中的关键工具,这个世界比屏幕大小还要大。特别是相机节点,你将在大多数 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 工作区
你首先应该注意到的中心的三条彩色线条。这些是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:透视菜单
这在结合使用多个视口的大屏幕上特别有用。点击 视图 菜单,您可以将屏幕分割成多个视图,让您能够同时从各个方向看到对象。
键盘快捷键
注意,这些菜单选项中的每一个都与一个键盘快捷键相关联。您可以通过点击 编辑器 -> 编辑器设置 -> 3D 来查看并调整您喜欢的键盘快捷键。
添加 3D 对象
是时候添加您的第一个 3D 节点了。就像所有 2D 节点继承自 Node2D
,它提供了诸如 Node3D
这样的属性,它提供了空间属性。将一个添加到场景中,您将看到以下内容:
图 5.3:带有 gizmo 的 Node3D
你看到的那个五彩斑斓的对象不是节点,而是一个 3Dgizmo。gizmo 是一个工具,允许你在空间中移动和旋转对象。三个环控制旋转,而三个箭头沿着三个轴移动对象。注意,环和箭头是按照轴的颜色进行着色编码的。箭头沿着相应的轴移动对象,而环则围绕特定的轴旋转对象。还有三个小方块可以锁定一个轴,并允许你在对象的平面上移动。
花几分钟时间进行实验,熟悉 gizmo。如果你发现自己迷失方向,可以删除节点并添加另一个。
有时候 gizmo 会碍事。你可以点击模式图标来限制自己只进行一种类型的变换:移动、旋转或缩放:
图 5.4:选择模式图标
Q/W/E/R键是这些按钮的快捷键,允许你快速在模式之间切换。
全局空间与局部空间
默认情况下,gizmo 控制操作在全局空间中。尝试旋转对象——无论你怎么转动它,gizmo 的移动箭头仍然沿着全局轴指向。现在尝试这样做:将Node3D
节点放回其原始位置和方向(或者删除它并添加一个新的)。围绕一个轴旋转对象,然后点击使用局部空间按钮(注意T快捷键):
图 5.5:切换局部空间模式
观察 gizmo 箭头的指向。现在它们沿着对象的局部轴而不是世界轴指向。当你点击并拖动箭头时,它们会相对于对象的自身旋转移动对象。你可以再次点击按钮切换回全局空间。在这些两种模式之间切换可以使放置对象到你想要的位置变得容易得多。
变换
查看 Inspector 中的Node3D
。在变换部分,你会看到节点的位置、旋转和缩放属性。当你移动对象时,你会看到这些值发生变化。就像在 2D 中一样,这些值是相对于节点的父节点相对的。
这三个量共同构成了节点的transform
属性,这是一个 Godot 的Transform3D
对象。Transform3D
有两个子属性:origin
和basis
。origin
属性表示物体的位置,而basis
属性包含三个向量,这些向量定义了物体的局部坐标轴。当你处于局部空间模式时,想想 gizmo 中的三个轴箭头。
你将在本节后面了解如何使用这些属性。
网格
就像Node2D
一样,Node3D
节点没有自己的大小或外观。在 2D 中,你添加Sprite2D
来显示节点上的纹理。在 3D 中,你通常想要添加一个网格。网格是三维形状的数学描述。它由称为顶点的点集合组成。这些顶点通过称为边的线连接,多个边(至少三个)共同构成一个面。
例如,一个立方体由八个顶点、十二条边和六个面组成:
图 5.6:顶点、边和面
如果你曾经使用过 3D 设计软件,这对你来说可能已经熟悉了。如果你还没有,并且你对学习 3D 建模感兴趣,Blender是一个非常流行的开源工具,用于设计 3D 对象。你可以在互联网上找到许多教程和课程,帮助你开始使用 Blender。
原始形状
如果你还没有创建或下载 3D 模型,或者你只需要快速创建一个简单形状,Godot 有直接创建某些 3D 网格的能力。将一个MeshInstance3D
节点作为你的Node3D
节点的子节点,然后在检查器中查找网格属性:
图 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.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:分配鼠标按钮输入
当您在项目设置中时,您还可以设置当游戏窗口大小调整时游戏的行为。在游戏过程中,用户可能会选择调整窗口大小,这可能会破坏您的 UI 布局或显示扭曲的游戏视图。为了防止这种情况,导航到显示/窗口部分并找到拉伸/模式设置。将其更改为视口:
图 5.11:设置窗口拉伸模式
这就完成了项目的设置。现在,您可以继续构建游戏的第一个部分:迷你高尔夫球场。
创建课程
对于第一个场景,添加一个名为Hole
的Node3D
节点并保存场景。就像在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:网格菜单
使用网格的数据选择 StaticBody3D
节点和 CollisionShape3D
节点。
一旦所有碰撞都添加完毕,你可以选择 GridMap
可以使用。
绘制第一个洞
将 MeshLibrary
文件拖入 GridMap
节点。你会在编辑器视口的右侧看到一个可用的瓦片列表。
为了匹配瓦片的大小,设置 (1,
1, 1)
。
为了确保球与碰撞看起来很好,找到 0.5
:
图 5.13:使用物理材质
尝试通过从列表中选择瓦片块并将其通过左键点击放置在场景中来绘制。你可以通过按 S 键在 y
轴周围旋转一个块。要删除瓦片,右键点击它。
现在,坚持简单的布局。当一切正常工作时,你可以变得复杂一些:
图 5.14:示例课程布局
你可以查看游戏运行时的样子。将 Camera3D
功能添加到场景中,并将其移动到一个可以俯瞰课程的位置。记住,你可以按 预览 按钮检查相机看到的画面。
播放场景。你会注意到一切都非常暗,与编辑器窗口中的样子不同。默认情况下,3D 场景没有配置 环境 或 光照。
环境和照明
照明是一个复杂的主题。选择放置光源的位置以及它们的配置可以显著影响场景的外观。
Godot 在 3D 中提供了三个光照节点:
-
OmniLight3D
:用于从所有方向发射的光,例如来自灯泡 -
DirectionalLight3D
:来自远处的光源,例如阳光 -
SpotLight3D
:从一个点投射出的锥形光,类似于手电筒或灯笼
除了放置单个光源外,你还可以使用 WorldEnvironment
节点设置 环境 光 – 由环境产生的光。
而不是从头开始,Godot 允许你使用工具栏中的按钮从编辑器窗口中看到的默认照明设置开始:
图 5.15:照明和环境设置
前两个按钮允许你切换预览太阳(方向光)和环境。请注意,环境不仅影响照明,还会生成天空纹理。
如果你点击三个点,你可以看到这些的默认设置。点击场景中的 WorldEnvironment
节点和 DirectionalLight3D
节点。
如果你放大你的网格,你可能会注意到阴影看起来不太好。默认的阴影设置需要调整,所以选择 DirectionalLight3D
并将 100
改为 40
。
添加球洞
现在你已经布置了球场,你需要一种方法来检测球是否掉入球洞。
添加一个名为 Hole
的 Area3D
节点。这个节点与其 2D 版本完全一样——它可以在物体进入其定义的形状时发出信号。将一个 CollisionShape3D
子节点添加到区域中。在 0.25
和 0.08
。
将 Hole
放置在你为球场放置的球洞瓷砖的位置。确保圆柱形状不会投影到球洞顶部以上,否则球在还没有掉入时会被计为“在洞内”。你可能发现使用 Perspective 按钮并切换到 Top View 来确保它正确居中很有用:
图 5.16:定位球洞
你还需要标记球的起始位置,因此将一个名为 Tee
的 Marker3D
节点添加到场景中。将其放置在你希望球开始的位置。确保将其放置在表面之上,这样球就不会在地面内部生成。
这样,你就完成了第一轮次的制作。花几分钟时间四处看看,确保你对布局满意。记住,这不应该是一个复杂或具有挑战性的布局。它将向玩家介绍游戏,你也会用它来测试一切是否正常工作。为此,你接下来需要创建高尔夫球。
制作球
由于球需要物理特性——重力、摩擦、与墙壁的碰撞等——因此 RigidBody3D
将是节点选择的最佳选择。刚体在 3D 中的工作方式与你在 2D 中使用的方式相似,你将使用相同的方法与它们交互,例如 _integrate_forces()
和 apply_impulse()
。
创建一个新的场景,并添加一个名为 Ball
的 RigidBody3D
节点,然后保存它。
由于你需要一个简单的球体形状,而 Godot 包含原始形状,因此这里不需要复杂的 3D 模型。添加一个 MeshInstance3D
子节点,并在检查器中选择 New SphereMesh 作为 Mesh 属性。
默认大小太大,所以点击 0.05
和 0.1
。
添加一个 CollisionShape3D
节点,并给它一个 SphereShape3D
。将其 0.05
设置与网格匹配。
测试球
将 Ball
场景的一个实例添加到你的课程中。将其放置在某个瓦片上方并播放场景。你应该看到球落下并落在地面上。
你也可以通过设置 y
轴向上来暂时给球一些运动。不要忘记在继续之前将其设置回 (0, 0, 0)
。
改善球碰撞
你可能已经注意到,在调整速度时,球有时会穿过墙壁和/或以奇怪的方式弹跳,尤其是如果你选择了一个高速值。你可以做几件事情来改善球的行为。
首先,你可以使用连续碰撞检测(CCD)。使用 CCD 改变了物理引擎计算碰撞的方式。通常,引擎通过首先移动对象,然后测试和解决碰撞来运行。这很快,并且适用于大多数常见情况。当使用 CCD 时,引擎会沿着对象的路径预测其移动,并尝试预测碰撞可能发生的位置。这比默认行为(在计算上)要慢,尤其是在模拟许多对象时,但它要准确得多。由于你只有一个球,且环境非常小,因此 CCD 是一个好的选择,因为它不会引入任何明显的性能惩罚。你可以在检查器中找到它作为连续 CD:
图 5.17:CCD 开关
球也需要一点额外的动作,所以在 0.25
。这个属性决定了碰撞会有多“弹跳”。值可以从 0
(完全没有弹跳)到 1.0
(最弹跳):
图 5.18:物理材质弹跳设置
你也可能已经注意到球需要很长时间才能完全停下来。设置 0.5
和 1
。这些值可以被认为是与空气阻力相似——使物体减速,无论是否与表面相互作用。增加这些值意味着玩家不需要等待那么长时间球才会停止移动,而且球在停止滚动后不会看起来像是在原地旋转。
你已经完成了球体的设置,但这里是一个好的地方可以暂停一下,确保在继续之前一切如你所愿。球感觉像是在弹跳和滚动吗?当它撞到墙壁时,弹跳是否过多或过少?当你对球的动作调整满意后,继续到下一部分,在那里你将设置如何发射球。
添加用户界面
现在球已经在赛道上了,你需要一种方法来瞄准和击打它。对于这类游戏,有许多可能的控制方案。对于这个项目,你将使用两步过程:
-
目标:出现一个箭头,来回摆动。点击鼠标按钮设置目标方向。
-
射击:一个力量条上下移动。点击鼠标设置力量并发射球。
对准箭头
在 3D 中绘制对象不像在 2D 中那么容易。在许多情况下,你将不得不切换到 3D 建模程序,如 Blender,来创建你的游戏对象。然而,在这种情况下,Godot 的原语将做得很好。要制作箭头,你需要两个网格:一个长而窄的矩形和一个三角棱柱。
制作自己的模型
如果你熟悉使用单独的 3D 建模程序,如 Blender,请随意使用它来创建箭头网格而不是遵循以下步骤。只需将导出的模型放入你的 Godot 项目文件夹中,并用 MeshInstance3D
节点加载它。请参阅最后一章中有关直接从 Blender 导入模型的详细信息。
通过添加一个名为 Arrow
的 Node3D
节点并为其添加一个 MeshInstance3D
子节点来开始一个新的场景。给这个网格赋予一个 BoxMesh
函数并设置盒子的 (0.5, 0.2, 2)
。这将成为箭头的主体,但在继续之前,有一个问题。如果你旋转父节点,网格将围绕其中心旋转。你需要它围绕其末端旋转,所以将 MeshInstance3D
节点更改为 (0, 0, -1)
。记住,这个属性是相对于节点的父节点测量的,所以这是将网格从 Node3D
节点偏移:
图 5.19:偏移基础
尝试使用 gizmo 旋转根节点(Arrow
)以确认形状现在已正确偏移。
当它在游戏中查看时,箭头应该是半透明的。你也可以给它一个颜色,使其更加突出。要更改网格的视觉属性,你需要使用 材质。
在网格属性(设置大小的地方)下,你会在该框中看到一个 StandardMaterial3D
节点:
图 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:定位箭头
你的瞄准箭头已完成。保存场景并将其实例化到你的 Hole
场景中。
UI 显示
使用名为 UI
的 CanvasLayer
层创建一个新的场景。在这个场景中,你将显示电力条以及玩家的得分次数。就像在 2D 中一样,这个节点将导致其内容被绘制在主场景之上。
添加一个 Label
节点,然后一个 MarginContainer
节点。在其中,添加一个 VboxContainer
节点,并在其中添加两个 Label
节点和一个 TextureProgressBar
节点。按所示命名:
图 5.22:UI 节点布局
在 MarginContainer
部分,设置 20
。将 Xolonium-Regular.ttf
字体添加到两个 Label
节点中,并将它们的字体大小设置为 30
。将 Shots
设置为 PowerLabel
的 Power。
使用更大的字体大小 80
为 Message
标签添加字体,并将其文本设置为 Get Ready!
。从 锚点预设 菜单中选择 居中,然后点击消息旁边的眼睛符号以隐藏它。
将 res://assets
中的一个彩色条纹理拖放到 PowerBar
中。默认情况下,TextureProgressBar
从左向右增长,因此对于垂直方向,将 填充模式 更改为 从下到上。将 值 设置为几个不同的值以查看结果。
完成的 UI 布局应如下所示:
图 5.23:UI 预览
在 Hole
场景中添加 UI
的一个实例。因为它是 CanvasLayer
,它将被绘制在 3D 摄像机视图之上。
现在你已经完成了课程的绘制并添加了 UI,你拥有了玩家在游戏过程中将看到的全部视觉元素。你的下一个任务将通过添加一些代码使这些部分协同工作。
游戏脚本编写
在本节中,你将创建使一切协同工作的脚本。游戏流程如下:
-
将球放置在
Tee
上。 -
切换到 Aim 模式并动画化箭头,直到玩家点击。
-
切换到 Power 模式并动画化电力条,直到玩家点击。
-
发射球。
-
重复从 步骤 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
列出了游戏可能处于的状态,而 power
和 angle
变量将用于设置它们各自的价值并在时间上改变它们。你可以通过调整两个导出变量来控制动画速度(因此难度)。
接下来,在开始游戏之前设置初始值:
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
中的 x
和 z
值来生成 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
播放场景以尝试它。
相机改进
另一个问题,特别是如果你布置了一个相对较大的赛道,那就是如果你将相机放置在发球台附近,它将无法很好地显示赛道上的其他部分,甚至根本不显示。你需要你的相机移动,显示赛道的其他部分,以便玩家可以舒适地瞄准。
解决这个问题的主要有两种方法:
-
多个相机:在赛道周围的不同位置放置几个相机。将
Area3D
节点附加到它们上,当球进入一个相机的区域时,通过将其current
属性设置为true
来激活该相机。 -
移动相机:坚持使用一个相机,但让它随着球移动,这样玩家的视角总是基于球的位置。
这两种方案都有优点和缺点。选项 1 需要更多的规划,决定确切的位置放置相机,以及使用多少个。因此,本节将重点介绍选项 2。
在许多 3D 游戏中,玩家可以控制一个旋转和移动的相机。通常,这种控制方案使用鼠标和键盘的组合。由于你已经在瞄准时使用了鼠标移动(如果你选择了该选项),W/A/S/D键是一个不错的选择。鼠标滚轮可以用来控制相机的缩放。
在输入 映射属性中添加这些新动作:
图 5.24:输入映射
创建万向节
相机移动需要有一定的限制。一方面,它应该始终保持水平,不要向两侧倾斜。尝试这样做:拿一个相机,围绕y
(工具箱的绿色环)旋转一小部分,然后围绕x
(红色环)旋转一小部分。现在,反转y
旋转并点击预览按钮。看看相机是否已经倾斜了吗?
解决这个问题的方法是将相机放置在Node3D
节点上,这将分别控制相机的左右和上下移动。
首先,确保从场景中移除任何其他Camera3D
节点,以免在哪个相机被使用上产生冲突。
创建一个新的场景并添加两个Node3D
节点和一个Camera3D
节点,命名如图 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:示例课程布局
视觉效果
球和其他网格在场景中的外观被有意地留得很简单。您可以将扁平的白色球视为空白画布,准备上色。首先,一些词汇:
-
纹理:纹理是围绕 3D 对象包裹的平面、2D 图像。想象一下包裹礼物:扁平的纸张折叠在包裹上,适应其形状。纹理可以是简单的,也可以是复杂的,这取决于它们设计要应用的形状。
-
StandardMaterial3D
. -
材料: Godot 使用一种名为 基于物理的渲染(PBR)的图形渲染模型。PBR 的目标是渲染图形,使其能够准确模拟现实世界中光的行为。这些效果通过使用其材料属性应用于网格。材料本质上是一个纹理和着色器的容器。材料的属性决定了纹理和着色器效果的应用方式。使用 Godot 内置的材料属性,你可以模拟各种物理材料,如石头、布料或金属。如果内置属性不足以满足你的需求,你可以编写自己的着色器代码来添加更多效果。
添加材料
在“球”场景中,选择MeshInstance
,并在其StandardMaterial3D
节点中。
展开材料,你会看到大量的属性,远超过这里所能涵盖的范围。本节将重点介绍一些制作球体更具吸引力的最有用属性。我们鼓励你访问 docs.godotengine.org/en/latest/tutorials/3d/standard_material_3d.html
以获得所有设置的完整解释。
首先,尝试对这些参数进行实验:
-
反照率: 这个属性设置了材料的基色。更改它可以使球体呈现你想要的任何颜色。如果你正在处理需要纹理的对象,这里也是添加纹理的地方。
-
0
和1
。金属值控制光泽度。更高的值会反射更多光线,就像金属物质一样。粗糙度值对反射应用一定程度的模糊。较低的值更具有反射性,例如镜子的抛光表面。通过调整这两个属性,你可以模拟各种材料。图 5.27 是一个关于 粗糙度 和 金属 属性如何影响物体外观的指南。请记住,照明和其他因素也会改变外观。理解光和反射如何与表面属性相互作用是设计有效 3D 对象的重要组成部分:
图 5.27:金属和粗糙度值
- 法线贴图: 法线贴图是一种 3D 图形技术,用于模拟表面上的凹凸效果。在网格本身中建模这些效果会导致组成对象的三角形或面数大量增加,从而导致性能降低。相反,使用一个 2D 纹理来映射这些小表面特征会产生光和影的模式。然后,照明引擎使用这些信息来改变反射,就像这些细节实际上存在一样。一个正确构建的法线贴图可以为原本看起来平淡无奇的对象添加大量细节。
球体是一个很好的正常映射用例的例子,因为真实的高尔夫球在其表面有数百个凹坑,但你使用的球体原语是一个光滑的表面。使用常规纹理可能会添加斑点,但它们看起来会像涂在表面上的,显得很平。模拟这些凹坑的正常映射看起来会是这样:
图 5.28:正常映射
红色和蓝色的图案包含信息,告诉引擎在那个点表面应该朝向哪个方向,因此光线应该在那个位置反射的方向。注意顶部和底部的拉伸——这是因为这张图片是为了包裹球形形状而制作的。
将res://assets/ball_normal_map.png
启用到-0.5
和-1.0
,效果最佳:
图 5.29:带有正常映射的球体
花些时间实验这些设置,找到你喜欢的组合。别忘了尝试播放场景,因为WorldEnvironment
功能的周围照明会影响最终结果。
在下一节中,你将学习如何调整WorldEnvironment
设置以改变场景的外观。
照明和环境
你一直在使用默认的照明设置,这是你在第一部分中添加到场景中的。虽然你可能对这个照明设置感到满意,但你也可以调整它以显著改变游戏的外观。
WorldEnvironment
节点包含一个Environment
属性,用于控制场景的背景、天空、环境光和其他外观方面。选择节点并点击属性以展开它:
图 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:完成的游戏截图
技术要求
从以下链接下载游戏资源,并将其解压到你的新项目文件夹中:
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_up
、pitch_down
、roll_left
和roll_right
中添加它们。你可以添加箭头键和/或W、A、S和D键,但如果你有游戏控制器,你也可以使用摇杆进行更精确的控制。要添加摇杆输入,你可以在按下+按钮后选择Joypad Axes。这些值都有标签,例如Left Stick Up,这样你可以轻松跟踪它们:
图 6.2:输入配置
这个设置的优点是,你的代码对于不同类型的输入不需要做任何改变。通过使用Input.get_axis()
并传入四个输入事件,无论玩家是按下了键还是移动了摇杆,你都会得到一个结果。按下键等同于将摇杆推到一端。
现在项目已经设置好了,你可以开始创建你的游戏对象,从玩家控制的飞机开始。
飞机场景
在本节中,您将创建玩家将控制的飞机。当玩家可以上下左右移动时,飞机将向前飞行。
使用名为Plane
的CharacterBody3D
节点开始您的新飞机场景,并保存它。
您可以在assets
文件夹中找到飞机的 3D 模型,命名为cartoon_plane.glb
。这个名字表明该模型以二进制 .gltf
文件格式存储(由 Blender 导出)。Godot 将.gltf
文件导入为包含网格、动画、材质和其他可能已导出在文件中的对象的场景。点击Node3D
,但它的方向是错误的。选择它,并在检查器功能中设置180
,使其指向z轴,这是 Godot 的“前进”方向。请注意,直接输入值比尝试使用鼠标精确旋转节点要容易。
模型方向
如前一章所述,不同的 3D 设计程序使用不同的轴方向。导入模型时,其前进方向不匹配 Godot 的情况非常常见。如果您自己制作模型,您可以在导出时纠正这一点,但使用他人制作的模型时,通常需要在 Godot 中重新定位它。
如果您在cartoon_plane
节点上右键单击并选择AnimationPlayer
:
图 6.3:飞机网格
AnimationPlayer
包含一个使螺旋桨旋转的动画,因此选择它,并将prop_spin
动画设置为加载时自动播放功能:
图 6.4:自动播放动画
碰撞形状
将CollisionShape3D
节点添加到Plane
,并选择90
以使其与飞机的机身对齐。您可以使用 gizmo(别忘了使用“使用智能吸附”图标打开吸附以使其完美对齐)或直接在检查器中输入值。
翼也需要被覆盖,因此添加第二个CollisionShape3D
节点。这次,使用BoxShape3D
。将其调整到覆盖翼的尺寸:
图 6.5:飞机碰撞形状
编写飞机脚本
您可以从飞机的控制开始。有两个移动轴:“抬头”和“低头”将抬起或降低飞机的机头(绕其x轴旋转),使其向上或向下移动。roll_left
和roll_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()
函数根据两个输入返回一个介于 -1
和 1
之间的值。当使用只能按下或未按下的按键时,这意味着当按下其中一个键时,你会得到 -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:测试场景
将摄像机放置在飞机后面,运行场景以测试按下俯仰向上和俯仰向下输入是否能够正确地使飞机上下倾斜。
对于滚转,你可以在 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.5
和 4
,这样你就有了一个窄环。
添加一个 CollisionShape3D
节点并选择 .5
和 3
。
之后,你将想要让环上下移动。一个简单的方法是将碰撞形状相对于根节点的位置移动。由于你希望网格也移动,将网格拖动使其成为 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
节点和名为Ground
的MeshInstance3D
子节点启动Chunk
场景。将PlaneMesh
设置为(50, 200)
。这是单个块的大小:
图 6.7:飞机大小设置
通过将其设置为-100
来定位它以从原点开始:
图 6.8:定位飞机
添加材质并使用texture_01.png
作为(2, 10, 2)
。默认情况下,Godot 会将三个比例值链接起来以保持它们相同,因此你需要取消选中链接按钮以允许它们不同:
图 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()
函数是一个常见的随机函数,它返回一个介于 0
和 1
之间的浮点数,这使得它很容易用于计算百分比。检查随机数是否大于 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)
你已经完成了块设置的配置。当它加载时,它会随机填充建筑物和环形,并在稍后它离开屏幕时将其删除。在下一节中,你将在场景中实例化块,当飞机向前移动时。
主场景
在本节中,你将创建主场景,在这个游戏中,它将负责加载世界块、显示游戏信息和开始及结束游戏。
使用名为 Main
的 Node3D
开始一个新的场景。添加一个 Plane
实例和一个 Chunk
实例以开始。
你还需要一些照明,因此请在工具栏中选择“编辑太阳和环境设置”下拉菜单,并将太阳和环境添加到场景中:
图 6.10:添加环境和太阳
你可以选择不使用生成的天空纹理,而是使用在资产文件夹中找到的 styled_sky.hdr
。选择 WorldEnvironment
并展开其 ProceduralSkyMaterial
。点击向下箭头并选择 styled_sky.hdr
:
图 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:相机跟随设置
播放 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
得到 5
,5/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
这两个布尔变量将允许你在 x 或 y 方向上开启移动,而 move_amount
和 move_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:爆炸精灵帧
你可能会注意到你无法在视图中看到精灵。当在 3D 中显示以像素绘制的 2D 图像时,引擎需要知道 3D 空间中像素的大小。为了使爆炸与飞机的大小相匹配,在检查器中将 0.5
设置为大小。在 Flags 下,将 Billboard 设置为启用。这确保了精灵始终面向相机。你现在应该看到一个大云(动画的第一帧)叠加在你的飞机上。
图 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
层。添加两个子元素:TextureProgressBar
(FuelBar
)和Label
(Score
)。
在Score
框中设置文本为0
,并添加字体,就像你之前做的那样,将其设置为48
。使用工具栏菜单将布局设置为右上角。
对于FuelBar
,在assets
文件夹中有两个纹理。你可以使用bar_red.png
用于bar_glass.png
,对于10
和0.01
。
你可以将条形放置在左下角,但如果你想调整大小,你需要更改更多设置。勾选标有6
的框。你会看到,无论你如何调整条形的大小,边框都不会拉伸:
图 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
再次播放场景,并验证条形是否显示燃料变化,并且在收集环形时分数是否正确更新。
你几乎完成了!到目前为止,你有一个基本可以工作的游戏。花点时间玩几次,确保你没有错过任何交互。随着你飞得更远,块是否在增加难度?你应该看到移动的环形,然后是中心左右生成的环形。如果有任何你不清楚的地方,请确保复习前面的部分。当你准备好了,继续制作标题屏幕。
标题屏幕
标题屏幕的目的是介绍游戏,并提供一个按钮来开始游戏。本节不会详细介绍样式 – 你应该尝试不同的设置,并尝试让它看起来令人愉悦。
使用Control
节点开始TitleScreen
场景,并添加一个Label
、一个TextureButton
以及一个用于背景的TextureRect
。
你可以使用styled_sky.hdr
作为TextureRect
的纹理属性。它比屏幕尺寸大得多,所以你可以随意缩放和/或定位它。
对于TextureButton
,在res://assets/buttons/
文件夹中有三个图像用于正常、按下和悬停纹理。图像相当大,允许调整大小,所以你可以勾选忽略纹理大小,并将拉伸模式设置为保持纵横比以允许你调整大小。
Label
节点用于显示游戏标题。设置字体为大号,例如128
。将Label
和TextureButton
放置在屏幕上。将它们的布局都设置为居中,然后上下移动以定位它们。
所需的唯一代码是确定按钮按下时应该执行的操作,因此向场景添加一个脚本并将按钮的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
用于收集环圈的声音。你可以在Plane
和Ring
场景中添加AudioStreamPlayer
节点,在适当的时间播放它们。
对于背景音乐,应在游戏过程中循环播放,将AudioStreamPlayer
添加到Main
场景中,使用Riverside Ride Short Loop.wav
作为流。由于它需要在开始时自动播放,你可以勾选自动播放框。
这款游戏的音频故意保持简单和欢快。虽然每个主要游戏事件(如飞过环圈、碰撞)都有声音效果,但你也可以尝试添加额外的声音,如飞机引擎、奖励或燃油低时的警告。尝试看看什么对你有效。
保存高分
保存玩家的最高分是许多游戏中的另一个常见功能(并且你可以将其添加到本书中的其他游戏中)。由于分数需要在游戏会话之间保存,因此你需要将其保存到一个外部文件,以便游戏在下次打开时可以读取它。
这里是过程:
-
当游戏启动时,检查是否有保存文件。
-
如果存在保存文件,则从其中加载分数,否则使用
0
。 -
当游戏结束时,检查分数是否高于当前高分。如果是,将其保存到文件中。
-
在标题屏幕上显示高分。
由于你需要从游戏的不同部分访问最高分变量,因此使用自动加载是有意义的。在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:添加全局脚本
前往您的标题
场景,并添加另一个标签
节点以显示高分。设置其字体并在屏幕上排列它——底部中间可能是一个不错的选择。将此添加到脚本中,以便在标题屏幕加载时显示分数:
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:文档按钮
对于position
,你可以查看Vector2
文档,看看该数据类型所有可用的函数。
另一个按钮允许你直接在 Godot 编辑器中查看文档。点击搜索帮助可以让你搜索任何方法或属性名称。搜索是智能的,这意味着你可以输入单词的一部分,随着你输入,结果会缩小。看看下面的截图:
图 7.2:搜索帮助
当你找到你正在寻找的属性或方法时,点击打开,该节点的文档引用将出现。
阅读 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 插件,你可以尝试使用。
无论如何,基本的工作流程可以分解为两个步骤:
-
添加你想要跟踪的文件。
-
提交你所做的更改。
此外,你还可以使用 GitHub 或 GitLab 等网站来存储和分享你的基于 Git 的项目。这是开发者协作项目的常见方式——实际上,Godot 的整个源代码都存储和管理在 GitHub 上。如果你这样做,你将有一个第三步:推送你提交的更改到远程仓库。
大多数开发者使用 Git 的命令行版本,你可以从你的操作系统包管理器安装或直接从 git-scm.com/downloads
下载。还有许多 GUI 界面,如 Git Kraken 或 GitHub Desktop。
Git 的使用细节超出了本书的范围,但这里有一个最基本使用的例子:创建和更新仓库以保存你的更改。所有这些步骤都将使用你的计算机终端或命令行界面完成:
-
在你的项目文件夹中创建一个新的 Git 仓库。导航到该文件夹,并输入以下命令:
~/project_folder/$ git init
-
在完成你的项目后,通过输入以下命令将新文件或更新后的文件添加到仓库中:
~/project_folder/$ git add *
-
提交你的更改,创建一个“时间点”的“检查点”,如果需要,可以回滚到该点:
~/project_folder/$ git commit -m "short description"
每次添加新功能或对项目进行更改时,都要重复步骤 2 和 3。
确保在提交信息中输入一些描述性的内容。如果你需要回滚到项目历史中的某个特定点,这将帮助你识别你正在寻找的更改。
Git 的内容远不止上述内容。你可以创建分支——游戏代码的多个版本,与他人协作同时进行更改,等等。以下是一些关于如何使用 Git 与你的项目学习的建议:
-
docs.github.com/en/get-started/quickstart/git-and-github-learning-resources
-
掌握 Git(书籍)作者:Jakub Narębski
起初可能看起来很难——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 支持
点击文件夹图标浏览到你的 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:管理导出模板
在此窗口中,你可以点击下载并安装以获取与您使用的 Godot 版本匹配的导出模板。如果你出于某种原因正在运行多个版本的 Godot,你将在窗口中看到其他版本。
导出预设
当你准备好导出你的项目时,点击项目 | 导出。
图 7.6:导出设置
在此窗口中,你可以通过点击添加…并从列表中选择平台来为每个平台创建预设。你可以为每个平台创建尽可能多的预设。例如,你可能想为你的项目创建“调试”和“发布”版本。
每个平台都有自己的设置和选项——太多无法在此描述。默认值通常很好,但在分发项目之前应该彻底测试它们。有关详细信息,请参阅官方文档docs.godotengine.org/
。
导出
在导出窗口的底部有两个导出按钮。第一个按钮,导出 PCK/ZIP…,将仅创建你项目数据的 PCK 或打包版本。这不包括可执行文件,因此游戏不能独立运行。如果你需要为你的游戏提供附加组件、更新或可下载内容(DLC),这种方法很有用。
第二个按钮,Windows 上的exe
或 Android 上的.apk
。
图 7.7:导出对话框
在下一个对话框中,你可以选择保存导出项目的位置。请注意,默认勾选的导出时包含调试复选框。当导出游戏的最终发布版本时,你将想要禁用此选项。
为特定平台导出
导出的确切步骤和要求取决于你的目标平台。例如,导出到桌面平台(Windows、MacOS、Linux)非常直接,无需任何额外配置。
在移动平台上导出可能更加复杂。例如,为了导出到 Android,你需要安装 Google 的 Android Studio 并正确配置它。由于移动平台会定期更新,详细要求可能会发生变化,因此你应该在此链接查看 Godot 文档以获取最准确的信息:docs.godotengine.org/en/latest/tutorials/export/
一旦你配置了你希望导出的平台,窗口将看起来像这样:
图 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:玩家精灵
着色器可以添加到任何由 CanvasItem
派生的节点——在这个例子中,通过其 材质 属性添加到 Sprite2D
。在这个属性中,选择 新着色器材质,然后点击新创建的资源。
图 7.10:添加着色器材质
第一个属性是着色器,在这里你可以选择新着色器。当你这样做时,会出现一个创建着色器面板。
图 7.11:创建着色器选项
注意 .gdshader
。点击 创建,然后你可以点击你新创建的着色器,在底部面板中编辑它。
你的新着色器默认有以下代码:
shader_type canvas_item;
void fragment() {
// Place fragment code here.
}
着色器函数有几个内置的 TEXTURE
输入,包含对象的纹理像素数据,而 COLOR
输出内置用于设置像素颜色。记住,片段着色器中的代码将影响每个处理的像素的颜色。
当在TEXTURE
属性中处理着色器时,例如,坐标是在一个归一化(即,范围从 0 到 1)的坐标空间中测量的。这个坐标空间被称为UV
,以区别于 x/y 坐标空间。
图 7.12:UV 坐标空间
作为一个非常小的例子,我们的第一个着色器将根据图像中每个像素的UV
位置改变图像中每个像素的颜色。
在着色器 编辑器面板中输入以下代码:
shader_type canvas_item;
void fragment() {
COLOR = vec4(UV.x, UV.y, 0.0, 1.0);
}
图 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:着色器参数
为什么在这些例子中整个图像的矩形都改变了颜色?因为输出的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:轮廓着色器
需要注意的一个重要事项——你注意到轮廓没有延伸到角色的脚下吗?这是因为一个对象的着色器只能影响该图像的像素。由于这个图像中角色的脚在边缘,下面没有像素可供着色器影响。在处理 2D 着色器效果时,这一点很重要。如果你正在创建 2D 艺术作品,请在图像周围留出几个像素的边框,以防止边缘裁剪。
3D 着色器
让我们尝试一个 3D 着色器,这样你就可以看到 vertex()
着色器是如何工作的。在一个新的场景中,添加一个具有 PlaneMesh
形状的 MeshInstance3D
。为了更好地看到顶点,从 Perspective 菜单中选择 Display Wireframe。
点击 Mesh 资源以展开它,并在 Material 属性中添加一个新的着色器,就像你之前做的那样。
图 7.16:向平面添加着色器
由于我们使用的是平面形状,所以我们有四个顶点:形状的四个角。vertex()
函数将对这些顶点中的每一个应用一个效果。例如,向它们的 y
值添加将会使它们都向上移动。
让我们从这段代码开始:
shader_type spatial;
void vertex() {
VERTEX.y += sin(10.0 * UV.x) * 0.5;
}
注意,我们现在使用的是 spatial
类型的着色器,因为我们的节点是 Node3D
。
图 7.17:移动顶点
看起来似乎没有太多变化——+X 方向的两个顶点稍微向下移动了一点。但是 UV.x
只能是 0
或 1
,所以 sin()
函数没有太多作用。为了看到更多的变化,我们需要添加更多的顶点。在网格属性中,将两个 32
都修改一下。
图 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:创建脚本对话框
通常,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
找到官方社区资源的列表。这些链接可能会随时间变化,但以下是一些您应该了解的主要社区资源:
- GitHub –
github.com/godotengine/
Godot 的 GitHub 仓库是 Godot 开发者工作的地方。如果您需要编译用于您自己使用的自定义版本或只是好奇底层是如何工作的,您可以在那里找到 Godot 的源代码。
如果你发现引擎本身有任何问题——比如某些功能不工作、文档中的错别字等等——这就是你应该报告问题的地点。
- Godot 问答 –
godotengine.org/qa/
这是 Godot 的官方帮助网站。你可以在这里发布问题供社区回答,也可以搜索不断增长的先前回答的问题数据库。如果你恰好看到你知道答案的问题,你也可以提供帮助。
- Discord –
discord.gg/zH7NUgz
Godot 引擎的 Discord 是一个非常活跃且欢迎的社区,你可以在这里获得帮助,找到问题的答案,并与他人讨论你的项目。你甚至可能会在#beginner 频道遇到本书的作者,他在那里回答问题!
Godot 菜谱
我还创建了godotrecipes.com/
上的Godot 菜谱网站。这是一个收集解决方案和示例的集合,帮助你制作你可能需要的任何游戏系统。你可以看到如何制作 FPS 角色、处理复杂的动画状态,或者为你的敌人添加 AI。
此外,还有额外的教程和已完成游戏的示例,你可以尝试使用。
图 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 开始游戏开发之旅有所帮助。本书的目标并不是为您提供制作游戏的“复制粘贴”解决方案,而是帮助您培养对游戏开发过程的直觉。正如您在探索其他资源时会看到的那样,解决问题往往有多种方法,可能没有唯一的“正确”答案。作为开发者,您需要评估并确定在您的情况下什么才是最适合您的。我祝愿您在未来的游戏项目中好运,并希望将来能有机会玩到它们!