Godot4-GDScript-游戏开发学习指南-全-

Godot4 GDScript 游戏开发学习指南(全)

原文:zh.annas-archive.org/md5/22a068492508ef9b5b81d83a6d83bae6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Godot 引擎是市场上最受欢迎的免费开源游戏引擎。随着 Godot 4.0 的推出以及许多在 Godot 中制作的热门游戏(如 Dome KeeperBrotatoCase of the Golden Idol)的发布,这种受欢迎程度只增不减。现在学习如何使用这个奇妙的游戏开发工具再合适不过了。

学习如何编程和使用新的游戏引擎可能是一项艰巨的任务。然而,这本书将逐步引导你了解从零开始创建自己游戏的各个方面,从编写我们第一个 GDScript 脚本的基础到更高级的主题。

我们将学习如何使用 GDScript 编程,这是 Godot 引擎的自定义语言,易于学习,但在游戏开发中功能强大且性能出色。然后,我们将深入了解引擎直观的图形界面,并发现其灵活的基于节点的游戏开发方法的所有内容。

这本书面向的对象

这本书是为程序员、游戏设计师、游戏开发者和游戏艺术家而写的,他们想要开始使用 Godot 4 创建游戏。如果你是编程或游戏开发的新手,正在寻找新的创意出口,并想尝试 Godot 4 和 GDScript 2.0,这本书适合你。虽然不需要具备编程或 Godot 的先验知识,但随着你在章节中的进步,这本书会逐渐引入更复杂的概念。

这本书涵盖的内容

第一章设置环境,通过设置我们在 Godot 引擎中创建游戏所需的一切开始本书,并简要概述了引擎以及如何编写脚本。

第二章熟悉变量和控制流,解释了变量是什么以及我们如何在其中存储数据的主要概念。从这里,我们将探讨不同的控制流,这些控制流有助于我们在游戏执行过程中做出决策。

第三章在数组、循环和字典中组织信息,介绍了两种新的数据类型:数组和字典。这些将帮助我们以更结构化的格式组织数据。在这个过程中,我们将学习两种不同类型的循环,我们可以用它们遍历不同的数据集。

第四章使用方法和类构建结构,深入探讨了使用方法编写可重用代码,以及如何将变量和方法结构化到类中。

第五章如何以及为什么保持代码整洁,介绍了许多关于编写整洁代码的概念,这将帮助我们创建可重用且易于他人和自己理解的代码。

第六章, 在 Godot 中创建自己的世界,将启动我们的游戏项目。我们首先定义我们将制作的游戏类型,然后逐步制作玩家角色的基础以及他们将在其中移动的环境。

第七章, 让角色动起来,提供了关于矢量数学的复习,这对于在二维空间中移动实体至关重要。然后,我们将编写物理代码使我们的层角色移动,并在游戏运行时进行调试。

第八章, 拆分和重用场景,展示了我们如何轻松地将游戏拆分成多个更小的场景,这些场景更容易管理和维护,随后介绍了如何在项目内部整理所有场景和脚本文件到整洁的文件夹中。

第九章, 摄像机、碰撞和可收集物品,首先制作一个平滑的摄像机,它将跟随玩家角色而不会让现实生活中的玩家感到恶心。在此之后,我们将处理与地形的碰撞并创建可收集物品。

第十章, 创建菜单、制作敌人和使用自动加载,通过教授我们 Godot 引擎的菜单系统来结束我们的单人游戏,随后介绍了可以穿越世界的敌人以及玩家可以用来射击这些敌人的投射物。我们本章以自动加载的介绍结束,通过它可以存储高分。

第十一章**,多人游戏,将我们的单人体验转换为多人游戏。我们首先进行计算机网络速成课程。在此之后,我们将学习MultiplayerSpawnerMultiplayerSynchronizer,以便能够在网络上与其他人一起玩游戏。

第十二章, 导出到多个平台,展示了我们如何将游戏导出到不同的平台,如 Windows、macOS、Linux,甚至网页。我们将通过将我们的游戏上传到 Itch.io(一个流行的独立游戏平台)来结束本章。

第十三章, 面向对象编程的继续和高级主题,介绍了更高级的super关键字、静态变量、枚举、lambda 函数、向方法传递值的不同方式以及tool关键字。

第十四章, 高级编程模式,为我们提供了编程模式的基础,并探讨了事件总线、对象池和状态机模式,以便我们可以在下一个项目中使用它们。

第十五章, 使用文件系统,介绍了 Godot 引擎的文件系统,并展示了我们如何在游戏中保存和加载数据。

第十六章接下来是什么?,为我们留下了一些最后的技巧和资源,以开始下一个游戏项目,并介绍我们可以加入的游戏开发社区。

要充分利用本书

您不需要任何关于编程或游戏开发的先验知识。唯一的前提是您愿意学习并愿意改进。在本书中,我提出了您可以进行的多个实验,并包括了测验来测试您的知识。重要的是您花时间做这些,以便知识在您的脑海中巩固。

我们将在本书的第一章介绍如何下载和设置 Godot 引擎,但如果你感到不耐烦,你现在就可以下载 Godot 4.2.1 或更高版本。本书中的所有示例都在 Godot 4.2.1 上进行了测试,但也应该在未来的版本中工作。

本书涵盖的软件/硬件 操作系统要求
Godot 4.2.1 Windows, macOS, 或 Linux
GDScript 2.0

Godot 引擎是一个非常轻量级的软件,可以在较旧、过时的硬件上轻松运行,但查看最低规格并确保您的计算机能够满足它们是没有坏处的:docs.godotengine.org/en/stable/about/system_requirements.html

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在第一章中,我们学习了如何使用节点的_ready方法编写代码。”

代码块设置如下:

func deal_damage(amount: float) -> void:
   player_health -= amount
func heal(amount: float) -> void:
   player_health += amount

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

func minimum(number1, number2):
   if number1 < number2:
      return number1
   else:
      return number2

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

unzip Godot_v4.2.1-stable_linux.x86_64.zip -d Godot

通过打开项目菜单并选择打开用户数据文件夹来打开特定项目的user://文件夹。”

容器

我们称数组为容器,因为我们可以在其中存储和检索其他数据类型的数据片段,如整数、字符串、布尔值等。数组包含其他数据。

容器结构其他数据,使其更容易处理。

联系我们

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

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

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

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

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

分享您的想法

一旦您阅读了《通过使用 Godot 4 开发游戏学习 GDScript》,我们很乐意听听您的想法!请点击此处直接转到该书的亚马逊评论页面并分享您的反馈。

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

下载这本书的免费 PDF 副本

感谢您购买这本书!

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

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

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

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

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

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

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

下载这本书的免费 PDF 副本

packt.link/free-ebook/978-1-80461-698-7

  1. 提交您的购买证明

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

第一部分:学习如何编程

在这部分,我们将首先下载免费开源的 Godot 引擎,并设置我们将从零开始开发自己的游戏的环境。然而,在我们开始创建游戏之前,我们将使用 GDScript 编程语言建立编程的坚实基础。

到这部分结束时,你将了解所有关于变量、控制流程、不同的数据类型和容器类型、方法以及类。我们将以关于代码整洁性的章节来结束这部分内容。

本部分包含以下章节:

  • 第一章, 设置环境

  • 第二章, 熟悉变量和控制流程

  • 第三章, 在数组、循环和字典中分组信息

  • 第四章, 使用方法和类构建结构

  • 第五章, 如何以及为什么保持代码整洁

第一章:设置环境

随着游戏引擎变得越来越强大,游戏开发正变得越来越容易接近。以前只有大公司和富裕个人才能使用的工具和流程现在对任何拥有电脑的人都是免费的。任何人都可以体验到创建自己游戏并让他人玩游戏的满足感。

这正是本书要实现的目标。我们将从对编程或游戏开发一无所知,到创建我们的第一个游戏,甚至更远。

在本书的第一部分,我们将学习有关设置 Godot 和编程的所有知识。这可能有点抽象,但我将尝试提供清晰的示例,并通过您可以自己进行的练习和实验来保持您的参与度。

本书第二部分将更加实用,因为我们将会深入创建我们自己的视频游戏!我们将学习如何使用 Godot 编辑器创建有趣的游戏场景和情景。

在本书的最后部分,我们将把我们的编程技能提升到新的水平,并学习所有关于高级主题的知识,例如更强大的概念、编程模式、文件系统等等。

但在我们到达那里之前,没有什么比开始一个新的项目更令人满足的了!它代表了一块空白的画布,充满了无限的可能性。在本章结束时,我们将创建我们自己的空白画布,并写下我们的第一行代码。但首先,我想花些时间介绍 Godot 游戏引擎和开源软件。

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

  • Godot 引擎和开源软件

  • 从官方网站下载引擎

  • 创建我们的第一个项目

  • 如何加入社区

技术要求

由于本书旨在帮助您从对编程和游戏开发一无所知到达到中级水平,因此没有技术要求。所以,我将引导您完成所有(或至少大多数)创建游戏所需的步骤。

示例项目和代码

您可以在本书的 GitHub 仓库中找到本书的示例项目和代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter01.

Godot 游戏引擎和开源软件

我们将使用 Godot 游戏引擎,我假设您已经知道它的存在,因为这是一本专门关于该引擎的书。但让我给您一些关于其历史和开源含义的更多见解。

关于引擎的一些背景信息

Godot 引擎是一款开源软件,让不同经验和背景的人都能创建游戏。该项目始于 2007 年,由 Juan Linietsky 和 Ariel Manzur 发起,作为几个阿根廷游戏工作室的内部引擎。到 2014 年底,该引擎被开源化,使每个人都能免费访问代码。从那时起,它获得了大量关注,并成为市场上最受欢迎的游戏引擎之一。许多商业游戏已经发布或正在使用该引擎开发。已发布的游戏示例包括 Brotato、Dome Keeper、Case of the Golden Idol 和 Cassette Beasts。

对于那些好奇的人,是的,这个引擎是以塞缪尔·贝克特的戏剧作品等待戈多命名的。选择这个名称是因为人们总是会等待下一个版本或新功能,从而形成一个无休止的等待循环。

在谈论引擎名称的话题时,让我们也把发音问题一并解决。简而言之,没有标准的发音方式来读 Godot。由于与法语剧本标题的关联,有些人说它应该读作“go-do”,不强调任何一个音节。但大多数英语使用者会说“GOH-doh”,并强调第一个音节。然后,还有一群人把它读作“go-DOT”,主要是因为它听起来与单词“robot”相似,而且引擎的标志是一个蓝色机器人。但我注意到我每次说的 Godot 都不一样。所以,长话短说,怎么读都行。只要大致使用相同的字母。

开源软件是什么?

如前所述,Godot 是开源的,这意味着引擎的源代码是免费可用的。由于每个人都有访问权限,人们可以按照自己的喜好修改这段代码。一旦他们调整了足够的参数或开发了新功能,他们可以向软件的创作者请求将这些调整或功能包含到原始项目中。创作者将审查其他人所做的工作,如果需要的话稍作修改,然后将它添加到原始软件的代码中。这个过程创造了一个良性循环,使每个人都受益:

  • 软件的创作者可以更快地扩展代码,因为每个人都有所贡献

  • 有技术知识的人可以添加他们缺少的功能,使其满足他们的需求

  • 最终用户将获得更好、更稳定的最终产品

但并非每个开源项目都是平等的。每个免费开源软件FOSS)都有自己的许可证。这个许可证规定了你可以或应该如何使用该软件。其中一些相当限制性,但在 Godot 引擎的情况下,我们很幸运:我们可以做任何事情,没有重大的限制。我们只需在我们的游戏信用页面中提及创作者。

好吧——我们已经知道了 Godot 引擎是什么,如何发音它的名字(或者不发音),以及为什么 FOSS 如此出色。让我们直接进入准备我们的开发环境吧!

获取和准备 Godot

在我们进行任何编程之前,我们需要设置开发环境。这就是我们将在本节中做的,从下载引擎和创建一个新项目开始。

下载引擎

获取引擎相对简单,只需要几个步骤:

  1. 首先,我们需要下载软件的一个副本。我们可以在 godotengine.org/download 做这件事。

图 1.1 – Windows 平台 Godot Engine 4.0 的下载页面

图 1.1 – Windows 平台 Godot Engine 4.0 的下载页面

  1. 通常,页面会自动将您带到浏览网站时使用的操作系统的下载页面,您可以在页面中间按下大蓝色按钮来下载引擎。如果它没有这样做,您需要在页面向下滚动时选择您的计算机平台(Windows、macOS、Linux 等)。

图 1.2 – 如果下载页面无法检测到您的计算机平台,请选择

图 1.2 – 如果下载页面无法检测到您的计算机平台,请选择

  1. 下载页面也应该检测您是否使用的是 64 位或 32 位系统。如果它没有正确检测,那么您可以在 所有下载 部分找到其他版本:

图 1.3 – 所有下载部分,您可以在这里找到不同版本的引擎

图 1.3 – 所有下载部分,您可以在这里找到不同版本的引擎

  1. 我们下载的是一个 ZIP 文件。因此,解压它以获取实际的引擎。

    • 在 Windows 上:右键单击 zip 文件并选择 解压到...。现在按照弹出的提示选择一个位置。

    • 在 macOS 上:双击 zip 文件,文件将被解压到一个新的文件夹中。

    • 在 Linux 上:在终端中运行以下命令:

      unzip Godot_v4.2.1-stable_linux.x86_64.zip -d Godot
      
  2. 将解压的文件放在您电脑上的一个安全位置,例如桌面、应用程序或任何除了 下载 文件夹之外的位置。否则,如果您像我一样,您可能会在清理 下载 文件夹的狂潮中不小心删除它。

对于这本书,我们将使用 4.0.0 版本,因为它刚刚发布。但任何以 4 开头的版本都应该可以正常工作。不幸的是,这并不能保证。我们将尽力保持这本书的内容更新,但开源软件的发展速度很快。

Godot Engine 的下载大小很小,大约 30 到 100 MB,具体取决于您的平台。这个小巧的包就足够我们创建令人惊叹的游戏了。与 Unity 的 10 GB 和 Unreal Engine 的 34 GB 相比!当然,这些都没有包含任何资产,如视觉效果或音频。

获取引擎的过程就到这里。您不需要安装任何其他东西来使用它。

引擎的其他版本

由于 Godot 引擎是开源的,因此也有很多开源的完整游戏项目。如果你想在你的机器上运行这些游戏项目之一,请确保你使用正确的 Godot 版本;否则,游戏可能会崩溃,出现一些奇怪的事情。你可以从 godotengine.org/download/ 找到并下载所有官方版本的 Godot。

创建新项目

现在,让我们继续创建我们的第一个 Godot 引擎项目,希望未来会有更多!

  1. 首先,通过双击在下载引擎部分下载的文件来打开引擎。屏幕将出现如下画面:

图 1.4 – 通过点击“新建”按钮创建新项目

图 1.4 – 通过点击“新建”按钮创建新项目

  1. 选择+新建;将弹出一个新窗口:

图 1.5 – 设置新项目

图 1.5 – 设置新项目

  1. 将项目命名为Hello World

  2. 选择一个项目路径区域来放置项目。通过使用创建文件夹按钮创建一个新的文件夹,或者使用现有的一个,但请注意,这个文件夹最好是空的。虽然你选择的文件夹可以包含文件,但从一个干净的目录开始将使我们所做的一切更加有序。

  3. 渲染器类别下选择兼容性。兼容性渲染器是为了确保我们的游戏可以在各种硬件上运行,并支持旧版显卡和网页导出。Forward+渲染器用于尖端图形,但需要更好的显卡,而移动渲染器针对移动设备进行了优化。对于我们正在做的事情,兼容性渲染器已经足够强大,并确保我们可以导出到尽可能多的平台。

  4. 最后,按创建 & 编辑

现在,Godot 将在所选文件夹内设置我们项目的基结构,几秒钟后,将显示编辑器:

图 1.6 – Godot 引擎 4.0 编辑器

图 1.6 – Godot 引擎 4.0 编辑器

初看可能会觉得相当令人畏惧——到处都是小窗口,这里那里有多个控件,中间有一个巨大的 3D 空间。别担心。到这本书的结尾,你将几乎了解你面前几乎所有东西的来龙去脉。你正在掌握良好的手艺。

有趣的事实

Godot 开发者使用 Godot 引擎创建了编辑器本身。试着让你的大脑围绕这一点转转!他们这样做是为了更容易地扩展和维护编辑器。

亮色模式

由于印刷媒体的局限性,暗色截图可能会看起来有颗粒感且不清晰。这就是为什么,从现在开始,我们将切换到 Godot 的亮色版本。没有区别,只是编辑器的外观不同。

如果你还想在亮色模式下跟进,请执行以下可选步骤:

  1. 在屏幕顶部,转到编辑器 | 编辑器设置…

图 1.7 – 编辑器菜单中的“编辑器设置…”选项

图 1.7 – 编辑器菜单中的“编辑器设置…”选项

  1. 查找主题设置。

  2. 预设下拉菜单中选择浅色主题:

图 1.8 – 在主题设置中选择浅色主题预设

图 1.8 – 在主题设置中选择浅色主题预设

现在,编辑器将看起来像图 1.9中所示:

图 1.9 – 应用了浅色主题的 Godot 引擎编辑器

图 1.9 – 应用了浅色主题的 Godot 引擎编辑器

在完成这些之后,让我们通过学习如何创建场景来创建一个游戏。

创建主场景

让我们继续设置我们的第一个场景:

  1. 图 1.10的最左侧面板中,显示场景面板,选择2D 场景。这个按钮将为 2D 游戏设置场景,如图所示:

图 1.10 – 在左侧面板中选择 2D 场景

图 1.10 – 在左侧面板中选择 2D 场景

你会看到在场景面板中有一个名为Node2D的节点,并且中间窗口中的 3D 空间被一个 2D 平面所取代。

  1. 右键点击名为Main的节点。这个节点将是我们目前要工作的主节点:

图 1.11 – 将 Node2D 节点重命名为 Main

图 1.11 – 将 Node2D 节点重命名为 Main

  1. 通过转到场景 | 保存场景或按Ctrl/Cmd + S来保存场景:

图 1.12 – 保存场景

图 1.12 – 保存场景

  1. 我们会被询问希望将场景保存到何处。选择项目的根文件夹,并将文件命名为main.tscn

图 1.13 – 选择根文件夹以保存场景并命名为 main.tscn

图 1.13 – 选择根文件夹以保存场景并命名为 main.tscn

首个场景的创建就到这里。我们刚刚添加的是一个节点。这些节点代表 Godot 中的所有内容。图像、声音、菜单、特殊效果——一切都是一个节点。你可以把它们看作是游戏对象,每个在游戏中都有其独立的功能。玩家可以是一个节点,就像敌人或金币一样。

另一方面,场景是由节点或游戏对象的集合组成的。目前,你可以将场景视为关卡。对于一个关卡,你需要一个玩家节点,一些敌人节点,以及一大堆金币节点;这些节点的集合就是一个场景。这就像节点是颜料,场景是我们的画布。

我们将在整本书中回顾节点和场景。

简要的 UI 概述

现在是时候回顾编辑器 UI 的一些更突出的功能了。正如我们之前看到的,它看起来就像这样:

图 1.14 – 编辑器的概述

图 1.14 – 编辑器的概述

编辑器中的突出元素如下:

  1. 场景树区域显示了当前场景中的所有节点。目前只有一个。

  2. 文件系统 区域提供了对项目文件夹内文件的访问。

  3. 中间窗口是 当前活动的主要编辑器。目前,我们可以看到 2D 编辑器,它将允许我们在场景中 2D 空间内放置节点。

  4. 检查器区域完全位于右侧,显示当前选中节点的属性。如果你打开一些手风琴菜单,例如 变换 部分,你将找到与选中节点相关联的多个设置。

单独的节点本身并没有什么作用。它们为我们提供了特定的功能,例如显示图像、播放声音等,但它们仍然需要一些高级逻辑来将它们绑定到实际的游戏中。这就是为什么我们可以通过脚本扩展它们的功能和行为。

编写我们的第一个脚本

脚本 是一段代码,它为节点添加逻辑,例如移动图像或决定何时播放声音。

我们现在将创建我们的第一个脚本。再次右键点击 Main 节点并选择 附加脚本

图 1.15 – 将脚本附加到主节点

图 1.15 – 将脚本附加到主节点

将会出现一个弹出窗口。保持一切原样。需要注意的是,选中的语言是 GDScript,这是我们将在本书的整个过程中学习的编程语言。其他内容目前并不很重要。它甚至预先填充了脚本名称,该名称将附加到节点上。按下 创建

图 1.16 – 点击创建以创建脚本

图 1.16 – 点击创建以创建脚本

中间面板,之前是 2D 平面所在的位置,被一个新的窗口所取代:

图 1.17 – 一个新的脚本

图 1.17 – 一个新的脚本

这是 脚本 编辑器。我们将在本书的第一部分的大部分时间里在这里学习如何编程。

如你所注意到的,中间窗口是上下文相关的。它可以是一个 2D3D脚本 编辑器:

图 1.18 – 不同的主要窗口

图 1.18 – 不同的主要窗口

要在这些不同的编辑器之间切换,请使用屏幕顶部的按钮。

AssetLib

最后一个标签页,AssetLib,用于从 Godot 的资产库中获取预制的资产。这个库可以直接在 Godot 编辑器内为你的项目提供自定义节点、脚本或其他任何资产。我们不会涵盖 3D 编辑器或 AssetLib,但了解它们的存在是好的。

AssetLib 上的所有资产都是开源的,因此可以完全免费使用!为自由开源软件欢呼!

如果你尝试切换到不同的编辑器,请返回到 脚本 编辑器,这样我们就可以创建我们的第一个脚本并确保一切准备就绪。脚本中的代码目前看起来是这样的:

extends Node2D
# Called when the node enters the scene tree for the first time.
func _ready():
   pass # Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
   pass

再次提醒,不要担心这里的所有不同命令和特定语法。我们将在适当的时候覆盖所有内容。现在,知道这是一个用 GDScript 编写的脚本就足够了,GDScript 是 Godot 的脚本语言。

为了创建经典的 pass # Replace with function body.,将其替换为以下内容:

   print("Hello, World")

这行代码将显示文本 "Hello, World;",它不会使用打印机打印任何内容。我们也可以丢弃一些不需要的代码。整个脚本现在应该看起来像这样:

extends Node2D
func _ready():
print statement we added. We add this *tab* because it shows that the line of code belongs to the _ready function. We call the practice of adding *tabs* in front of lines indentation.
			Important note
			Throughout this book, we haven’t used tabs in the text due to editorial reasons. We will use three spaces to represent one tab. This is why you’re better off not copying and pasting code from this book into the editor. The complete code for this book can be accessed and copied from this book’s GitHub repository (link in the *Technical* *requirements* section).
			All the lines within the `_ready` function will run when the node is ready, we’ll see what this means in more detail later. For now, it suffices to know that this function gets executed when the node is ready to be used.
			![Figure 1.19 – A function contains a code block](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_01_20.jpg)

			Figure 1.19 – A function contains a code block
			Functions are small groups of code a computer can execute. A function is always introduced by the `func` keyword, followed by the name of the function.
			You can see that the pre-filled script also provided us with a `_process` function, which we will not use for now, so we deleted it. We’ll return to functions in *Chapter 4*. Remember that every line of code within the `_ready` function will execute from the moment our game runs and that a *tab* must precede these lines.
			Use the *Tab* key to insert these tabs. The symbol on your keyboard looks like this: ↹
			The last line of interest in the script says `extends Node2D`. This simply says that we are using **Node2D**, the type of node we added to the scene, as a base for the script to start from. Everything in the script is an extension of the functionality that **Node2D** completes. We’ll learn more about extending scripts and classes in *Chapter 4*.
			Now, press the play button in the top right to run our project:
			![Figure 1.20 – The play button is used to run the project](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_01_21.jpg)

			Figure 1.20 – The play button is used to run the project
			A popup will ask us which scene we want to use as the main scene. Choose **Select Current** to set the current scene as the main one:
			![Figure 1.21 – Godot Editor will ask us to define a main scene. We can just select the current one](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_01_22.jpg)

			Figure 1.21 – Godot Editor will ask us to define a main scene. We can just select the current one
			An empty, gray screen will pop up. We did not add anything visually to our game yet. Later, there will be a sprawling and exciting game here. But this gray screen is what we should expect for now:
			![Figure 1.22 – An empty game window](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_01_23.jpg)

			Figure 1.22 – An empty game window
			The actual exciting part is happening in the editor window itself. You’ll see a new little window unfolding from the bottom where the text **Hello, World** is printed out:
			![Figure 1.23 – The output of the game shows Hello, World](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_01_24.jpg)

			Figure 1.23 – The output of the game shows Hello, World
			Success! We wrote our first script!
			As an experiment, try changing the text within the double quotes of *step 4* and rerun the program. You should see the new text printed in the output window:
			![Figure 1.24 – The output of the game after changing the printed text](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_01_25.jpg)

			Figure 1.24 – The output of the game after changing the printed text
			Those were our first steps in creating a scene and script within the Godot game engine. Throughout this book, we’ll learn everything we need to know to create a whole game from scratch, but we’ll leave it here for now. Next, we’ll take a quick look at joining the game development community.
			The Godot Engine Documentation
			If you ever feel lost, there is also the official **Godot Engine Documentation**. This is a very exhaustive source of information on all the different classes and nodes and contains manuals on all the different subsystems related to the engine.
			You can access the documentation here: [`docs.godotengine.org/`](https://docs.godotengine.org/).
			Whenever you are searching how to use a certain part of the engine or something in the book is not 100% clear, you could consult the documentation.
			Join our community!
			As the last part of this chapter, I invite you to join our community! If you need any help, encounter a bug, or just want to chat with other game developers, come and find us on any of the platforms mentioned at [`godotengine.org/community`](https://godotengine.org/community).
			I also encourage you to post your progress on 𝕏, Facebook, Instagram, Mastodon, or any other social media platform. Getting feedback and extra eyes on your projects is always fun! If you decide to do so, don’t forget to use these hashtags: `#GodotEngine`, `#indiedev`, and `#gamedev`.
			Want to reach out to me personally? Check out my site for the most up-to-date contact information: [www.sandervanhove.com](http://www.sandervanhove.com).
			In the last part of this book, I’ll go into more detail about the community and how you can join and maybe even help. But for now, let’s focus on learning the trade ourselves!
			Summary
			In this chapter, we learned about Godot Engine, which is a FOSS. Then, we downloaded the engine for ourselves and created our first project. Lastly, we saw that the built-in programming language is GDScript and made our first `"Hello,` `World"` script.
			In the next chapter, we’ll start our journey of learning how to program. See you there!
			Quiz time

				*   What does the acronym FOSS mean and where is it used?
				*   Is the Godot engine an open-source project?
				*   What line of code did we add to show *“Hello, World”* in the Output? Why did we add a *tab* at the beginning of this line?
				*   What are *nodes* in Godot Engine and how do they relate to *scenes*?

第二章:在数组、循环和字典中组织信息

第二章中,我们学习了编程的所有重要基础知识:变量和控制流。尽管这些构建块可能看起来基础且有限,但它们已经是图灵完备的,这意味着你可以用它们创建你曾经使用过的任何程序。我并不是说你应该这样做,或者这会很容易,但你可以做到。

在本章中,我们将学习新的数据结构和更高级的控制流,以便在处理大量数据时使我们的生活更加轻松。首先,我们将了解数组如何帮助我们创建数据列表。然后,我们将学习所有关于循环的知识,循环是一个非常强大的控制流结构,可以多次执行代码块而不是只执行一次。最后,我们将学习字典,这是一种帮助我们以小包的形式组织其他数据的结构。

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

  • 数组

  • 循环

  • 字典

  • 调试

  • 空白

技术要求

如果你遇到任何困难,别忘了你可以在存储库的chapter03文件夹中找到本章中我们做的所有示例。你可以在这里找到存储库:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter03

数组

通常,我们想要处理数据列表,例如玩家拥有的物品列表。数组正是我们想要用于此类场合的数据结构:它是一个可以包含来自其他数据类型元素的列表;它是一种容器类型。

容器

我们称数组为容器,因为我们可以在其中存储和检索其他数据类型的片段,例如整数、字符串、布尔值等。数组包含其他数据。

容器结构其他数据,使其更容易处理。

让我们看看在代码中数组是什么,以及我们如何创建一个数组并访问其元素。

创建一个数组

创建一个数组看起来是这样的:

var inventory = ["Key", "Potion", "Red Flower", "Boots"]

在这里,我们创建了一个数组——一个包含四个字符串的列表——并将其放入inventory变量中。注意,数组的所有元素都包含在方括号[]内,并且每个元素之间由一个逗号分隔。

就像我们刚才做的那样,一行创建一个数组通常是可行的。但有时,它可能会使代码行太长。幸运的是,我们也可以将每个元素放在单独的一行上;这有助于提高可读性,并在我们想要添加或删除元素时更容易地编辑数组:

var inventory = [
   "Key",
   "Potion",
   "Red Flower",
   "Boots"
]

这段代码将创建与本章开头相同的数组,但每个元素都格式化在新的一行上,因此更容易阅读。

重要提示

注意,我们不需要用制表符缩进数组的元素,但一般的惯例是这样做,我强烈建议你也这样做。这是清洁代码哲学的一部分,使得这些元素是数组的一部分变得格外清晰。我们将在第五章中更多地讨论编写清洁代码。

作为实验,尝试打印出一个包含数组的变量。

访问值

现在我们有了我们的列表——数组——我们希望能够访问它的元素。为此,我们必须在变量的名称后面,方括号内指定我们想要检索的元素编号。例如,要获取我们数组的第一个元素,我们必须写下以下内容:

print( inventory[0] )
# Prints out: Key

但这是什么?我告诉你我们正在检索数组中的第一个元素,但我使用了数字 0 来这样做。这是因为,与人类的计数不同,数组是 0 基的,这意味着它们从 0 开始计数。由此扩展,第二个元素可以通过使用1来访问,依此类推:

库存内容
索引
0
1
2
3

表 3.1 – 库存数组的内 容

零基计数在数学概念和计算机算法的背景下非常有意义,所以我们最好习惯它。

我们称数组中元素的位置为索引

作为实验,而不是直接使用数字来检索元素,尝试在方括号中放置一个包含数字的变量,如下所示:

var index = 3
print(inventory[index])

你也可以尝试访问数组中不存在的元素,例如元素1000,看看会出现什么错误。也尝试使用负数。

向后访问元素

如果你尝试了之前实验中我要求你使用负数访问数组元素的情况,你可能已经注意到了一些奇怪的事情。虽然负数并不总是导致错误,但它们会从数组中返回一些元素。

这是因为如果你使用负数,你会从数组的末尾访问数组内的元素!所以,索引为-1 的元素是最后一个,-2 是倒数第二个,依此类推:

var inventory = ["Key", "Potion"]
prints("The last item in your inventory is a: ", inventory[-1])
# Prints out: Potion

这个索引技巧将证明非常有用。

修改数组元素

数组的一个有趣特性是我们可以将每个元素视为一个常规变量。例如,要给一个元素赋新值,我们只需给它赋新值:

inventory[3] = "Helmet"

我们还可以使用我们之前在第二章中学到的特殊赋值运算符,如+=-=,直接更改数组中的某个值:

var array_of_numbers = [1, 4, -74, 0]
array_of_numbers[3] 4 to the third element in our array_of_numbers, 0, so that the value is now 4.
			Using the assignment operator (`=`) makes it easy to change the elements in an array.
			Data types in arrays
			Arrays can hold any data type. You can even put multiple different data types in the same array, like so:

var an_array = [

5,          # 一个整数

"seven",    # 一个字符串

8.9,        # 一个浮点数

True        # 一个布尔值

]


			This is bad practice because when you want to access one of the elements, you don’t know what you are dealing with. That is why I advise you to always use one data type for all the elements in an array.
			Strings are secretly arrays
			If you think back to *Chapter 2*, when I said that strings are called that way because they are *strings of characters*, then this might not seem like a big surprise. But a string can be thought of as an array of characters. So, we can get one specific character, just like we would get one particular element in an array:

var player_name = "Eric"

print(player_name[0])

打印出:E


			In practice, we use this less often, but it is good to know how strings work under the hood.
			Manipulating arrays
			So far, we have created arrays, accessed their elements, and even changed those elements. But there are so many more things that arrays can do. Unlike the standard data types we’ve already encountered, arrays provide functions to us that we could use. Functions are little pieces of code, just like the `_ready()` function we have been writing for each scene, that provides functionality and can do things for us.
			For example, one of these functions can append an extra element at the end of an array:

var inventory = ["钥匙", "药水"]

inventory.append("剑")


			Try printing out this variable; it will show `[Key, Potion, Sword]`. Cool, right?
			As you can see, to call a function of an array, add a point (`.`), followed by the name of that function, to the name of that array. This also applies to other data types.
			But what if we want to append one array to another one? Well, there is a function for that too:

var loot = ["金币", "匕首"]

inventory.append_array(loot)


			Now, the whole `loot` array will be appended at the end of the inventory.
			But wait, there’s more! What if you need to remove an element? You use the `remove_at()` function. This function removes an element of the array at a certain index:

inventory.remove_at(1)


			This will remove the element at index 1\. But what if you don’t know the position the element is at? You can always find it!

var index_of_sword = inventory.find("剑")

inventory.remove_at(index_of_sword)


			The `find()` function will return the index of the element we were looking for. If it finds nothing, it will return the number `-1`. So, it’s best to check if the number it returns is equal to or larger than 0; otherwise, you might remove the wrong element.
			The `remove_at()` and `find()` functions are very useful in their own right, but there is also a function that combines the two into one! This is the `erase()` function, and it can be used like this:

inventory.erase("剑")


			This last line of code will give the same result as the snippet of the two lines before because it removes the first instance of `"Sword"` it finds within the array.
			Arrays are an important concept in programming. They dynamically hold an arbitrary number of elements.
			After a small detour into debugging in the next section, we’ll learn how we can loop over these elements and run code for each of them separately using the `for` and `while` keywords.
			Don’t be scared of errors or warnings
			We’ve encountered errors here and there while writing code, especially during some of the experiments. These errors often contain valuable information about the problem and how to solve it. Let’s examine the following piece of code:
			![Figure 3.1 – An error tells us that the var keyword should be followed by the new variable’s name](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_01.jpg)

			Figure 3.1 – An error tells us that the var keyword should be followed by the new variable’s name
			Here, I stopped typing halfway while defining a variable. The code editor immediately gave me an error. As shown at the bottom, it is telling me it expected a variable’s name. Thanks for the hint, engine!
			Let’s look at an error that the engine can’t predict before running the code:

func _ready():

var inventory = [

"靴子",

"香蕉",

"蜜蜂"

]

print(inventory[100])


			At first glance, this code might look okay, but if you put it in a script and run it, you’ll see the following error pop up:
			![Figure 3.2 – After running this code, the interpreter warns us that the inventory array does not have a 101st element](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_02.jpg)

			Figure 3.2 – After running this code, the interpreter warns us that the inventory array does not have a 101st element
			The **Debugger** panel opened up, clearly stating that there was an *Invalid get index ‘100’ (on base: ‘Array’)* error. This means we used an invalid index to retrieve an element from an array. We can also see a yellow arrow showing the exact line where the error occurred. From this, we can conclude that the inventory array does not have a 101st element and thus, we should not try to print it out.
			Some errors and warnings already pop up while writing the code, such as the first one we saw in this section. This makes it easy to ensure what we write will run. Unfortunately, writing code that does not contain errors or warnings at this stage does not guarantee that executing this code will be error and warning-free. This is because, from the moment we run a piece of code, it can encounter scenarios the code parser could never have considered.
			These runtime errors and warnings occur because GDScript is a weakly typed language, meaning that any variable can have any type and could even switch types mid-execution. Here’s a simple example:

var my_vairable = 5

my_variable = "Hello, World"


			But this means that during execution, a piece of code could crash. In *Chapter 4*, you’ll learn how to deal with this uncertainty in an elegant way that keeps the flexibility of loosely typed variables.
			It is good to appreciate warnings and errors. The engine does not show these to bully us but to nudge us in the right direction to create a better, more solid piece of software. If the engine didn’t care and didn’t crash the game when an error occurred, for example, then we might ship a half-broken game. This is not what we want! We want the best experience for our players with the lowest number of bugs!
			Bugs
			When a game is broken in some way, be it a crash or a logical error, we say it has **bugs**. This terminology comes from one of the first times a computer malfunctioned and the culprit turned out to be a literal bug that crawled into the machinery. But the term was even used before that by the likes of Thomas Edison to describe “little faults and difficulties” in a piece of hardware.
			In the next section, we’ll learn about loops.
			Loops
			We’ve been putting lines of code one after the other and Godot Engine executed them nicely from top to bottom. But there comes a time when we want to repeat one or multiple lines of code. For example, what if we want to print out every item in the player’s inventory in a nice way?
			We could do something like that with the following code:

var inventory = ["靴子", "香蕉", "绷带"]

for item in inventory:

print("你拥有 ", item)


			This is called a **loop**; this specific one is a **for loop**. We’ll have a look at the two kinds of loops that are present in GDScript in the next few sections.
			For loops
			 A `for` loop called **item**. Of course, we can use the same structure for other arrays and call the temporary variable differently. I chose the name *items* because that is what an inventory contains. Another suitable name could be *item_name*.
			We can visualize the `for` loop with the following flow chart:
			![Figure 3.3 – The flow of a for loop during the execution of the code](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_03.jpg)

			Figure 3.3 – The flow of a for loop during the execution of the code
			As shown in *Figure 3**.3*, we start the loop from the top. If there is a first element, we follow the *Yes* and execute the code block within the loop for that element. Then, we check if there is a next element. If so, we execute the code block again, now for that element, and so on until there are no more elements left.
			The `for` loop looks like this:

for <temporary_variable_name> in :

<code_block>


			Syntax
			The preceding structure is also called the syntax of the language. It captures the rules of what is possible and how things should be specified in the language.
			A `for` loop is a very powerful control flow structure. Let’s have a look at some other use cases in which we can use it.
			Range function
			Sometimes, you will want to iterate over all indexes within an array. To do so, you can use the `range()` function, which is built into the engine. This function returns an array from `0` to a specified number. Here’s an example:

var numbers_from_0_to_5 = range(6)

print(numbers_from_0_to_5 )


			This code will print out `[0, 1, 2, 3,` `4, 5]`.
			Notice that we gave the number `6` to the function but that the array stops at the number 5.
			We can use the `range()` function like so:

var inventory = ["靴子", "香蕉", "绷带"]

for index in range(inventory.size()):

print("索引为 ", index, " 的物品是 ", inventory[index])


			Here, I called the `size()` function on the `inventory` array; this returns the size of the array, which we then can plug right into the `range()` function.
			The `range()` function can even do more than this. If you provide it with two numbers, it will create an array that starts from the first number and goes up to the second one, excluding it again. Try this:

for number in range(10, 20):

print(number)


			As an experiment, try constructing a `for` loop with the preceding `range()` function and print the result – that is, `range(16,` `26, 2)`.
			You’ll see that we go from `16` to `26` as expected. But this time, the interval between each number is `2`. So, we should get the numbers `16`, `18`, `20`, `22` and `24`.
			The third argument that’s given to a range command defines the size of the step we take between numbers.
			Now that we have the `for` loop and `range()` function under our belt, let’s take a look at the while loop.
			While loops
			The second kind of loop in GDScript is the `if` statement – we give it a condition and repeat its code block, so long as the condition evaluates to `true`. Here’s an example:

var inventory = ["靴子", "香蕉", "绷带", "保暖手套", "护目镜"]

while inventory.size() > 3:

inventory.remove_at(0)


			Here, we remove the first element of the inventory array as long, so the array has a length of more than three elements.
			The syntax of a `while` loop looks as follows:

while :

<code_block>


			When Godot encounters a `while` statement, it follows these steps:

				1.  First, it evaluates the condition statement. If it is true, it will go to *step 2*; otherwise. it will skip the code block completely and go to *step 4*.
				2.  Second, the code block will be executed.
				3.  Third, it will go back to *step 1* to evaluate the condition again and see if it has changed.
				4.  Finally, it will execute the rest of the code.

			So `while` loops will loop so long as the condition we defined returns a `true` value.
			We can visualize a `while` loop with the following flow chart:
			![Figure 3.4 – The flow of a while loop during the execution of the code](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_04.jpg)

			Figure 3.4 – The flow of a while loop during the execution of the code
			 In *Figure 3**.4*, we start the loop by evaluating the condition we specified. If it results in a `true` value, we execute the code block of the loop. If this value is not evaluated to be `true`, we exit the loop.
			Infinite loops
			The code block within a `while` statement must work toward getting the condition to evaluate false. Otherwise, this will result in an **infinite loop**. Luckily, Godot ensures this does not crash our computer, but it could freeze up your game and make that crash instead!
			So far, we have learned about the two basic loops within GDScript. Now, let’s have a look at how we can have more control over these loops with some special keywords that we can only use within these loops.
			Continuing or breaking a loop
			Two keywords can only be used within a loop: **continue** and **break**. Using them excessively or abusing them is not a best practice; you can avoid both if you construct your loop correctly. But they are still essential to know.
			The continue keyword
			The `for` loop, this means we go to the next element in the array. In a `while` loop, this means we go back to evaluating the condition:
			![Figure 3.5 – The continue keyword will skip all subsequent code and go back to the start of the loop for the next element in the array](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_05.jpg)

			Figure 3.5 – The continue keyword will skip all subsequent code and go back to the start of the loop for the next element in the array
			For example, the following code will print out all items in the inventory but skip `Banana`:

var inventory = ["靴子", "香蕉", "绷带"]

for item in inventory.size():

if item == "香蕉":

continue

print(item)


			The result of this loop will be as follows:

靴子

绷带


			The `continue` keyword is quite useful when you want to skip elements, but what if you want to halt the execution of the loop altogether? That is where the `break` keyword comes in. Let’s look at that now.
			The break keyword
			Sometimes, you’ll want to stop the execution of a loop prematurely. In such a case, we can use the **break** keyword:
			![Figure 3.6 – The break keyword will skip all subsequent code and stop executing the loop](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_06.jpg)

			Figure 3.6 – The break keyword will skip all subsequent code and stop executing the loop
			The interpreter will drop everything it was doing within the loop and go to the code after it. Here’s an example:

var inventory = ["靴子", "绷带", "香蕉", "保暖手套", "护目镜"]

while inventory.size() > 3:

if inventory[0] == "香蕉":

break

prints("移除:", inventory[0])

inventory.remove_at(0)


			This snippet will print out the following:

移除靴子

移除绷带


			Then, it will see that the first item in the inventory is `Bananas`, so the `if` statement evaluates to `true` and we break the loop, stopping it completely.
			With that, we’ve seen how to use `continue` and `break`, but as I said earlier, it is possible to write the same loop without these keywords. As an experiment, try rewriting both examples using the **continue** and **break** keywords so that they have the same behavior, but don’t use the **continue** or **break** keywords.
			Loops allow us to run code for an undefined number of elements, making our code more flexible and dynamic. We will use them throughout this book. Now, let’s take a look at another container data type: dictionaries.
			Dictionaries
			A **dictionary** is another data container, just like an array. But unlike arrays, which store data in a certain order, dictionaries store data using a **key-value pair**. Instead of associating each element with a predetermined number, like in an array, we associate them with a key that we define ourselves. Because we must define our own keys, there is a more rigid structure in a dictionary than in an array.
			Creating a dictionary
			Let’s say that we want to store the name, price, and weight of an item in our game. We can do this using a dictionary:

var item = {

"名称": "靴子",

"价格": 5,

"重量": 3.9

}


			Here, we use curly brackets, `{}`, to define a dictionary. Then, we define a key and its associated value within the curly brackets. For example, the `"name"` key is associated with the `"Boots"` value. Each key-value pair must be separated by a comma:
			![Figure 3.7 – Dictionaries consist of one or more key-value pairs](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_03_07.jpg)

			Figure 3.7 – Dictionaries consist of one or more key-value pairs
			As an experiment, try printing the preceding dictionary like so:

print({

"名称": "靴子",

"价格": 5,

"重量": 3.9

})


			Dictionaries help us in organizing the data together in a more structured form. Now, let’s have a look at what data we can put in a dictionary.
			Data types in dictionaries
			What data types can we use for keys and values?
			The values within a dictionary can be any data type we like, even arrays or other dictionaries!
			The keys, on the other hand, cannot. We can only use simple data types as keys in a dictionary. Strings, floats and integers are valid. More complex data types, such as arrays or dictionaries, are not allowed.
			It’s good to note that the type of the keys and values don’t need to be the same over the whole dictionary. You can use different data types throughout:

var a_mess_dictionary = {

"字符串键": [4, 6, 9],

3.14: "π",

123: {

"子键": "这是一个子字典"

}

}


			As you can see, dictionaries are very powerful structures for organizing data.
			Accessing and changing values
			Accessing and changing a dictionary’s values is very similar to how we access the values of an array. Instead of specifying the index of the element within the square brackets, we specify the key of the value we want:

var item = {

"名称": "靴子",

"价格": 5,

"重量": 3.9

}

print(item["name"])

item["价格"] += 10


			We can even use a stored key from within a variable:

var key_variable = "name"

print(item[key_variable])


			Lastly, if the key you are trying to access is a string, you can also get to its value using the following syntax:

print(item.name)


			Just use a dot behind the dictionary’s name and then the key. This will not work if the key is anything other than a string, like a number.
			Creating a new key-value pair
			A remarkable feature of dictionaries is that we can easily add new key-value pairs after the dictionary is created. We can simply do this by assigning a value to a non-existing key:

item["颜色"] = "蓝色"


			Here, we added a new key-value pair to the item dictionary with `"color"` as the key and `"blue"` as the value.
			Useful functions
			Just like arrays, dictionaries have some useful functions that come in handy occasionally.
			has
			Sometimes, we need to know if a dictionary contains a certain key. In such a case, we can count on the `has()` function:

var item = { "名称": "香蕉" }

if item.has("name"):

print(item.name)


			Because the `item` dictionary has a key called *name*, this code will print that name.
			erase
			We only saw how to add key-value pairs to a dictionary. But with `erase()`, we can also remove a pair:

var item = { "名称": "香蕉" }

item.erase("name")


			The item dictionary will be empty at the end of this snippet.
			As an experiment, try printing out the item dictionary after you erase all keys.
			Looping through dictionaries
			It might be surprising, but you can loop through dictionaries, just like arrays. For example, if we want to print out all the information in an item dictionary, we could do something like this:

var item = {

"名称": "靴子",

"价格": 5,

"重量": 3.9

}

for key in item:

prints(key, "是", item[key])


			As you can see, the temporary variable, `key`, carries the keys of the dictionary one for one.
			We can also directly iterate over all the values of a dictionary instead of first having to get the keys:

for value in item.values():

print(value)


			This loop will print out all the values within the dictionary.
			Important note
			Looping over an array or other data structure can also be called *iterating* over it.
			As an experiment, try printing out the values of a dictionary with `item.values()`:  `print(item.values())`
			Nested loops
			If you want to have more fun with loops, you can also nest them. By this, we mean you can use a loop within another loop. For example, let’s say we have an inventory that is an array of item dictionaries and we want to print out the information of each item in a nice way. We could do something like this:

var inventory = [

{

"名称": "靴子",

"价格": 5,

},

{

"名称": "魔法手套",

"价格": 10

},

{

"名称": "酷镜",

"价格": 58

}

]

for item_index in inventory.size():

print("物品 ", item_index, " 的统计数据:")

var item = inventory[item_index]

for key in item:

printt(key, item[key])


			First, we iterate over all the elements of the array, which gives us each item dictionary. Then, we iterate on all the keys of that item and print the key and its value.
			Of course, you can also combine `while` and `for` loops freely. There are no limits!
			With that, we’ve learned all about the two main looping control flows and the two main container data types in GDScript. In the next section, we’ll learn about a new data type: `null`.
			Null
			Lastly, let me introduce you to a new data type: `null`. It carries no information, cannot be changed, and has no functions you can call. So, what is it good for? It is a variable’s value when we don’t give it one from the start. Try out the following snippet of code:

var inventory

print(inventory)


			You’ll see that it will print out `null`. Sometimes, you’ll want to do this to ensure a variable exists but don’t want to initiate it with a value yet. In the filing cabinet metaphor from *Chapter 2*, this would mean that we reserved a drawer and a name for the variable but haven’t filled it with data yet.
			Using a variable in any way while it is null will result in an error. For example, the next two operations will result in an error while running the code:

var inventory

inventory.append("靴子")

var number_of_lives

number_of_lives -= 2


			So, it is best to check whether a variable is `null` if you are not sure that the variable is initialized:

var number_of_lives

if number_of_lives != null:

number_of_lives -= 2


			Some operators or functions return `null` when they cannot complete their task as expected. For example, if you access a key in a dictionary that doesn’t exist, it will return the `null` value:

var item = {

"名称": "靴子",

"价格": 5,

"重量": 3.9

}

print(item["高度"])


			The preceding example will print out `null`.
			Additional exercises – Sharpening the axe

				1.  Write a script that finds and prints out the name of the most expensive item in the following array using a `for` loop. You will need to keep two variables `most_expensive_item` and `max_price`. The `max_price` variable starts out at 0\. Now, every time you come across a more expensive item you save that item in the `most_expensive_item` variable and update the new `max_price`. The most expensive item in the following array should be the *Ring* *of Might*:

    ```

    var inventory = [

    { "名称": "香蕉", "价格": 5 },

    { "名称": "力量戒指", "价格": 100 },

    { "名称": "治疗药水", "价格": 58 },

    { "名称": "头盔", "价格": 44 },

    ]

    ```cpp

    				2.  Write a script that checks whether a specific string is a palindrome; this means that the string should look the same whether you’re reading it forward or backward. For example, *rotator* is a palindrome, while *bee* is not. To do this, you’ll have to iterate over the string in two directions simultaneously.

			Summary
			In this chapter, we looked at two new container types, arrays and dictionaries, and two types of loops, `for` and `while` loops. We also learned about useful functions in the string data type and got acquainted with the `null` value.
			In the next chapter, we’ll learn all about classes, which are custom data types that we can define ourselves.
			Quiz time

				*   What are two container types we learned about in this chapter?
				*   What is the difference between arrays and dictionaries?
				*   How do you access the 4th value in the following array?

    ```

    var grocery list = ["苹果", "面粉", "生菜", "果冻", "肥皂"]

    ```cpp

    				*   How do you access the value of the height in the following dictionary?

    ```

    var person = {

    "名称": "迈克",

    "眼睛颜色": "棕色",

    "头发颜色": "金色",

    "高度": 184,

    }

    ```cpp

    				*   What does `range(2,` `9)` return?
				*   What is the difference between a `for` and a `while` loop?
				*   When we use one loop inside of another loop, do we call this a nested loop?
				*   What value does the following variable have?

    ```

    var number_of_lives

    ```cpp

第三章:通过方法和类引入结构

第三章中,我们学习了集合类型和循环。这些强大的概念帮助我们结构化我们的数据并多次运行代码。

能够在循环中重用代码是很好的,但如果我们想在任何任意时刻重用这段代码呢?如果我们想重用整个代码和数据结构,比如——例如——敌人或车辆呢?

方法和类正是帮助我们达到这种重用水平的概念!

在本章的剩余部分,我们将看到编程的几个基本概念。到结束时,我们将学会所有成为真正的程序员所需的技能。

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

  • 函数

  • 类型提示

  • 面向对象编程(OOP)

技术要求

如果你遇到任何困难,不要忘记你可以在存储库的chapter04文件夹中找到我们在这个章节中做的所有示例。你可以在这里找到存储库:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter04

方法是可重用的代码块

第一章中,我们学习了如何使用节点的_ready()方法编写代码。我们看到了这个函数中的代码将从我们的游戏开始运行的那一刻起执行。现在,让我们更详细地看看函数是什么以及我们如何使用它们。

方法与函数的区别

术语方法函数经常被互换使用。它们表示两个非常相似的概念,但应用方式不同。在这本书中,我们将两者互换使用。

函数是什么?

一个find()函数:

var inventory = ["Amulet", "Bananas", "Candles"]
print(inventory.find("Bananas"))

在底层,解释器查找与find函数关联的代码块,使用Bananas字符串作为输入执行它,然后将结果返回给我们。

在前面的例子中,我们会打印出结果。请注意,我们在这段代码中使用的print语句也是一个函数!

我们提供给函数的输入数据被称为参数

为了简化技术细节,函数只是我们的程序从其正常执行路径中做出的一个绕行——通过另一个代码块的旁路。

定义一个函数

让我们看看一个降低玩家生命值的函数:

func lower_player_health(amount):
   player_health -= amount

如你所见,要定义一个函数,我们需要以下部分:

  • func关键字。这表示 GDScript 我们即将定义一个新的函数,就像var关键字用于变量一样。

  • 一个名称。这是我们用来调用函数的名称,例如在这个例子中是lower_player_health()。确保你选择一个描述性的名称,就像变量名一样。

  • 一系列用逗号分隔并括在括号中的参数;在这种情况下,我们只有一个参数:amount。这些是我们希望函数用户提供给我们的数据。不一定需要任何参数。

  • 当我们调用函数时执行的代码块。在这个代码块中,我们可以将函数的参数用作普通变量。

参数和参数

仔细的读者可能会注意到,当我们调用函数时,输入数据被称为参数,而在函数内部,我们称它们为参数。参数基本上是函数的输入变量,而参数是我们用来调用函数的具体值。

但不用担心术语的混淆;几乎每个程序员都会这样做,每个人都会知道你在说什么。

函数的基本语法如下所示:

func <function_name>(<parameter1>, <parameter2>):
   <code_block>

值得注意的是,定义的参数数量可以变化。在语法示例中,我们定义了两个参数,但我们可以定义零个甚至一百个。

例如,这里有一个简单的函数,它只是打印出Hello, World

func say_hello():
   print("Hello, World")

函数命名

函数名称有与变量名称相同的约束:

  • 它们只包含字母数字字符。

  • 不应该有空格。

  • 它们不能以数字开头。

  • 它们不应该以现有关键字命名。

但与变量名不同,一个函数的名称应该反映函数内部代码的功能。这样,当你运行一个函数时,你就知道可以期待什么。

下面是一些好的函数名称示例:

calculate_player_health()
apply_velocity()
prepare_race()

下面是一些不好的函数名称示例:

do_the_thing()
calculate()
a()

命名函数,就像编程时命名任何事物一样,虽然困难但很重要。但有必要给一切清晰描述性的名称。

返回关键字

forwhile循环中,我们使用break关键字提前退出循环。在函数中,我们有一个非常相似的关键字:return。这个关键字将使执行立即退出函数。而且公平地说,如果你在循环中放置一个return语句,它也会停止该循环,因为我们不再在一般情况下执行函数。

你可以在函数的任何地方放置它,我们就可以返回到调用函数的地方,即使这意味着某些代码永远不会被执行:

func a_cool_function():
   print("This piece of code will be executed")
   return
   print("This piece of code will NEVER EVER be executed")

函数也可以返回值,就像我们看到的数组find()函数一样,它返回了我们正在搜索的值的索引。要返回一个值,我们再次使用return关键字,但这次,我们在它后面指定我们想要返回的值:

func minimum(number1, number2):
   if number1 < number2:
      return number1
   else:
      minimum() function to get the smallest of the two values:

print(minimum(5, 2))

var lowest_number = minimum(1, 300)


			Running this snippet of code will print the number `2` and will populate the `lowest_number` variable with the number `1`.
			In this section, we implemented our own `minimum()` function, but this function actually already exists in the engine, called `min()`. So, from now on, you can use the one that the engine provides to find the smallest number.
			The pass keyword
			When creating a new script, we’ve already seen the `_ready()` function structured like this:

func _ready():

pass关键字出现。这是一行什么也不做的代码。因此,我们可以用它创建一个不包含逻辑的代码块。这样,我们可以创建空函数。

        在面向对象编程(OOP)中,空函数非常有用,我们将在*第五章*中讨论。 

        可选参数

        要使函数更灵活,你可以决定将一些参数指定为可选的。这样,你以后可以选择是否提供参数。为此,我们必须为该参数提供一个默认值。

        如果你调用函数时没有为这些参数提供值,GDScript 将使用我们指定的默认值。

        我们可以使用这种技术来扩展我们之前关于从玩家生命值中移除生命的方法:
extends Node
var player_health = 2
func lower_player_health(amount = 1):
   player_health -= amount
        在前面的例子中,`lower_player_health()` 函数有一个参数,`amount`,它是可选的。我们知道它是可选的,因为我们使用等号在定义中给它提供了一个默认值。如果我们调用这个函数并传递一个参数,它将使用该参数来填充数量。如果我们不传递任何参数,它将默认为`1`作为数量的值。我们可以这样使用这个函数:
lower_player_health(5) # Will subtract 5 from the player's health
lower_player_health(2) # Will subtract 2 from the player's health
lower_player_health() # Will subtract 1 from the player's health
        如果一个函数有多个参数,其中一个是或多个是可选的,那么可选参数应该始终在定义中放在最后。这是因为如果你省略了一个参数,GDScript 无法猜测它是哪一个,只会假设它是最后一个。如果我们不小心错误地排列了参数,代码编辑器会给出错误提示,让我们正确地排列它们。

        假设我们必须编写一个函数,该函数以特定角度、以特定速度移动玩家,并且我们还需要指定玩家是否在跑步以及是否可以与世界中的物体碰撞:
func move_player(angle, is_running, speed = 20, can_collide = true):
   # function body
        这个`move_player()`函数比`lower_player_health()`函数有更多样化的使用方式:
move_player(.5, true) # Fill none of the optional parameters
move_player(.5, true, 100) # Fill one of the optional parameters
move_player(.5, true, 1, false) # Fill two of the optional parameters
        如你所见,我们可以选择填写哪些可选参数,只要我们始终按照函数定义中指定的顺序提供它们。

        函数是所有编程的基础。许多程序只使用我们至今所学的数据类型和函数。但让我们更进一步,学习如何使用类将数据和函数组合成一个统一的单元。

        类将代码和数据组合在一起

        最后,我们来到了计算机科学中最重要的一次革命之一,这是在 20 世纪 60 年代中期震撼了编程语言世界的事情:**类**。

        一些聪明的计算机工程师思考了我们是怎样使用数据和函数的,并发现我们经常在特定的数据集上使用一组特定的函数。这促使他们将这两个概念结合起来,使它们紧密地联系在一起。这样的组合被称为类。

        在游戏中,类通常模拟特定的独立实体。我们可以为以下内容创建一个类:

            +   玩家

            +   敌人

            +   可收集物品

            +   障碍物

        每个这些类都包含并管理自己的数据。玩家类可以管理玩家的生命值和库存,而可收集物品类则管理它们是什么类型的可收集物品以及它们对玩家有什么影响。

        从本质上讲,每个类都是一个自定义的数据类型,就像我们之前看到的那样。但现在,我们亲自放入数据和函数!这是一个非常强大的概念,所以让我们开始吧!

        定义一个类

        要创建一个简单的类,我们只需使用`class`关键字并指定我们希望类拥有的名称。之后,我们可以通过定义它包含的变量和方法来开始构建类:
class Enemy:
   var damage = 5
   var health = 10
   func take_damage(amount):
      health -= amount
      if health <= 0:
         die()
   func die():
      print("Aaargh I died!")
        在这里,我们看到一个名为`Enemy`的类;它有两个成员变量,`damage`和`health`,以及两个成员方法,`take_damage()`和`die()`。

        实例化一个类

        你可以将类视为我们自定义数据类型的蓝图或模板。因此,一旦我们定义了具有成员变量和函数的类,我们就可以从中创建一个新的实例。我们称这个实例为`.new()`函数:
var enemy = Enemy.new()
        现在,这个变量包含我们自己的`Enemy`类的对象!有了这个对象,我们可以访问其成员变量并调用其函数:
print(enemy.damage)
enemy.take_damage(20)
        我们还可以将这个对象用于容器类型,如数组和字典,并将其作为参数传递给函数:
var list_of_enemies = [
   Enemy.new(),
   Enemy.new(),
]
var dict_of_enemies = {
   "Enemy1": Enemy.new(),
}

var enemy = Enemy.new()
any_function(enemy)
        你可以看到类的实例可以像任何其他类型的变量一样使用。

        命名一个类

        类需要名称,就像变量和方法一样。尽管类的名称与变量的名称有相同的限制,但惯例是将名称中的单词粘合在一起,并将每个单词的首字母大写。我们称这种格式为**Pascal case**或**PascalCase**,因为它在 1970 年代的 Pascal 编程语言中变得流行。以下是一些示例:
Enemy
HealthTracker
InventoryItem
        这些都是很好的类名。在*第五章*中,我们将讨论更多关于命名类的技巧。

        扩展一个类

        我们也可以通过扩展一个已经存在的类来创建一个新的类。这被称为**继承**,因为我们从父类继承所有数据和逻辑到子类,并在此基础上添加新的数据和逻辑。

        例如,要创建基于上一个类的新的敌人,我们可以遵循以下结构:
class BuffEnemy extends Enemy:
   func _init():
      health = 100
   func die():
      print("How did you defeat me?!?")
        你可以看到我们在新类的名称后面跟随着`extends`关键字,然后是我们想要继承的类。为了覆盖原始类的变量,我们必须在`_init()`函数中设置它们。这是一个特殊函数,当创建`BuffEnemy`类时被调用。构造函数应该初始化对象,使其准备好使用。

        你也可以看到我们可以重新定义方法,因为我覆盖了`die`函数以打印出不同的字符串。当`BuffEnemy`类受到伤害并死亡时,它将调用继承类的`die`函数而不是父类的`die`函数。

        如果我们创建`BuffEnemy`类的对象,我们可以看到其健康值确实是`100`,并且它不会因为`20`点的伤害而死亡,当敌人死亡时,它将打印出覆盖函数中的新字符串:
var buff_enemy = BuffEnemy.new()
print(buff_enemy.damage)
buff_enemy.take_damage(20)
print(buff_enemy.health)
buff_enemy.take_damage(80)
        作为实验,尝试通过自己扩展`Enemy`类来创建一个新的敌人。

        每个脚本都是一个类!

        我会向你透露一个小秘密。我们至今为止所编写的每个脚本已经是一个类了!你可能已经在阅读了*扩展类*部分之后意识到了这一点,因为我们所编写的每个脚本的第一个命令都是扩展`Node`类!这个类是 Godot 引擎中每种类型节点的基类。

        这个`Node`类包含了一些 Godot 在游戏过程中使用时需要的基本数据和代码。其中大部分对我们目前来说并不感兴趣。但一些感兴趣的内容包括以下:

            +   **生命周期方法**:这些是在节点生命周期中的特定时间执行的方法——例如,当节点被创建、销毁或更新时。

            +   **子节点和父节点**:在 Godot 中,节点遵循层次结构,每个节点都有一个对其子节点和父节点的引用。在处理给定的层次结构时,访问这些节点非常有帮助。

        我们附加脚本的节点将与该脚本配对,以及脚本的数据和逻辑,基本上是脚本的实例化对象。

        在*第七章*中,我们还将学习扩展更具体的节点,例如`Node2D`或`Sprite`。

        虽然常规类需要有一个名称,但由脚本派生的类不需要,尽管这样做是可能的。只需在脚本顶部使用`class_name`关键字即可:
class_name MyCustomNode
extends Node
# Rest of the class
        Godot 使我们很容易开始一个新的类。

        何时某些变量可用?

        你可能已经注意到了,但我们定义的变量并非在所有地方都可以访问。每个变量都有一个特定的域,在这个域内你可以使用它。让我们更仔细地看看以下这段代码:
func _ready():
   var player_health = 5
   if player_health > 2:
      var damage = 2
   player_health -= damage
        如果你在这段代码在脚本编辑器中输入,我鼓励你这样做,你会在最后一行看到一个错误弹出,说变量`damage`不在作用域内。这意味着该变量对我们不可用,我们无法使用它。

        通常,有五种情况变量对我们是可用的:

            +   变量是在我们使用变量的同一代码块中定义的,如下所示:

```cpp
var player_health = 2
print(player_health)
```

                +   变量是在当前代码块的父代码块中定义的,如下所示:

```cpp
var player_health = 2
if player_health > 1:
   print(player_health)
```

                +   变量是在当前类内部定义的,如下所示:

```cpp
extends Node
var player_health = 2
func _ready():
   print(player_health)
```

                +   变量被全局定义。我们将在*第十章*中了解更多关于这类变量的内容。但可以简单地说,这类变量在任何时间、任何脚本中都是可用的,甚至在编辑器本身中也是如此。这类变量对于存储被许多不同进程使用的信息非常有用。我们称之为**自动加载**。

            +   变量是内置于引擎中的。这些变量在全局级别上对我们是可见的;我们并没有自己定义它们。你可以在这里找到这些全局常量和函数的列表:[`docs.godotengine.org/en/stable/classes/class_%40globalscope.html`](https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html)。

        这里有一些例子:
PI # Carries the constant of pi, about 3.1415
Time
OS
        变量可访问的域称为其**作用域**。

        虽然在同一个作用域内不可能定义两个具有相同名称的变量,但可以在一个变量在当前函数外部而另一个在函数内部时定义两个具有相同名称的变量。我们称这为**阴影**,因为一个变量生活在另一个的阴影中。例如,一个变量在类中作为成员变量定义,另一个在函数中定义,如下所示:
extends Node
var damage = 3
func a_function():
   var damage = 100
   print(damage)
        如果你运行前面的代码,你会看到它打印出`100`,因为当不确定时,GDScript 总是选择最近定义的变量:

        ![图 4.1 – 一个警告,告诉我们损坏变量在脚本和函数中定义](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_04_1.jpg)

        图 4.1 – 一个警告,告诉我们损坏变量在脚本和函数中定义

        然而,如图*图 4**.1*所示,你也会看到引擎抛出一个警告,告诉我们变量名的双重使用,这可能会让我们作为开发者感到困惑。

        函数的作用域

        函数也有一定的作用域,尽管比变量的作用域要小一些。你可以在以下场景中使用函数:

            +   函数是在我们使用的类中定义的。

            +   函数是在我们类继承的任何父类中定义的。

            +   函数是内置于引擎中的;这些函数在任何地方都可以使用。以下是一个例子:
print("Hey")
max(5, 3) # Returns the highest of the two numbers
sin(PI) # Returns the sinus value for an angle
        但,正如我们之前看到的,我们也可以调用对象的类函数。这样,该函数的作用域就与对象的作用域一样大。

        类型帮助我们了解如何使用变量

        我们看到了不同的数据类型,甚至知道如何创建我们自己的。但有一个大问题!变量在执行过程中可能会改变类型。这尤其令人烦恼,因为如果我们在一个特定情况下使用了错误的数据类型,游戏就会崩溃!
var number_of_lives = 5
number_of_lives += 1
number_of_lives = {
   player_lives = 5,
   enemie_lives = 1,
}
1 to the value 5, another number, while in the second instance, we try to add 1 to a whole dictionary. This operation is not supported and thus crashes the game.
			Luckily, there is a way we can leverage our knowledge of what data type we expect for certain operations or functions. This is what we will learn over the course of this section.
			What is type hinting?
			Other popular languages such as C++, C#, Java, Golang, and Rust solve the problem of not knowing what data type a variable is by implicitly specifying what type it will carry from the moment it is defined. There is (almost) no way of defining a variable without locking it to a certain type. Also, the type of a variable, unlike in GDScript, cannot be changed over the course of a program in those other languages.
			In GDScript, there is a system to do something such as this too, but less restrictive. This system is called **type hinting** because we give a hint of what type we would like a variable to be in. This helps GDScript to determine beforehand if an operation will work or is going to crash the game.
			Let’s have a look at different ways to type hint in GDScript.
			Type hinting variables
			For example, if we want to specify that the player’s number of lives will always be a whole number, aka an *integer*, we can give a hint of this variable’s type, like so:

var number_of_lives: int = 5


			We can do the same for different data types as well:

var player_name: String = "Erik"

var inventory: Array = ["Cool glasses", "Drinks"]


			If we try to assign a value of a different type to a type-hinted variable, as in the following example, the code editor will give us a warning before we run the game and an error while running it:

var inventory: Array = ["Cool glasses", "Drinks"]

inventory = 100


			Note that we can only type hint a variable while defining it. After the definition, we can freely use the variable, and the engine needs to know if it is a specific type. That is why we cannot just add a type or change it later on.
			Type hinting helps us to catch bugs before they happen!
			Type hinting arrays
			On top of specifying that a certain variable is an `Array` type, we can also specify the type of values we can find within this array. This is very useful and makes it easy for us to know what kind of data to expect within an array.
			To specify what data types can be found within an array, just mention this type within square brackets after the `Array` type, like so:

var cool_numbers: Arraycool_numbers是一个浮点数数组,因此这个数组的每个元素都应该被视为浮点数。

        作为实验,尝试以下代码行。它将出错;为什么?
var inventory: Array[String] = ["Cool glasses", "Drinks", 100]
        如果你尝试运行它,你会看到这会出错,因为我们暗示`inventory`变量是一个填充着字符串的数组。但数组中的一个值是数字。引擎会看到这一点并给出错误。

        了解 Variant 类型

        在后台,GDScript 将使用 `Variant` 作为几乎所有变量的类型。`Variant` 类可以存储几乎任何其他数据类型;这就是为什么我们可以在不指定创建时的类型的情况下,在执行过程中切换变量的类型。

        此外,我们类型提示的变量是 `Variant` 类型。但它们附加了额外的类型要求,例如它们的值应该是整数或字典。

        在 GDScript 中,我们从不直接处理 `Variant` 类的功能。GDScript 将其很好地包装在我们分配给它的任何值周围,因此我们不必担心 `Variant` 类型。我们只需考虑存储在变量中的数据类型。

        函数参数的类型提示

        除了提示变量的类型外,我们还可以以相同的方式提示函数参数的类型:
func take_damage(amount: int):
   player_health -= amount
        现在,如果你尝试使用非整数的参数调用此函数,编辑器会警告你正在犯错误。例如,看看以下使用上一段代码中 `take_damage()` 函数的代码行:
take_damage("Two")
        在这里,引擎将抛出一个错误,因为 `take_damage()` 函数期望一个整数值,而字符串与整数值不兼容。

        变量的自动转换

        当你尝试 `take_damage(1.5)` 时,你会看到编辑器不会显示警告或抛出错误。这是因为 GDScript 会自动将某些变量从一种类型转换为另一种类型。这被称为 **隐式转换**。

        其中一种转换发生在浮点数和整数之间。在这种情况下,GDScript 将浮点数向下舍入到最接近的整数值。对于本调用开头的小例子,这意味着 `1.5` 将被舍入到整数值 `1`。

        类型提示也可以与参数的默认值结合使用;只需将类型提示放在前面,然后指定默认值:
func take_damage(amount: int = 1):
   player_health -= amount
        现在的 `take_damage()` 函数接受一个参数,名为 `amount`,它被提示为整数类型,默认值为 `1`。

        函数返回值的类型提示

        我们还可以提示函数将返回的值。这非常有用,因为它为我们提供了很多关于该函数预期内容的信息。要这样做的方式如下:
func minimum(number_1: float, number_2: float) -> float:
   if number_1 < number_2:
      return number_1
   else:
      return number_2
        这个 `minimum()` 函数将始终返回一个浮点数,无论哪个 `return` 语句执行。

        作为实验,尝试在一个被提示为返回浮点数的函数中返回空值;你会看到引擎会向我们抛出一个错误。

        使用 `void` 作为函数返回值

        有时,一个函数根本不返回任何值。在这种情况下,我们可以使用 `void` 类型来提示该函数的返回值。`void` 不能用于变量,只能用于函数定义。因此,`void` 表示该函数不返回任何内容:
var player_health: int = 5
func subtract_amount_from_health(amount: int) -> void:
   player_health -= amount
        然而,当函数不返回任何内容时,大多数人会省略`void`类型提示,并且只在函数实际返回内容时才对函数进行类型提示。当你遇到它时,了解`void`类型提示的存在是很好的。

        推断类型

        另一种在不显式给出类型的情况下为变量添加类型的方法是利用引擎自身的类型识别。我们可以使用分配给变量的第一个值的类型作为该变量在其余执行中的类型。我们可以这样做:
var number_of_lives := 5
        这看起来与常规的无类型变量定义非常相似。但这次,我们在等号之前放了一个冒号。这将锁定变量的类型为我们分配给它的值的类型。

        这种技术被称为**类型推断**,因为 GDScript 只是在赋值时获取我们传递给它的值的类型。

        注意,就像正常的变量类型提示一样,我们只能在定义变量时推断变量类型。因此,以下代码将无法工作:
var number_of_lives
number_of_lives := 5 # This will error
        类型推断可以使我们在不事先考虑实际类型的情况下更容易地为变量添加类型提示。

        `null`可以是任何类型

        知道一个变量携带的类型并不意味着我们不需要注意那些是`null`的变量。`null`可以被分配给任何不是基本类型(`int`、`float`、`String`等)的变量。因此,数组、字典、自定义类等,如果它们没有被初始化,仍然可以是`null`:
var inventory: Array[String] = ["Bananas", "Cinder", "Drake"]
inventory = null # This is legal
inventory.find("Drake") # This will crash the game
        `null`常用于将变量重置为空状态。

        自动补全

        另一个打字变量的好处是,当我们要通过自动补全调用一个函数或访问一个类的成员变量时,文本编辑器会帮助我们。例如,如果我们有一个字符串并且开始打字来调用它上面的函数,一个小弹出窗口将显示我们试图访问的所有可能的函数。然后我们只需继续打字或使用箭头键选择正确的函数,然后按*Enter*键选择一个。如果你知道你想做什么但不确定函数的名称,或者只是想加快打字长函数名的速度,这非常有帮助:

        ![图 4.2 – 当使用类型提示时,代码编辑器将帮助我们进行自动补全](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_04_2.jpg)

        图 4.2 – 当使用类型提示时,代码编辑器将帮助我们进行自动补全

        自动补全通常是我们的朋友,因此使自动补全更加完整只会从长远来看帮助我们。

        使用类型提示为命名类

        除了内置类型外,我们还可以为自定义类添加类型提示。但为此,我们首先必须为类的类型注册一个名称。要注册名称,我们可以在文件顶部使用`class_name`关键字后跟我们希望数据类型具有的名称,如下所示:
class_name Player
extends Node
var player_health = 2
func _ready():
   print(player_health)
        在这里,我们看到我们命名我们的类为`Player`。现在我们可以使用这个类型来为`Player`类的变量添加类型提示,甚至可以用它来初始化一个新实例,如下所示:
var player: Player = Player.new()
player.player_health += 1
        命名类是使用我们自定义类的实例来为变量添加类型提示的简单方法。

        性能

        除了在发生之前捕获错误和自动补全之外,类型提示还有一个很大的优势。如果你在游戏中输入变量,引擎将能够更容易地与它们一起工作,从而提高性能。

        由于引擎不需要检查变量是否能够执行某些操作,它可以每秒执行更多的这些操作。在某些情况下,这会使你的代码速度提高一倍!

        编辑器添加类型提示

        关于类型提示的最后一个小贴士,我想向大家展示编辑器也能帮上忙!如果你进入**编辑器设置** | **文本编辑器** | **完成**,你会看到一个名为**添加类型提示**的设置。这个设置会让编辑器自动用类型提示来补全你的代码的某些部分。我建议你开启它:

        ![图 4.3 – 添加类型提示设置](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_04_3.jpg)

        图 4.3 – 添加类型提示设置

        启用此设置后,编辑器将自动在需要为我们生成任何代码时填充类型提示——例如,在生成空脚本时。

        在本节中,我们学习了大量的类型提示知识,并看到了它如何增强我们的编码体验。接下来,让我们看看编程中一个非常重要的概念:面向对象编程(OOP)。

        面向对象编程入门

        到目前为止,在本章中,我们已经学习了函数、类和对象。这些概念非常强大:它们为我们提供了完全不同的处理数据和伴随逻辑的方式。

        在编程中,有多个不同的代码和数据结构化范式,其中之一是**面向对象编程**(**OOP**)。GDScript 是一种**面向对象**(**OO**)和**命令式**编程语言,这意味着我们将数据和伴随的逻辑分组在类和对象中。我们编写的逻辑由告诉计算机如何为我们执行特定任务的语句组成。每个语句都会改变程序的内部状态。大多数游戏引擎及其伴随的编程语言都是面向对象和命令式的。

        面向对象编程(OOP)建立在四个关键原则之上:继承、抽象、封装和多态。让我们来看看这些。

        继承

        面向对象编程允许类相互继承。这意味着我们可以免费获得父类所有的功能,并可以通过额外的逻辑来扩展它。这使得代码重用变得非常容易。

        例如,虽然一个游戏中可能会有很多不同的敌人,但其中大部分都会共享一些相当常见的代码,而更常见的代码则会将它们区分开来。路径查找、造成伤害、健康管理、库存管理等几乎任何敌人都会共享。因此,我们可以定义一个类,`Enemy`,它封装了所有这些功能,并且所有其他敌人都可以从它继承。

        从这里,我们可以定义执行以下操作的敌人:

            1.  走到玩家面前并使用近战攻击。

            1.  与玩家保持距离,并向玩家射击投射物。

            1.  在很多地方移动,并治愈其他敌人。

            1.  以此类推…

        这个列表不是详尽的,它表明我们可以基于相同的基类 `Enemy` 建立一个多样化的敌人阵容。

        我们可以用继承树来直观地表示这种继承,就像我们用人类及其家庭一样:

        ![图 4.4 – 不同类型的敌人可以很容易地从基础敌人类派生而来](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_04_4.jpg)

        图 4.4 – 不同类型的敌人可以很容易地从基础敌人类派生而来

        *图 4**.4* 清楚地显示了某些类是如何相互关联和/或不同的。

        抽象

        一个类隐藏了其内部实现,仅通过暴露高级函数来抽象其功能。类的用户不关心某些结果是如何实现的;对于外部世界来说,获取某些结果的实际过程可能是纯粹的魔法。

        对于之前提到的 `Enemy` 类示例,这意味着我们可以要求敌人向世界中的某个点移动,但不能指定如何移动。我们无权过问敌人如何进行路径查找或在世界中移动。这是敌人的事:

        ![图 4.5 – 公共和私有成员变量和方法告诉外部世界如何与类交互](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_04_5.jpg)

        图 4.5 – 公共和私有成员变量和方法告诉外部世界如何与类交互

        在大多数编程语言中,抽象以公共和私有成员函数和变量的形式出现。它们是这样工作的:

            +   `public` 用于与对象交互。

            +   `private` 对外部世界不可访问,只能由类本身使用。这些支持类的内部功能。

        在 GDScript 中,然而,没有方法可以明确地将变量或函数标记为公共或私有。默认情况下,所有内容都是公共的,并且对外部世界是可访问的。但有一个 GDScript 开发者从 Python 开发者那里继承的约定:我们在应该为私有的变量和函数名前加上一个下划线(`_`)。这样,我们可以表明一个变量或方法应该是私有的,并且不应该被类外部的任何东西使用:
extends Node
var health: int = 2 # Public variable
var _weapon: String = "Sword" # Private variable
func take_damage(amount: float): # Public function
   # Take damage in some way
func _calculate_damage() -> float: # Private function
   # Calculate damage in some way
        引擎不会强制执行这样的私有成员,所以你仍然可以调用它们,但这是一种非常不好的做法。你可以在我们已编写的脚本中看到这种公共和私有成员之间的区别,例如节点中已经存在的函数,如`_ready()`和`_update()`。

        抽象具有多个优点:

            +   **安全性**:因为类的使用者只知道使用公共方法和变量,所以它们意外误用类的可能性较低。

            +   **可维护性**:因为类的功能隐藏在几个公共函数后面,所以如果需要,我们可以轻松地重写该功能,而不会破坏其他代码。

这可以防止其他类或代码片段过多地干涉一个类的内部实现。因为如果我们重写敌人的寻路算法呢?如果我们正确地封装这段代码,那就没问题,但如果其他代码片段直接调用敌人的寻路算法,我们就必须重写所有这些代码。

            +   **隐藏复杂性**:某些代码可能非常复杂,但通过使用类,我们可以将这些复杂性隐藏在易于使用的函数和成员变量后面。

        现在我们已经了解了抽象,让我们来看看最后一个原则:封装。

        封装

        一个编写良好的类应该将所有重要信息封装在自身内部,这样类的使用者就不必担心细节问题。这意味着类应该只向外界暴露选择的信息。

        封装是抽象的扩展,但专注于类的数据。外部世界直接处理类的成员变量的程度越少,而通过成员函数处理的程度越多,就越好。

        多态

        面向对象编程的最后一个原则是多态,它表示对象和方法可以转变为多种不同的形式。在 GDScript 中,这以两种不同的方式发生:通过对象和通过方法。

        对象多态

        假设我们有一个类似于前面示例中的类结构:一个基类敌人,其他敌人从中继承。代码可能看起来像这样:
class Enemy:
   var damage: float
   var health: float
class BuffEnemy extends Enemy:
   var attack_distance: float = 50
   func _ready():
      damage = 2
      health = 10
class StrongEnemy extends Enemy:
   func _ready():
      damage = 10
      health = 1
        现在,当我们创建`BuffEnemy`和`StrongEnemy`类的实例时,我们可以将它们类型提示为这样的类型,但也可以将它们类型提示为它们的基类`Enemy`:
var buff_enemy: BuffEnemy = BuffEnemy.new()
print(buff_enemy.damage)
var enemy: Enemy = buff_enemy
print(enemy.damage)
        这之所以有效,是因为从`Enemy`类继承的所有内容都应该在其核心具有相同的成员变量和函数,因此它可以放入父类的变量中。

        但是你不能将`Enemy`类型的对象分配给类型为它的子类之一的变量。所以,下一行也会出错:
var buff_enemy: BuffEnemy = Enemy.new()
        这两个子类也不兼容。所以,下一行也会出错:
var buff_enemy: BuffEnemy = StrongEnemy.new()
        前两个例子不工作,因为你不能保证 `Enemy` 和 `StrongEnemy` 类中的成员变量和函数与 `BuffEnemy` 类中的相同。实际上,我们可以看到 `BuffEnemy` 类有一个 `attack_distance` 成员变量,而 `Enemy` 和 `StrongEnemy` 类没有。

        多态概念的一个很好的类比是现实世界中的车辆。假设我们有三种车辆:

            +   汽车

            +   自行车

            +   卡车

        尽管所有三种车辆都能将你从一个地点移动到另一个地点,都有一定数量的轮子,并且由金属制成,但它们之间存在一定的层次结构:

        ![图 4.6 – 简单车辆的类结构](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_04_6.jpg)

        图 4.6 – 简单车辆的类结构

        自行车需要有人来提供动力,而汽车和卡车有发动机。此外,我们可以将汽车作为卡车的模板,并说卡车是一种长而大的汽车。

        卡车也因其可以运输更大货物的地方而与汽车不同。这使得我们可以说卡车是汽车,但汽车不是卡车。

        方法覆盖

        然后,还有覆盖父类方法的可能性。这将完全用新的函数替换原始函数,但仅限于给定的子类。这在子类需要与父类略有不同甚至截然不同的逻辑时非常有用。要在 GDScript 中做到这一点,子类中的方法应遵循以下约定:

            +   名称必须相同

            +   参数数量必须相同

            +   如果参数被输入,它们应该具有相同的类型

            +   如果有任何默认值,它们必须完全相同

        你会看到当我们想要覆盖一个方法时,我们需要非常精确。如果我们不是这样做的,引擎会将其识别为单独的函数或错误,因为覆盖操作执行不正确。

        让我们看看两个演示这一点的类。一个基类 `Enemy` 有一个名为 `die()` 的函数,该函数打印出 `"Aaargh!"`。这个 `die` 函数只是在敌人死亡时打印出一个感叹号。然后,我们从基类继承 `BuffEnemy` 类并覆盖 `die()` 函数以打印出 `"How did you` `defeat me?!?"`:
class Enemy:
   func die():
      print("Aaargh!")
class BuffEnemy extends Enemy:
   func die():
      print("How did you defeat me?!?")
        如果你调用每种敌人的 `die()` 函数,你会看到它们各自都有自己函数的实现:
var enemy: Enemy = Enemy.new()
enemy.die()
        前面的代码将按预期打印出 `"Aaargh!"`。现在对于 `BuffEnemy` 类:
var buff_enemy: BuffEnemy = BuffEnemy.new()
buff_enemy.die()
        现在,我们执行覆盖的 `die()` 函数,打印输出将显示 `"How did you` `defeat me?!?"`。

        即使将 `BuffEnemy` 对象放入 `Enemy` 变量中,它仍然会使用来自 `BuffEnemy` 类的覆盖函数:
var enemy: Enemy = BuffEnemy.new()
enemy.die()
        再次,我们会看到打印出 `"How did you defeat me?!?"`。这是因为 `BuffEnemy` 类从 `Enemy` 类继承,因此它是 `Enemy` 类型,但其函数的实现仍然可以被覆盖。

        我们对面向对象编程(OOP)及其原则有了很多了解。这是一个非常有趣但复杂的主题。不必过于担心一开始就能完全掌握所有原则。知道它们的存在已经完成了一半的工作。让我们在下一节通过一些额外的练习来结束本章。

        额外练习 - 锋利斧头

            1.  编写一个名为 `limit_inventory()` 的函数,它接受一个表示库存的数组和整数。该函数检查数组是否比提供的整数长;如果是,它应该删除所有过多的项目。最后,该函数返回结果数组:

```cpp
var inventory: Array = ["Boots", "Sword", "Grapes", "Cuffs", "Potion"]
var limited_inventory: Array = limit_inventory(inventory, 2)
print(limited_inventory)
```

这个例子应该会打印出 `["``Boots", "Sword"]`。

                1.  重新编写之前的函数,使其接受的整数具有默认值 3,以便以下代码能够正常工作:

```cpp
var inventory: Array = ["Boots", "Sword", "Grapes", "Cuffs", "Potion"]
var limited_inventory: Array = limit_inventory(inventory)
print(limited_inventory)
```

这应该会打印出 `["Boots", "``Sword", "Grapes"]`。

                1.  重新编写此代码,使其不再出错:

```cpp
func _ready():
   var player_health = 5
   if player_health > 2:
      var damage = 2
   player_health -= damage
```

                1.  编写 `Player` 和 `Enemy` 类,使以下代码能够工作。在这段代码中,玩家和敌人将互相伤害,直到其中一方的生命值等于或低于零。将其视为一场原始的战斗:

```cpp
var player: Player = Player.new()
var enemy: Enemy = Enemy.new()
while player.health > 0 and enemy.health > 0:
   enemy.take_damage(player.damage)
   player.take_damage(enemy.damage)
```

                1.  将之前的练习中的 `Player` 和 `Enemy` 类重写为从同一个基类继承。

        概述

        在我们的工具箱中有了函数、类和类型提示后,我们终于学会了编程的所有基本构建块!从现在开始,可能性是无限的!

        在下一章中,我们将学习如何以干净的方式编写和结构化我们的代码,以便其他人容易使用和理解。

        测验时间

            +   为什么我们使用函数和类?

            +   在函数中,`return` 关键字可以用于哪两个目的?

            +   变量的作用域是什么?有哪些不同的层级?

            +   函数的作用域是什么?

            +   类是一组变量和函数的组合吗?

            +   给定以下代码,我们如何创建 `Enemy` 类的新实例?

```cpp
class Enemy:
   var damage: int = 5
   # Rest of the class
var new_enemy: Enemy = ...
```

                +   我们如何调用类的实例?

            +   类型提示是什么?

            +   为以下变量添加类型提示:

+   `var player_health = 5`

+   `var can_take_damage =` `true`

+   `var sword = { "damage_type": "fire", "damage":` `6 }`

            +   除了自动完成和性能提升之外,使用类型提示的最终好处是什么?


第四章:如何以及为什么保持代码整洁

第 14 章,我们学习了编程的所有基础知识,即将深入开发我们自己的游戏。

但在我们这样做之前,我们必须意识到游戏代码库可以变得非常大。这意味着我们今天编写的代码和系统可能会被其他代码和系统埋没。因此,返回到我们之前的工作可能会很麻烦,因为我们忘记了我们为什么以及如何以某种方式编写某些代码。

正因如此,现在是停下来思考如何保持我们的代码即使在编写后数月仍然干净和可理解的理想时刻。本章中的大多数内容都是通过我自己犯错误并在书籍和文章中寻找解决方案学到的。

尽管大多数技巧可能感觉像是一种批判性思维,并将你带到同一个点(它们可能确实如此),但总是说出它们并解释为什么程序员会采用它们总是有帮助的。

在编程时将其牢记在心,这将使你比其他初学者程序员迈出更大的步伐。

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

  • 再次讨论命名问题

  • 编写好的函数

  • 为什么使用私有变量和函数

  • 不要重复自己 (DRY)

  • 防御性编程

  • 编码风格指南

技术要求

如果你在代码示例仓库中的任何地方遇到困难,请查看 chapter05 文件夹。你可以在这里找到仓库:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter05

回到命名问题

让我们再次看看如何命名变量、函数和类。为这些中的任何一个选择正确的名称非常重要,因为它会使理解代码变得容易得多。

命名约定

正如我们在 第 34 章中看到的,变量、函数和类名有不同的约束。我们使用了特定的规则来命名每个。这些方法被称为 命名约定。它们为我们想要约束名称形成的方式提供了一个术语。GDScript 风格指南中推荐的三种主要命名约定如下:

  • player_health, movement_speed, 和 weekly_highscore.

  • BUTTON_SIZE, PI, 和 TEAM_A_COLOR.

  • BackgroundColor, PlayerWeapon, 和 GameStartTimer.

还有许多其他更独特的约定,如下所示:kebab-case, camelCase, flatcase 等。但在 GDScript 中并不使用这些。

命名的一般技巧

现在,让我明确一点:命名事物并不容易。实际上,这是编程中最困难的事情之一。所以,如果你命名正确,你将始终能够快速返回到任何代码。

这里有一些技巧可以帮助你成为命名大师。

使用有意义的描述性名称

在早期,程序员必须与计算能力和内存都不多的计算机一起工作,他们还以创建最短的脚本来解决问题而自豪。这导致了变量被赋予一个或两个字母的名称,如ac5。将代码优化得尽可能短是非常令人满意的。然而,这些代码片段非常难以理解。即使是编写脚本的人,在过了一段时间后也无法再阅读它。

这就是为什么描述性变量名是一个很大的加分项。当然,它们需要多花几秒钟来输入,但这与花费几分钟甚至几个小时来弄清楚变量为什么存在以及应该如何使用相比,微不足道;而且,自动完成功能总是会帮助我们。

甚至有些人说,短的变量名会导致游戏性能更好。这根本不是真的。编程语言会在代码中将变量标记化,使得任何变量名执行速度都一样快,无论其长度如何。

技巧是使变量、方法或类的名称具有意义和描述性。为此,你可以针对不同类型提出以下问题:

  • 变量

    • 变量将包含什么类型的数据?

    • 这类数据应该如何使用?

  • 函数

    • 函数做什么?

    • 函数返回什么数据?

    • 函数需要什么类型的参数才能工作?

    • 这个类将用于什么?

    • 这个类负责哪些数据?

使用这些问题将指导你在为变量、函数和类命名时的决策。

避免使用填充词

虽然长描述性名称是最佳选择,但我们也不希望用填充词或不必要的词(如下所示)来模糊名称:

  • The

  • A

  • 对象

这些词只是让名称变得冗长,而没有任何额外的意义。

保持名称可发音

好的代码应该是易于阅读的。这意味着你应该能够像读一本书一样阅读它,而且它应该在你不需要查看函数的内容或变量的数据类型时就有意义。

这也意味着我们应该保持名称的可发音性。如果我们决定缩写,可以使用完整的单词或非常常见的缩写。

保持一致性

现在,最重要的提示是:在命名上保持一致性。这样,你可以依赖自己的命名风格,并对你编写的变量、函数和类做出假设。如果你违反了任何规则,至少要一致地违反,不要每次都做不同的事情。

公共和私有类成员

第四章中,我们了解到抽象封装面向对象编程的两个关键组成部分。这意味着类外的代码不需要担心该类如何得到结果。对于外界来说,它可能是魔法,甚至更糟,是手工劳动。

为了表示某个特定的变量或方法仅由类本身内部使用,GDScript 采用了在 Python 中流行的约定:在该变量或函数名称前加上下划线。

图 5.1 – 自动完成仍建议私有变量

图 5.1 – 自动完成仍建议私有变量

然而,如图 5**.1所示,你会注意到自动完成仍然建议私有类成员。指明哪些类成员是私有的且不应被访问仍然非常重要。这将帮助你或任何其他使用该类的程序员。

编写简短函数

函数尝试做的事情越多,该函数内部的代码就越多,理解它所做的事情就越困难。因此,为了使函数易于理解,一个很好的经验法则是将行数保持在 20 行以下。这样你可以快速理解正在发生的事情以及如何有效地使用该函数。

当然,你可以调用不同的函数。将长函数拆分成多个具有良好描述性的小函数,将节省你许多时间来弄清楚代码的功能。

DRY

几乎每个编程学生都会听说两个缩写。第一个是 DRY。这个缩写敦促我们只写一次代码,然后尽可能多地重用它。如果我们创建小的通用函数,我们就可以防止在整个代码库中复制粘贴相同的几行代码。

但我也应该警告你不要过度使用。有时,有一点点适合特定场景的重复代码是可以接受的,而不是将多个场景强行塞入一段代码中。使用你的最佳判断。

做一件事(KISS)

每个人都知道的第二个缩写是KISS,代表keep it simple, stupid。这可以有两种解释:

  • 将解决方案保持至最简,这意味着你不解决尚未存在的问题。这样,你不会开发不需要的功能,也不会浪费时间创建无人使用的东西。

  • 不要让你的代码复杂。复杂的代码众所周知难以维护和理解。这就是为什么最好保持任何解决方案简单,这样你总是知道正在发生什么。

简单的代码总是更容易阅读、理解和维护。所以,保持简单,傻瓜!

防御性编程

我想要展示的最后一个原则是防御性编程。在这个范例中,您通过尽可能检查代码中的各种事项和边缘情况来确保安全。例如,在一个函数中,您可以在函数开始时检查参数是否正确。这样,您将防止在长期运行中发生很多崩溃。

例如,如果您有一个函数应该返回库存中某个索引处的项目,您可以非防御性地和防御性地这样编写:

func get_inventory_item(index: int):
   return inventory[index]
func  get_inventory_item (index: int):
   if index < 0 or index >= inventory.size():
      return
   return inventory[index]

函数的第二版是防御性的,因为它首先检查我们想要的项目索引是否在库存的范围之内。我们这样做是因为如果索引超出这个范围,游戏就会崩溃。

编程风格指南

最后,我想介绍一下编程风格指南是什么。这些指南会告诉您如何组织您的代码。这些指南从不涉及代码的内容,而是更多地关于如何进行样式化。

您可以将这些指南与本书的风格进行比较。我可以将所有句子放在一行中,不进行样式化、标题或图像。但最终,这会使内容非常难以理解。

除了使代码更易于阅读之外,这些风格指南还使整个团队的编码风格保持一致,因此每个人的代码看起来更相似,人们在尝试理解项目的代码库时不需要在不同编码风格之间切换。

大多数公司都有自己的内部风格指南,是的,确实有一个官方的 GDScript 风格指南!您可以在这里阅读:docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html

我不推荐您一次性阅读整个指南并尝试一次性应用所有内容。相反,您可以在这里和那里阅读一些片段,一旦您掌握了这些指南,再阅读更多,并尝试将其融入您自己的编码风格中。

不要误解。即使有这些风格指南,在编码时仍然可以保留个人风格。这些指南将只是在一个框架内,使您的代码更美观,更容易被使用相同编程语言和框架的其他程序员理解。

在本章的剩余部分,我想回顾一下官方 GDScript 风格指南中的一些提示。特别是以下几点:

  • 空白

  • 空行

  • 行长度

因此,让我们直接深入到 Godot 引擎开发者自己的这些风格建议中。

空白

除了我们在第二章中讨论的缩进之外,GDScript 不关心代码行内的空白。下面两行在功能上是相同的:

var total_damage:float=100+get_damage()*0.5
var total_damage: float = 100 + get_damage() * 0.5

然而,第二行对人类来说更易读,因为每个部分都有足够的空间呼吸。这就是为什么始终在数字、函数调用和运算符之间使用空格是至关重要的。这样,一行代码就不会仅仅变成字符的混乱。

在数组中,我们还想在元素之间添加空格,以清楚地显示每个元素都是一个单独的实体:

inventory = ["Boots","Sword","Potion"]
inventory = ["Boots", "Sword", "Potion"]

反过来也可能成立。插入不必要的空格可能会掩盖某些运算符,例如,访问字典中的键:

dictionary ["key"] = 100
dictionary["key"] = 100

在前面的例子中,很明显方括号是用来从字典中访问键,而不是用来定义数组。

在以下两个例子中,没有空格显示了元素之间的明确关系:函数名及其参数列表:

print ("Hello")
print("Hello")

此外,我们还可以在访问对象中的成员变量或函数时看到这一点:

object . function()
object.function()

通常,在代码行中使用空白空间来显示事物是分离的实体还是属于一起,这一点非常重要。这将极大地提高可读性。

空白行

我们还可以使用另一种空白空间来使代码更易读,那就是空白行。空白行简单地就是一行什么都没有的行。风格指南建议使用两个空白行来分隔函数和类定义。这样,就可以清楚地知道哪些文本属于同一个函数:

func deal_damage(amount: float) -> void:
   player_health -= amount
func heal(amount: float) -> void:
   player_health += amount

除了用两个空白行分隔函数和类之外,指南还建议我们使用一个空白行来分隔逻辑上分组的一行代码。例如,如果我们有计算攻击造成的损害并将其应用到所有敌人身上的代码,我们可以将这个逻辑很好地组合如下:

  • 确定损害

  • 计算总损害

  • 应用到所有敌人的损害

我们可以在以下代码中看到这一点:

var weapon_damage: float = 10
var damage_type: String = "Fire"
var total_damage: float = weapon_damage
if damage_type == "Electricity":
   total_damage *= 2
for enemy in enemies:
   enemy.deal_damage(total_damage)

你会在本书的各个地方看到我违反空白行指南规则。我这样做主要是为了让代码更紧凑,以便适应页面,但这并不意味着规则不那么重要!

行长度

在早期,计算机显示器很小。它们通常在一行文本滚动到末尾或换行之前,无法容纳 70 到 90 个字符。这就是为什么代码最好写成最多这个长度的行。如今,我的超宽计算机显示器可以一行显示超过 500 个字符而不会出现问题。好吧,至少不是技术问题。处理这么宽的文本对人类来说非常难以阅读!

这就是为什么人们在编程时仍然会限制行长度,以保持一切整洁且易于阅读。当然,并不是每个人都同意完美的行长度,GDScript 的默认值是 80 个字符作为软限制,100个字符作为硬限制。

建议将您的行长度控制在以下数量。如果您确实遇到了这种情况,您可以通过将中间结果存储在单独的变量中来始终细分您的行。例如,下面的代码片段检查玩家的健康值是否在 0100 之间,以及玩家是否在其存货中有药水:

if player_health > 0 and player_health < 100 and inventory.has("Potion"):
   # Take potion

从技术上讲,这并不算太长,但为了展示我们如何可以缩短行长度,甚至使 if 语句中的条件更加易读,让我们将此代码片段重写如下:

var can_heal: bool = player_health > 0 and player_health < 100
var has_potion: bool = inventory.has("Potion")
if can_heal and has_potion:
   # Take potion

如您所见,现在没有多余的行,if 语句的易读性也大大提高。

利用文档

有时,很难预测 Godot 引擎的内置类、函数或变量的工作方式。幸运的是,该引擎拥有详尽的文档,详细解释了所有内容。

访问类的文档

我们可以通过简单地使用 Ctrl + 点击类的名称来访问任何内部类的文档。这将带您到该类的特定文档页面。

图 5.2 – 在内部类名上 Ctrl + 点击,例如 Array 类

图 5.2 – 在内部类名上 Ctrl + 点击,例如 Array 类

图 5.3 所示,文档页面以对类用途的简单描述开始,有时这部分会给出使用示例。

图 5.3 – Array 类的文档页面

图 5.3 – Array 类的文档页面

然后是类的成员函数、变量、信号和运算符的概述。我们将在 第九章 中了解更多关于信号的内容。请注意,您可以轻松地点击函数或变量名称直接转到它们的解释。

重要提示

请记住,函数也可以称为方法,变量也可以称为属性。这是因为绑定到类的函数被称为方法,绑定到类的变量被称为属性。

在概述部分之后,对每个函数和变量都有详细的描述。对于函数,我们得到关于函数做什么,函数接受哪些参数,以及返回什么数据类型的解释。

图 5.4 – 函数的文档部分

图 5.4 – 函数的文档部分

对于变量,我们也会得到关于这个变量用途和值的数据类型的描述。

图 5.5 – 变量的文档部分

图 5.5 – 变量的文档部分

如果我们想要对类的功能有一个大致的了解,这种方式访问文档非常有效。

直接访问函数或变量的文档

要直接转到函数或变量的文档,您只需按 Ctrl + 点击该函数或变量,您就会直接转到相关部分。

图 5.6 – 按住 Ctrl + 点击函数或变量将直接带您到文档中的正确部分

图 5.6 – 按住 Ctrl + 点击函数或变量将直接带您到文档中的正确部分

点击链接后,我们直接进入 图 5**.4 的部分。

跳转到函数或变量的定义

这个快捷键也适用于我们自己的代码:如果您在 Windows 或 Linux 上按住 Ctrl + 点击或在 Mac 上按 option + 点击,那么编辑器将显示我们定义的函数或变量在代码中的位置。

作为实验,尝试使用这个快捷键在我们在 第四章 中创建的 Enemy 类定义的不同函数上。

搜索文档

您也可以搜索所有类、函数和变量。只需按照以下步骤操作:

  1. F1 键,搜索栏将弹出。

  2. 输入您想要搜索的任何类、函数或变量。

  3. 选择正确的搜索结果。

结果显示在 图 5**.6 中:

图 5.7 – 您也可以通过按键盘上的 F11 键搜索所有文档

图 5.7 – 您也可以通过按键盘上的 F11 键搜索所有文档

如果您无法在 Windows 和 Linux 上按住 Ctrl + 点击 或在 Mac 上按 Option + 点击,那么这个快捷键可以方便地找到文档中的正确部分,尤其是在类、函数或变量内部。

访问在线文档

所有这些文档也都在线托管。在线版本中有些页面和教程在离线版本中无法访问。它还更容易打开在线版本中的多个文档页面。

只需导航到:docs.godotengine.org/

图 5.8 – 在线文档

图 5.8 – 在线文档

在左侧菜单栏中,您可以查看所有可用的不同文章,并导航到您感兴趣的部分。

摘要

好了!在本书 第一部分 中,我们学习了如何编程,并在本章中提供了一些成为优秀程序员的额外技巧。记住,本章中的所有建议都不是一成不变的,也不会被引擎强制执行。因此,您可以在需要的地方打破它们。但它们在这里是为了您的利益,许多程序员已经将它们作为日常实践。

在下一章,也就是本书下一部分的开始,我们终于开始着手制作我们的游戏了!我希望您和我一样兴奋!

测验时间

  • 在 Godot 引擎和 GDScript 中使用了哪三种命名约定?

  • 这些函数的命名是否得当?

    • CalculateLifePoints()

    • stop_moving()

    • do_a_thing()

    • drawcircles()

  • 以下类的命名是否得当?

    • Player

    • normal_enemy

    • MOTORCYCLE

  • DRY 和 KISS 这两个缩写分别代表什么?

第二部分:在 Godot 引擎中制作游戏

在掌握编程基础之后,我们最终将从零开始制作我们自己的游戏。在本部分,我们将了解 Godot 引擎灵活的基于节点的系统,并创建一个类似 Vampire Survivors 的游戏。

到本部分结束时,你将使用不同的节点和游戏开发技术创建一个完整的游戏。你甚至可以和你的朋友们一起玩这个游戏,因为我们将以一个关于制作多人游戏章节结束本部分。

本部分包含以下章节:

  • 第六章在 Godot 中创建自己的世界

  • 第七章制作角色移动

  • 第八章分割和重用场景

  • 第九章摄像机、碰撞和可收集物品

  • 第十章创建菜单、制作敌人和使用自动加载

  • 第十一章多人联机游戏

第五章:在 Godot 中创建自己的世界

在本书的第一部分中,你学习了编程的基础!如果问我,这可不是一件小事情。所以,恭喜你达到这个里程碑!现在,是时候将所有这些知识串联起来,开始制作我们的游戏了。

在游戏开发的早期,所有的事情都是通过代码完成的。计算机巫师必须编写所有代码,从系统、功能到关卡和资产放置。近年来,创建游戏的工具已经变得非常好用,而且是免费的,非常用户友好。

Godot,像大多数现代游戏引擎(Unity、Unreal Engine、Construct 等)一样,有一个图形界面,这使得我们可以轻松地将游戏元素拖放到关卡或其他场景中。在本章中,我们将通过创建一个基本的玩家角色和它们的小世界来学习如何使用这个图形界面。

我们还将学习一些技巧,将代码和图形编辑器通过节点引用和变量导出结合起来。

本章将涵盖以下主要内容:

  • Godot 的基于节点的系统

  • 创建玩家角色

  • 在脚本中引用节点

  • 导出变量

  • 制作基本的形状

技术要求

由于我们将从头开始创建游戏,我自作主张地为你提供了一个项目的基础。你可以在本章的文件夹下的/start中找到这个基础项目。该项目提供了一些资产,如图片和声音。创建这些资产超出了本书的范围。本章的结果项目文件可以在本章文件夹的/result下找到。

在随后的章节中,你将在该章节的root文件夹中找到最终的项目。假设你使用上一章的结果作为起点。

因此,获取起始项目,让我们深入探讨:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter06/start

游戏设计

在盲目地创建游戏之前,让我们规划一下我们想要制作的游戏类型。这将结构化我们的思想,并确保我们朝着我们想要制作的游戏前进,而不会走不必要的弯路。最好的方式是通过游戏设计文档GDD)。尽管这种文档没有固定的格式,但它最终应该回答一些关于游戏的基本问题:

  • 游戏属于哪个类型?

  • 游戏将包含哪些机制?

  • 故事是什么?

一些游戏设计文档长达数百页。但鉴于这不是一本游戏设计书籍,让我们就以下三个问题来定义我们的游戏,然后在我们前进的过程中详细规划。

类型

最近,我们见证了名为 吸血鬼生存类 的新类型的诞生,也称为 VS 游戏。在这种类型的游戏中,你控制一个角色在 2D 俯视世界中。这个角色必须通过射击来击败追击他们的怪物波次。玩家可以通过移动角色来控制角色,但射击是自动发生的。它不需要输入。

这种类型的游戏拥有庞大的玩家基础,基础游戏相对简单易实现,同时玩起来也令人满意。因此,它将是以下章节中重新创建的理想游戏类型。

机制

生存类游戏有一些基本的机制,这些机制非常重要,需要正确实现:

  • 2D 世界:游戏场是一个 2D 平面,我们在其中有一个俯视视角。其中一些确实是 3D 的,但机制在 2D 中仍然很出色。

  • 角色的移动:我们需要能够在世界中移动角色。

  • 敌人波次:我们需要威胁到玩家生命的敌人,并且我们需要生成它们,以便它们构成适当的挑战。

  • 自动射击:玩家角色将自动射击针对敌人的投射物。

现在我们已经整理了游戏类型和基本机制,让我们来制定我们的游戏将基于的故事。

故事

我们不要在编写整个故事上给自己太多压力。在游戏中,故事也可以通过游戏的外观和感觉来讲述。因此,我们可以指定一个将整个体验联系在一起的一般设置。

我们的设定如何:你是一位中世纪骑士,在国王的锦标赛中战斗,以寻找整个土地上最强的士兵。你将不得不在多轮比赛中与多个敌人战斗,如兽人和巨魔,每一轮都比上一轮更难。你唯一得到的武器是一把弓,你可以用它向你的对手射箭。

现在我们对我们正在创建的游戏类型有了想法,让我们开始吧!

创建玩家角色

我们将首先为我们的游戏创建一个基本的玩家角色:

  1. 打开我在项目基础上提供的 main.tscn 文件。

  2. 选择名为 Mainroot 节点,然后按 添加子节点 Node 按钮:

图 6.1 – 在树中为所选节点添加新子节点的按钮

图 6.1 – 在树中为所选节点添加新子节点的按钮

  1. 然后,查找并添加一个 Node2D 节点。您可以使用顶部的搜索栏来简化节点的搜索。这是一个在 2D 空间中具有位置的节点:

图 6.2 – 查找和选择 Node2D 节点

图 6.2 – 查找和选择 Node2D 节点

  1. 接下来,通过右键单击节点并选择 重命名,将此 Node2D 重命名为 Player,就像我们在 第二章 中做的那样。

Player 将是我们玩家角色的基础节点。从这里,我们将添加构成 Player 节点的所有其他节点。这些节点中的第一个将是一个精灵。

添加精灵

我们可以做的第一件事是为我们的玩家角色添加一个视觉元素,一些玩家可以将其与主要角色联系起来的东西。再次按照 步骤 13创建玩家角色 部分中操作,将一个名为 Sprite2D 的节点添加到 Player 节点中,以便场景树看起来像这样:

图 6.3 – 到目前为止的场景树

图 6.3 – 到目前为止的场景树

Sprite2D 节点是一个可以显示图像的节点,也称为 Sprite2D,你将看到右侧的 检查器 视图会填充有关该节点的信息:

图 6.4 – Sprite2D 节点的检查器视图

图 6.4 – Sprite2D 节点的检查器视图

纹理偏移动画区域 等设置。你可以浏览它们,以了解所有可用的设置。不同的选项卡是 属性组,而设置本身被称为 属性

我们只对 纹理 属性感兴趣,因为这是我们可以设置该节点显示的图像的地方。所以,让我们为我们的角色添加一个精灵!

  1. assets/sprites/character

    这里,你可以找到一些预制的角色精灵。

Kenney 资产

本书使用的所有资产都来自 Kenney,并且可以在你想要的任何项目中免费使用。你可以在 kenney.nl/ 找到他更多优秀的资产。

  1. 将它们中的任何一个拖放到 Sprite2D 节点上。我正在使用 character01.png 纹理。

  2. Sprite2D 节点现在应该看起来像这样:

图 6.5 – 向精灵节点添加纹理

图 6.5 – 向精灵节点添加纹理

精灵也应该出现在编辑器的 2D 视图中。然而,它看起来非常小。这是因为图像的大小只有 16 × 16 像素。让我们稍微放大一下。在 3 之下。你可以分别设置 X 轴和 Y 轴的缩放比例,但我们希望它们都相等,这样精灵在缩放时不会拉伸:

图 6.6 – 精灵节点的变换属性

图 6.6 – 精灵节点的变换属性

哦不——这是怎么回事?

图 6.7 – 一个模糊的像素艺术精灵

图 6.7 – 一个模糊的像素艺术精灵

这个精灵看起来模糊!这是因为我们正在使用 像素艺术 资产,这种风格以其方块像素而闻名。当放大时,Godot 引擎使用一个算法来模糊这些像素。这对于其他艺术风格,如手绘或矢量艺术,非常棒,但对于像素艺术来说则不然。幸运的是,有一个解决方案。按照以下步骤操作:

  1. 导航到 项目 | 项目设置...

图 6.8 – 前往项目设置...

图 6.8 – 前往项目设置...

  1. 渲染 | 纹理 下,将 默认纹理过滤器 设置为 最近

图 6.9 – 将默认纹理过滤器设置为最近

图 6.9 – 设置默认纹理过滤器为最近邻

这些设置将以更适合像素艺术的方式缩放图像。现在,我们的精灵看起来好多了!

图 6.10 – 清晰的像素艺术精灵

图 6.10 – 清晰的像素艺术精灵

现在我们可以看到我们的玩家,让我们看看如何显示健康 UI。

显示健康信息

接下来,让我们添加一些东西来显示玩家健康信息在角色上方。当然,我们还没有为玩家创建跟踪健康的脚本,但我们可以放置视觉效果。我们将使用 Label 节点,它可以在游戏中显示文本:

  1. Player 节点中查找并添加一个 Label 节点。

  2. 将节点命名为 HealthLabel

图 6.11 - 添加了 HealthLabel 节点的场景树

图 6.11 - 添加了 HealthLabel 节点的场景树

  1. 当选择 Label 节点时,将其中的 10/10 放入其中,就像玩家有 10 条生命中的 10 条一样:

图 6.12 – 文本设置为 10/10 的 Label 节点的检查器视图

图 6.12 – 文本设置为 10/10 的 Label 节点的检查器视图

  1. 接下来,将标签拖到玩家上方,使其远离精灵:

图 6.13 – 将 HealthLabel 节点重新定位在玩家角色上方

图 6.13 – 将 HealthLabel 节点重新定位在玩家角色上方

太好了!在 HealthLabel 标签就位后,我们可以在稍后通过脚本更新它(见 创建玩家脚本 部分)。这就是我们在场景树中设置节点所需的所有内容。

现在,让我们看看我们如何可以操作我们添加的节点。

在编辑器中操作节点

现在我们已经建立了一个小场景树,让我们看看我们有哪些工具可以用来操作节点。如果你看看 2D 编辑器的右上角,你会看到一些这些工具:

图 6.14 – 2D 编辑器视图中的工具栏

图 6.14 – 2D 编辑器视图中的工具栏

工具栏中有很多有趣的工具,但就目前而言,前四个是最重要的:

  • 选择模式:这是默认模式,是一个多工具。您可以在场景中选择节点并将它们拖动。

  • 移动模式:在此模式下,您可以移动选定的节点。

  • 旋转模式:在此模式下,您可以旋转选定的节点。

  • 缩放模式:在此模式下,您可以缩放选定的节点。

通过选择 Player 节点并进行一些操作来尝试这些模式。这可能会导致以下结果:

图 6.15 – 多次变换操作后的 Player 节点

图 6.15 – 多次变换操作后的 Player 节点

你还会注意到,当你移动、旋转或缩放一个节点时,其子节点将以相同的方式被操作。这种变换的继承是层次节点系统的优势。

如果你查看 Player 节点,你会看到你对其所做的确切修改。如果你更改这些值中的任何一个,你也会在 2D 编辑器中看到它们的变化:

图 6.16 – 多次变换操作后的变换参数

图 6.16 – 多次变换操作后的变换参数

作为实验,尝试从 检查器 视图中更改 Skew 的值。

在继续以下部分之前,别忘了将 Player 节点中的所有这些操作重置。你可以通过简单地按每个属性旁边的 ↺ 符号来完成此操作。此按钮将属性重置为其默认值。让我们也将 Player 节点的位置设置为玩家角色大致位于屏幕中心:

图 6.17 – 将玩家角色定位在屏幕中间

图 6.17 – 将玩家角色定位在屏幕中间

到此为止,我们已经完成了玩家角色的基础创建,并学习了如何在编辑器中操作节点。接下来,我们将关注玩家角色的脚本,并学习如何通过代码操作节点。

创建玩家脚本

这是我们一直在训练的时刻。我们已经知道如何做到这一点!所以,首先创建一个新的脚本,并将其附加到 Player 节点:

  1. 右键点击 Player 节点并选择 附加脚本

图 6.18 – 将脚本附加到 Player 节点

图 6.18 – 将脚本附加到 Player 节点

  1. 在弹出的对话框中,将脚本命名为 player.gd

图 6.19 – 调用脚本 player.gd

图 6.19 – 调用脚本 player.gd

  1. 我们现在保持简单,只添加一些代码来管理玩家的健康:

    extends Node2D
    const MAX_HEALTH: int = 10
    var health: int = 10
    func add__health_points(difference: int):
       health += difference
       health = clamp(health, 0, MAX_HEALTH)
    

    我们在 add_health_points() 函数中使用的 clamp() 函数将一个数值作为第一个参数,并将其保持在第二个和第三个数值参数之间。

这样,健康值始终在 0MAX_HEALTH 之间,最大值为 10

重要提示

记住,在 Windows 和 Linux 上,你可以 Ctrl 和点击任何函数以转到文档并查看其功能;在 Mac 上,你可以 Option 和点击。

这样一来,我们就可以更改玩家的健康值了。现在,让我们看看如何更新我们之前创建的 HealthLabel 节点以反映这个值。

在脚本中引用节点

我们希望根据玩家剩余的健康量更新玩家角色的 HealthLabel 节点。为了在脚本中更改场景中的节点,我们需要能够引用它们。幸运的是,在 Godot 4 中这相当简单。

获取节点引用有多种方法,但最简单的是美元符号表示法。这种表示法看起来是这样的:

$HealthLabel

该表示法以美元符号 ($) 开头,后跟通过场景树到我们想要引用的节点的路径。在这里,我们引用了之前创建的健康标签。

注意,此路径相对于提及此路径的脚本所在的节点。因此,如果主节点有一个脚本,我们想要引用玩家的健康标签,表示法将如下所示:

$Player/HealthLabel

因此,既然我们已经知道了如何获取节点的引用,让我们创建一个小的函数来更新玩家的健康标签,并在add_health_points()函数中调用它:

func update_health_label():
   update_health_label() function, we take the HealthLabel node and directly change its text variable. This will change whatever text the label is showing on the screen.
			Here, we use a new function named `str()` in `update_health_label()`. This function takes any parameter and converts it into a string. We need to do this because the `health` and `MAX_HEALTH` values, which are integers, we’ll have to convert them into a string.
			Now, we can use this `update_health_label()` function whenever we change the `health` value:

func add_health_points(difference: int):

health += difference

health = clamp(health, 0, MAX_HEALTH)

HealthLabel 节点正在显示。但有一种更好的方法来访问或引用场景树中的节点:通过缓存它们。我们将在下一节中查看这一点。

        缓存节点引用

        虽然美元符号非常方便,但有时你需要经常访问某个节点。在这种情况下,使用美元符号将会很慢,因为引擎将不得不在树中不断搜索节点,并在每次访问时都这样做。

        缓存

        在计算机术语中,缓存意味着为了以后使用而存储某些数据,这样你就不必每次需要时都加载它。

        为了每次停止搜索节点,我们可以在变量中保存节点的引用。例如,我们可以这样更改玩家脚本:
extends Node2D
@onready var _health_label: Label = $HealthLabel
func update_health_label():
   HealthLabel node in a variable called _health_label. Later on, we can use this reference.
			The upside is, of course, that we only have to change the path to the node at one point: the line where the reference gets stored in a variable. Another upside is that we can type-hint the variable with the type of the node. So, we are making it even safer than the previous way of referencing the node.
			You’ll also notice that I use the `@onready` annotation. We call commands that start with an `@` annotation, like the one shown previously. This annotation executes that line of code when the node is ready and has entered the scene tree. This is right before the `_ready()` function of that node is called. In Godot, the `_ready()` function of each node gets called after each of its children are ready, meaning that their `_ready()` functions get called before the parent node’s `_ready()` function. We need to wait for this moment to get any nodes in the tree because otherwise, there is a possibility for them not to exist yet!
			Annotations
			There are more annotations. We’ll return to them when they are applicable. But it’s already good to know that all of these annotations affect how external tools will treat the script and don’t change any logic within the script itself.
			I advise that you always cache variables as described here because it will keep your code clean and fast.
			Trying out the player script
			To try out what we have created so far, we can run a quick test by adding the `_ready()` function to the player script:

func _ready():

add_health_points(-2)


			Now, when you run the scene, you should see that the health label says **8/10**, like so:
			![Figure 6.20 – The player’s health label has been updated to 8/10](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_20.jpg)

			Figure 6.20 – The player’s health label has been updated to 8/10
			After testing the script, remove the preceding lines again so that they don’t interfere with the rest of our coding.
			In this section, we learned how to reference nodes from the scene tree within our code and how to update the values of these nodes. We also set up a basic script for tracking the health of our player. In the following section, we’ll learn about exporting variables.
			Exporting variables to the editor
			We have always defined variables within code and every time we wanted to change them, we had to change the code too. But in Godot, it is straightforward to expose variables to the editor so that we can change them without even opening the code editor. This is extremely useful when you want to test things out and tweak variables on the fly. An exported variable pops up in the **Inspector** view of that node, just like the transformation and text properties we saw in the *Manipulating nodes in the* *editor* section.
			Important note
			An exported variable is also useful for people who don’t know how to code, such as level designers, but still want to change the behavior of specific nodes.
			To export a variable to the editor, we can use the `@export` annotation. Let’s change the line where we define the `health` variable, like so:

@export var health: int = 10


			Make sure you save the script. Go to the 2D editor using the button at the top of the editor.
			![Figure 6.21 – Click 2D to go back to the 2D editor](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_21.jpg)

			Figure 6.21 – Click 2D to go back to the 2D editor
			Click on our `Player` node, and see the `health` variable in the **Inspector** view. This is our exported variable. Changing it will change the variable’s value at the start of the game, not directly in the script itself:
			![Figure 6.22 – The health variable as an exported variable in the Inspector view of the Player node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_22.jpg)

			Figure 6.22 – The health variable as an exported variable in the Inspector view of the Player node
			Now, when you change the player’s health value through the `health` label in the `_ready()` function, like so:

func _ready():

update_health_label()


			This will ensure that the health label is updated from the moment the `Player` node enters the scene tree.
			More information
			If you want to learn more about export variables, you can check out the official documentation: [`docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html`](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html).
			Now, we start the game with the correct amount of health displayed on the health label. But there is a better way of updating this health label: using setters and getters.
			Setters and getters
			When you change the player’s health value through the `_ready()` function, like so:

func _ready():

print(health)


			That is because the `update_health_label()` function is not being called when we change the value!
			Luckily, we can fix this. In programming, **getter** and **setter** functions exist. These functions are called when you get or set the value of a variable. With these getter or setter functions, we can execute all the logic needed to handle a new value. We can define a getter and setter for our health variable like so:

@export var health: int = 10:

get:

return health

设置(new_value):

health = clamp(new_value, 0, MAX_HEALTH)

update_health_label()


			So, the getter is defined by `get:`, followed by the code block that defines the getter logic, and the setter by `set(new_value):`, followed by its code block. `new_value` is the new value that is assigned to the variable. Within the setter, we get the opportunity to process this value if needed or set other processes in motion. In our case, we don’t want to process the new value, but we do want to update the health label.
			The getter does nothing special – it just returns the health value. On the other hand, the setter clamps the new value so that it is valid and then updates the health label.
			When we get or set the `health` value, the interpreter will execute these functions first. Here’s an example:

print(health) # 执行获取器

health = 100 # 执行设置器


			This also simplifies the `add_health_points()` function because we no longer have to clamp the new health value as this already gets done in the setter. So, let’s update the `add_health_points()` function to the following:

func add_health_points(difference: int):

health += difference


			But what is this? The project errors when we run it now!
			![Figure 6.23 – An error showing that the health label is non-existing](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_23.jpg)

			Figure 6.23 – An error showing that the health label is non-existing
			The setter function gets executed before the `_health_label` reference is created. So, we must make sure that the `_health_label` is filled in before we update its text. If it isn’t, we can just return from the function:

func update_health_label():

if not is_instance_valid(_health_label):

return

_health_label.text = str(health) + "/" + str(MAX_HEALTH)


			The `is_instance_valid()` function checks if the reference to a node is valid. It returns `true` if it is and `false` otherwise.
			Checking if a node reference exists
			Your first instinct might be to check if the reference to the node is not `null` by running `_health_label != null`. However, this does not guarantee that the node is available. When the node is deleted, for example, this check for `null` will still return `true` because the reference still exists within the variable. `is_instance_valid(_health_label)` will check more than just whether the variable is `null` – it will also make sure that the node still exists and is in use within the scene tree.
			At this point, the code for the player should look like this:

extends Node2D

const MAX_HEALTH: int = 10

@onready var _health_label: Label = $HealthLabel

@export var health: int = 10:

获取:

return health

set(new_value):

health = clamp(new_value, 0, MAX_HEALTH)

update_health_label()

func _ready():

update_health_label()

func update_health_label():

if not is_instance_valid(_health_label):

return

_health_label.text = str(health) + "/" + str(MAX_HEALTH)

func add_health_points(difference: int):

health += difference


			Setters and getters help us encapsulate behavior related to updating variables, as we saw in *Chapter 5*. It abstracts the logic behind what needs to happen when updating this variable so that the user of the class doesn’t have to worry about it.
			With this code set up, the health of our player can easily be updated using the regular or special assignment operators and the health label will update accordingly.
			Changing values while the game is running
			Another cool thing about these exported variables, now that we have a setter and a getter defined for them, is that we can change them while the game runs! So, if you run the game and change the `health` parameter in the **Inspector** view while it is running, you will see that change reflected in the health label instantaneously.
			This (mostly) works with all built-in parameters too! If you keep the game open and change the player’s **Transformation** parameters, for example, you’ll see them change in real time.
			This will be useful later on so that we don’t always have to re-launch the game when working on it.
			Different types of exported variables
			When exporting a variable that we type hinted, Godot will choose the right input field type for that type. It will have a numerical input field with up and down arrows for integers, while it will use a normal text input for strings, like so:

@export var health: int = 10

@export var damage: float = 0.0

@export var player_name: String = "Erika"


			These three lines will each export a variable to the editor, but each with a different data type: integer, floating-point number, and string, respectively. The result is that we get a different kind of input field for each of the variables:
			![Figure 6.24 – Different variable types that get exported](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_24.jpg)

			Figure 6.24 – Different variable types that get exported
			There are also other export annotations to be even more specific. One of those is the `@export_range` annotation, which specifies a number range the value should be in, like so:

@export_range(0, 10) health: int = 10


			In the preceding code excerpt, we export the `health` variable and specify that it should be a number between 0 and 10, including the outer values of 0 and 20\. When you try out this ranged export, you’ll see that you cannot input values that fall outside of this range.
			To make it more dynamic, we can use the `MAX_HEALTH` variable we defined earlier within the player’s script:

@export_range(0, MAX_HEALTH) health: int = 10


			Exporting variables is a very important technique to keep in our toolkit for tweaking variables and values when testing out the game. Now, let’s direct our attention to the arena and world the player will be walking around in.
			Creating a little world
			Now that we have a little player character, let’s create a world for them to inhabit! In this section, we’ll flesh out the arena in which the player has to battle challenging foes.
			Changing the background color
			Let’s start simple by changing the background color for our arena. We can easily do this from the project settings:

				1.  Navigate to **Rendering** | **Environment** in the project settings:

			![Figure 6.25 – Finding Default Clear Color under Rendering > Environment in the project settings](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_25.jpg)

			Figure 6.25 – Finding Default Clear Color under Rendering > Environment in the project settings

				1.  Set the `#e0bf7b` because it looks like sand or dried-up mud:

			![Figure 6.26 – Picking a color using the color selection tool](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_26.jpg)

			Figure 6.26 – Picking a color using the color selection tool
			With this nice color in place, let’s add some features, such as boulders and walls, to our arena.
			Adding Polygon2D boulders
			Now that we have a ground for the player character to stand on, let’s add some boulders that will serve as obstacles in the arena. To do this, we will be using the `Polygon2D` node. This node can draw any polygon shape on the screen in any color we want:

				1.  Add a `Arena` to the root node of our `Main` scene.
				2.  Now, drag the `Arena` node we just created above the `Player` node. This will ensure everything within the `Arena` node will be drawn beneath the `Player` node. See the *Node drawing order* section to learn more about this.
				3.  We will put all our arena elements, such as boulders and walls, into this node. This way, we’ll keep the tree structure nice and tidy.
				4.  Now, add a `Polygon2D` node under the `Arena` node and call it `Boulder`:

			![Figure 6.27 – The scene tree with the Arena node and a Boulder node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_27.jpg)

			Figure 6.27 – The scene tree with the Arena node and a Boulder node

				1.  You can add points to the polygon by left-clicking anywhere on the screen while the `Boulder` node is selected. Right-clicking will remove a point. You can also drag earlier placed points around. Place some points and close the shape by clicking on the first point you put down:

			![Figure 6.28 – Drawing a boulder using a Polygon2D node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_28.jpg)

			Figure 6.28 – Drawing a boulder using a Polygon2D node

				1.  Set the `Boulder` node’s color property to something that resembles a stone. I chose `#504f51`.

			These boulders might look simple, but they will serve our purpose.
			Node drawing order
			So, why did we drag the `Arena` node above the `Player` node? By default, the nodes get drawn in the order they’re in within the tree. The nodes closest to their parents get drawn first and the ones further away from the parent node within the tree structure are drawn on top of the ones below.
			There are ways to circumvent this, but that’s out of the scope of this book. So, for now, we must structure our node tree correctly:
			![Figure 6.29 – Nodes get drawn in the order they are in within the scene tree](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_29.jpg)

			Figure 6.29 – Nodes get drawn in the order they are in within the scene tree
			A well-structured tree will draw all nodes in the exact order we want them to be.
			Creating an outer wall
			For the outer wall of the arena, we’ll use a `Polygon2D` node again, but in a different way this time:

				1.  Add a `Polygon2D` node under the `Arena` node and call it `OuterWall`.
				2.  Draw a rough rectangle that will be the inside of the arena. It’s okay if this rectangle is not perfect. This will make the arena look extra medieval:

			![Figure 6.30 – Drawing an arena outer wall using a Polygon2D node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_30.jpg)

			Figure 6.30 – Drawing an arena outer wall using a Polygon2D node

				1.  Now, with `OuterWall` selected, find and enable the **Invert** parameter in the **Inspector** view. This option inverts the shape and makes it look like the outer walls of the arena:

			![Figure 6.31 – Inverting the shape of a Polygon2D node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_31.jpg)

			Figure 6.31 – Inverting the shape of a Polygon2D node

				1.  Set the `1000px`, so that the walls expand very far.
				2.  Give the wall a fitting color. I chose `#2d2c2e`, which is a little darker than the boulders, so that the player sees the difference:

			![Figure 6.32 – The resulting arena](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_32.jpg)

			Figure 6.32 – The resulting arena
			Inverting a polygon makes it very easy to create the internals of an arena or room. The natural next step is to let our imagination loose and make a visually nice arena.
			Getting creative
			With these simple tools, get creative and create some interesting terrain to serve as an arena.
			For instance, you can add some more boulders to your arena. You can do this by creating an entirely new `Polygon2D` node or by duplicating your earlier boulder and altering them a bit by dragging points around and using the **Transform** tools we learned about.
			You can also add more walls and change the outer boundaries of the arena some more.
			I came up with this arena:
			![Figure 6.33 – My arena after spending some more time refining it](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_06_33.jpg)

			Figure 6.33 – My arena after spending some more time refining it
			Now that we can create our little worlds and arenas using colored rectangles and polygons, we have everything to create the basic visual structure of our game. Next, we’ll do some extra exercises and summarize this chapter.
			Additional exercises – Sharpening the axe

				1.  Start by making more boulders and walls based on what we learned in the previous section so that you can populate your arena.
				2.  Based on what you learned about the `health` value of the player, add an exported variable that tracks the number of coins the player has, called `number_of_coins`, to the player script.
				3.  Add a setter and getter for the `number_of_coins` variable.
				4.  Lastly, make a label that shows the coins above the player’s head. Make sure that everything gets handled and updated correctly so that we can update the variable from the editor and the code while the label always stays up to date when running the game.

			Summary
			In this chapter, we created our first real scene. We saw how different nodes do different things for us and we extended **Node2D** with a script that will manage the player’s health. We also created the area in which all the action will take place.
			In the next chapter, we’ll make it possible for the player to move around and we’ll also refresh our vector math. Don’t worry – it won’t be painful, but a bit of math will be useful.
			Quiz time

				*   Why did we start by making a Game Design Document (GDD) instead of jumping right into creating the game?
				*   How do you reference nodes within a script?
				*   What keyword can we use to make a variable, such as the amount of health, available in the **Inspector** view?
				*   What are setter and getter functions used for?


第六章:分割和重用场景

有可能在一个 Godot 场景中创建整个游戏,但这可能会变得相当难以管理。我们不仅必须一次又一次地重新创建每个部分,例如每个巨石或敌人,而且如果我们想改变关于岩石的某些内容,我们必须去找每个岩石来更改它们。

这对于任何类型的游戏来说都不具可扩展性。幸运的是,在 Godot 中,有像场景这样的东西。在第二章中,我们看到了如何从头创建新场景,但在这章中,我们将学习如何为每个元素创建一个场景,这样我们就可以在整个游戏中轻松重用它。这样,我们可以为岩石创建一个场景,并使用它来填充竞技场,而不是使用多个独特的岩石。

除了重用组件之外,单独对游戏中的某些部分进行工作也比有一个大场景要容易得多。以这种方式保存游戏的部分将使我们专注于我们正在工作的内容。

其他游戏引擎也有非常类似的功能。Unity 有预制体,Unreal Engine 有蓝图类,等等。Godot 场景的伟大之处在于,一旦它们在场景树中实例化,它们的行为就像任何其他节点一样。

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

  • 将分支保存为新的场景

  • 使用保存的场景

  • 在项目中组织场景

技术要求

就像每一章一样,你可以在本书的 GitHub 仓库中找到本章的最终代码,位于该章节的子文件夹中:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter08

将分支保存为新的场景

第二章的[创建新场景]部分,我们学习了如何为不同的代码实验创建新场景。这个过程可以用来创建任何场景。但另一种方法是保存现有场景树的一部分。我们将把场景树的一个分支拆分成一个独立的子场景,我们可以在任何地方重用它。

创建独立的玩家场景

让我们将Player节点保存为一个独立的场景,这样我们就可以单独对其工作。前往我们的Main场景,按照以下步骤操作:

  1. 右键点击Player节点。

  2. 从弹出菜单中选择保存分支 为场景

图 8.1 – 选择将分支保存为场景以将节点保存为独立场景

图 8.1 – 选择将分支保存为场景以将节点保存为独立场景

  1. 现在,我们必须选择新场景的位置和名称。保持一切不变应该就可以了:

图 8.2 – 将场景保存为适当的名称

图 8.2 – 将场景保存为适当的名称

  1. 新的场景现在将打开,并且只包含Player节点及其子节点:

图 8.3 – 只包含玩家节点及其子节点的 player.tscn 场景

图 8.3 – 只包含玩家节点及其子节点的 player.tscn 场景

  1. 将“玩家”节点的位置重置为场景内的(0, 0)

图 8.4 – 将根玩家节点的位置重置为(0, 0)

图 8.4 – 将根玩家节点的位置重置为(0, 0)

如果你回到主场景,如图图 8**.3所示,你会看到原本有一些子节点位于其下的“玩家”节点被一个名为“玩家”的单个节点所取代。这个节点现在代表玩家场景内的所有内容。在 2D 编辑器中,从视觉上看没有任何变化;玩家仍然完整地存在,带有其Sprite2DHealth标签:

图 8.5 – 玩家节点及其子节点被替换为单个节点

图 8.5 – 玩家节点及其子节点被替换为单个节点

如果你运行游戏,什么都不会改变,因为一切都没有改变。我们只是将“玩家”节点拆分成了自己的场景文件。你可以通过进入远程树并确认当游戏开始运行时玩家节点会展开到所有部分来检查这一点:

图 8.6 – 当运行游戏时,玩家节点扩展到在远程树中包含所有子节点

图 8.6 – 当运行游戏时,玩家节点扩展到在远程树中包含所有子节点

在“玩家”节点中也有一个新按钮可用。按下此按钮将直接带我们到“玩家”场景。这对于我们稍后需要处理许多不同场景和节点来说非常方便:

图 8.7 - 将我们直接带到玩家场景的新按钮

图 8.7 - 将我们直接带到玩家场景的新按钮

现在,玩家有了他们自己的场景,我们可以在其中工作,而无需处理整个游戏中的所有内容。

场景的根节点

你还会看到玩家场景的根节点是名为PlayerCharacterBody2D节点,这是我们选择用于它的第七章。场景可以有任意类型的节点作为其根节点。你可以在创建场景时选择此类型,就像我们在第二章中做的那样,或者稍后通过更改节点类型来实现,就像我们在第七章中对“玩家”节点所做的那样。

使用单独的场景文件,我们可以在这个场景内部创建该场景的多个实例。我们将在下一节中看到如何做到这一点。

使用保存的场景

由于我们将在游戏中只使用一个玩家,所以我们不会多次重用玩家场景。然而,我们希望重用竞技场内的岩石和墙壁。按照将分支保存为新场景部分中的步骤,将一个巨石分离到一个新场景中:

图 8.8 – boulder.tscn 场景

图 8.8 – boulder.tscn 场景

现在,让我们将这个新场景在我们的竞技场中作为默认岩石重用:

  1. 返回主场景。从场景中移除所有岩石;我们不再需要它们了。

  2. 选择Arena节点。通过这样做,我们添加的所有内容都将作为此节点的子节点添加。

  3. 现在,将巨石场景从FileSystem拖放到 2D 编辑器中。当你还在拖动它时,你会看到巨石的视觉效果弹出:

图 8.9 – 将 boulder.tscn 场景拖放到主场景树中

图 8.9 – 将 boulder.tscn 场景拖放到主场景树中

现在,你可以对场景中的内部墙壁做同样的处理,并用岩石和墙壁重新填充竞技场,使其看起来不那么荒凉。然而,不要对OuterWalls做同样的事情 – 我们不会重用它,所以这个可以保持不变。

当放置巨石和墙壁场景时,你可以使用变换参数,如旋转、缩放和倾斜,为实例添加多样性,这样它们就不会看起来太相似。

好玩的是,我们可以在任何场景中使用任何其他场景!

拥有许多较小的场景文件有很多优点,代码的可维护性和易于重用只是其中两个,但它也会使项目的文件结构变得复杂。因此,我们必须考虑如何组织项目中的所有文件。我们将在下一节中这样做。

组织场景文件

现在我们有更多文件需要关注,我们不得不开始考虑如何组织它们。让我们将场景分别放入对项目有意义的不同文件夹中。这样,我们总能知道在哪里找到某物或保存一个新场景。

在我们项目的根目录中添加以下文件夹:

  • parts

    • environment

    • player

  • screens

    • game

parts文件夹将包含所有属于不同场景的场景,例如玩家、墙壁、敌人、可收集物品、UI 按钮等。

另一方面,screens将包含所有可以独立存在的场景,例如游戏屏幕、全屏菜单,如主菜单或暂停菜单等。这些场景由parts文件夹中的场景组成。

在项目开始时,我给了你一个assets文件夹。这个文件夹用于存放所有艺术资产,从精灵到动画和声音。

现在,将所有场景和脚本移动到适当的文件夹中,如下所示:

图 8.10 – 我们的项目,文件管理更佳

图 8.10 – 我们的项目,文件管理更佳

一旦你看过用 Godot 或其他游戏引擎制作的其它游戏项目,你会发现每个人都在他们项目中以自己的方式组织不同的文件。我喜欢将场景和脚本放在同一个文件夹中,例如,因为大多数时候,你都会非常接近地使用和编辑它们。然而,我会将资产,如图片和声音,分开,因为这些更容易在不同的场景中重复使用。

随着时间的推移,你可能为项目发展出一种组织结构,这是完全可以的。只要对你来说最有意义,你就应该使用它,只要你能保持一致性。

额外练习 – 锋利斧头

  1. 使用我们关于分割场景的知识,尝试制作一个与第一个不同的形状的第二块巨石场景。将第一个巨石场景命名为boulder01.tscn,第二个命名为boulder02.tscn

概述

重复使用你的工作部分几乎总是个好主意。在本章中,我们学习了如何将场景树中的整个分支作为单独的场景进行重复使用。这将在接下来的章节中派上用场,因为我们现在能够单独处理玩家的摄像机移动,并同时在所有岩石和墙壁上创建碰撞。但这将是下一章的内容。

测验时间

  • 你如何将场景树中的分支保存为单独的场景?

  • 文件系统区域组织我们的场景、脚本和资产是否重要?为什么?

第七章:相机、碰撞和收藏品

在玩游戏时,玩家不希望必须考虑相机及其位置。相机应该始终跟随玩家的角色,并预测玩家想要实现的目标,以免阻碍玩家的视线。

在更大的游戏中,整个团队的任务是制作最平滑的相机。在本章中,我们将尝试使用一些 Godot 引擎节点来完成同样的工作。

之后,我们将阻止玩家穿过墙壁,并查看在竞技场周围撒播收集品,如健康和金钱。

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

  • 制作跟随玩家的相机

  • 与巨石和墙壁的碰撞

  • 碰撞掩码

  • 创建继承场景

  • 连接到信号

技术要求

就像每个章节一样,你可以在 GitHub 仓库的子文件夹中找到本章的最终代码,该文件夹位于 github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter9

制作跟随玩家的相机

目前,我们的角色可以四处奔跑,但迟早它会跑出屏幕并永远消失。我们的游戏内相机应该跟随它们,以便玩家知道它们在哪里。

幸运的是,Godot 引擎有一个相当不错的相机系统,我们可以使用。它可能有点基础,但这是我们需要的所有功能,并且通过一些额外的节点,我们将能够实现一个非常平滑移动的相机。

设置基本相机

对于 2D 游戏,Godot 提供了 Camera2D 节点。

打开 player.tscn 场景并添加一个 Player 节点。这就是我们制作一个基本的跟随玩家相机的所有所需:

图 9.1 – 添加了 Camera2D 节点的玩家场景

图 9.1 – 添加了 Camera2D 节点的玩家场景

但这个基本的相机感觉有点僵硬;它开始和停止移动的时间与角色完全一致。这感觉不太自然。让我们看看如何解决这个问题。

添加拖动边距

为了使相机移动看起来自然,我们将使用 拖动边距。在相机的检查器中找到并启用水平垂直拖动:

图 9.2 – 在检查器中设置 Camera2D 节点的拖动边距设置

图 9.2 – 在检查器中设置 Camera2D 节点的拖动边距设置

现在,相机只有在玩家退出屏幕中间的某个区域时才会移动。这是没有任何事情发生的边距。如果你在相机的检查器中启用绘制拖动边距,你可以看到拖动边距的视觉表示:

图 9.3 – 通过在 Camera2D 检查器中启用它们来在编辑器中显示拖动边距

图 9.3 – 通过在 Camera2D 检查器中启用它们来在编辑器中显示拖动边距

当启用Draw Drag Margin时,你应该能够看到表示相机即将开始移动的蓝色矩形:

图 9.4 – 编辑器现在以浅蓝色显示拖动边距

图 9.4 – 编辑器现在以浅蓝色显示拖动边距

你可以在检查器中稍微调整左、上、右和下边距。我选择将它们都设置为0.1,如之前在 9**.2中看到的。

很好,拖动边距已经感觉很好了。但是,相机的移动仍然开始和停止非常突然,似乎落后于玩家。让我们接下来修复这个问题。

让相机向前看

现在相机的移动感觉很好。但是,有些地方不对劲;我们需要比平滑运动更基本的东西。当玩家移动时,相机拖在后面,显示玩家已经去过的位置,而不是玩家将要去的位置。

图 9.5 – 相机落后于玩家,没有显示玩家将要去哪里

图 9.5 – 相机落后于玩家,没有显示玩家将要去哪里

这并不理想;想象一下跑在某处,只能向后看。我们实际上想要的是相机向前看,朝向玩家移动的方向。我们可以通过跟踪玩家前方的一个点来做到这一点,而不是跟踪玩家本身。基本上,就像玩家角色拿着自拍杆一样。所以,按照以下步骤操作:

  1. 添加一个新的Player并命名为CameraPosition。这将变成我们要跟踪的点,而不是玩家本身。

  2. 现在将我们已创建的相机拖动到这个CameraPosition上,使其成为新节点的子节点:

图 9.6 – 将 Camera2D 节点放在一个名为 CameraPosition 的单独节点下

图 9.6 – 将 Camera2D 节点放在一个名为 CameraPosition 的单独节点下

  1. 确保在检查器中CameraPosition的位置设置为(0, 0)

图 9.7 – 确保 Camera2D 和 CameraPosition 节点位于位置(0, 0)

图 9.7 – 确保 Camera2D 和 CameraPosition 节点位于位置(0, 0)

  1. CameraPosition添加一个名为camera_position.gd的脚本。

此脚本将保持相机前的位置。我们可以根据玩家角色的velocity来实现这一点。

完整的脚本如下:

extends Node2D
@export var camera_distance: float = 200
@onready var _player: CharacterBody2D = get_parent()
func _process(_delta):
   var move_direction: Vector2 = _player.velocity.normalized()
   position = move_direction * camera_distance

首先,我们定义一个可以操作的导出变量,称为camera_distance。这将是在玩家移动时,我们将保持相机距离玩家前方的距离。

通过 _physics_process(),它在游戏的每个物理帧上执行,我们计算摄像机点的位置。记住,这个位置相对于 Player 节点,所以 (0, 0) 的位置就在玩家那里。想法是取玩家移动的方向并将其乘以 camera_distance。玩家移动的方向可以通过玩家的 velocity 得到。因此,首先,我们使用 get_parent 函数获取玩家节点并将其缓存到 _player 变量中。这个函数返回一个节点的父节点,在这种情况下,是 Player 节点,因为 CameraPosition 是该节点的直接子节点。

然后,为了获取玩家移动的方向,我们将这个 velocity 向量标准化。正如我们在 第七章 中看到的,标准化一个向量意味着你取整个向量并使其长度为 1。因此,整个向量长度为 1 像素。这将使我们得到 velocity 的方向,而不包括其长度。现在我们可以通过将其与 camera_distance 相乘来轻松地将这个方向缩放到我们想要的任何长度,以定义 CameraPositionposition。如果你现在运行游戏,你会看到 CameraPosition 正如你想要的那样工作,使摄像机朝向角色移动的方向看去。但它仍然有点不稳定,所以让我们最后一次将其平滑化。

平滑前瞻

现在的问题是,摄像机开始非常突然地移动,然后又突然停止。这是因为我们创建的 CameraPosition 移动得相当快。为了解决这个问题,我们应该使 CameraPosition 的移动本身更加平滑。

首先,添加一个新的导出变量,如下所示:

@export var position_interpolation_speed: float = 1.0

现在,让我们将 camera_position.gd 脚本的 _process() 函数更改为以下内容:

func _physics_process(delta):
   var move_direction: Vector2 = _player.velocity.normalized()
   var target_position: Vector2 = move_direction * camera_distance
   position = CameraPosition to have as a target_position. Then, in the next line, we calculate the actual position.
			To calculate the position, we use a new function, `lerp()`. This is short for `move_towards()`, which we used in *Chapter 7*. However, while `move_towards()` moves the position a certain number of pixels toward the target position, `lerp` moves the position toward the `target_position` according to a percentage between the two, which is the last argument in the function. This percentage is expressed in a value from `0.0` to `1.0`, where `0.0` is `0%` and `1.0` is `100%`.
			So, let’s say we want to move `50%` between the position and its target, then the resulting position will be right in the middle between the two points.
			This process is called **linear interpolation** because we interpolate between two values linearly. The way we use linear interpolation in our camera position script is to move toward the target position a little bit every frame.
			The percentage of the linear interpolation that I chose was `5.0 * delta`. I put this value in an exported variable so we can easily tweak it from the editor. Because delta is the time between two frames, the result of this product is very small and should result in an interpolation of around `10%` per frame. We multiply by `delta` because, just like for the movement speed of the player, we want the speed of the camera not to change on faster or slower computers that run at a higher or lower framerate. We talked about frame rate independent calculations in *Chapter 7* too when making the character move.
			You can play around with the speed of the interpolation by changing the `position_interpolation_speed` to anything else through the inspector.
			In this section, we learned a great deal about creating a smooth and useful camera that frames where the player is moving toward. As mentioned in the introduction of this chapter, big-budget games have whole teams that work on nothing else but the camera. But using some smart tricks, we achieved a fairly nice camera for our little game. Now, we’ll shift gears and make sure the player stops running through walls by adding collision detection.
			Collisions
			With our brand-new camera in place, let’s take a look at collisions. For now, we have the visuals of a nice arena, including walls and rocks, but they don’t really act like them. The player character is able to just run through them as if they were made out of air instead of solid matter.
			Just like with the movement of the player, we can solve this using the built-in physics engine. Let’s start by taking a look at the different physics bodies at our disposal.
			The different physics bodies
			For the player character, we used the **CharacterBody2D** physics body. But this is not the only kind that comes with the physics engine Godot. There are a few other ones:
			![Figure 9.8 – The three different physics bodies as displayed in the scene tree](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_8.jpg)

			Figure 9.8 – The three different physics bodies as displayed in the scene tree
			For 2D games, there are three different kinds of physics bodies available. Each has its own uses within a game or physics simulation. Let’s take a look at each.
			RigidBody2D
			A rigid body is a physics object that is fully simulated. Rigid bodies are completely dependent on outside physical forces and collisions. You are not supposed to control them directly.
			Important note
			These bodies are called rigid because the body itself does not deform. So, it gets used for simulating solid objects from cars to bones to walls. The **StaticBody2D** and **CharacterBody2D** can also not deform and are therefore also rigid bodies, but more in the mathematical sense of the word and not in how they are implemented within the engine.
			We cannot directly control a rigid body; they are fully managed by the physics engine, which resolves how it moves and how the velocities and forces get applied. The only way to control a rigid body is by applying external forces to it. This is like hitting a golf ball with a stick. With enough practice and fine-tuning, you can get the ball in the general direction of the hole, but picking it up and dropping it in there, though easier, is not an option.
			Simulating non-rigid, bodies, also known as soft bodies, is generally harder to do mathematically and performantly within a game. Soft bodies could be sponges, rubber objects that deform, jelly, and so on. There are ways to simulate these within a rigid body simulation, but it’s not advised to do this. In 3D, there is a **Softbody3D** node, but it is not for 2D.
			StaticBody2D
			A static body is a physics body that stays static, meaning it does not move around and also cannot be pushed by external forces. This is the simplest of physics bodies to deal with and will be ideal for making our walls and rocks out of.
			CharacterBody2D
			A character body is a physics body that we are able to control through code. A **RigidBody2D**, as we saw earlier, is fully managed by the physics engine. This makes it hard to control to get it to do what you want it to.
			A character body, on the other hand, gives us a good middle ground. Like in the `player.gd` script, we have to calculate the `velocity` ourselves and call `move_and_slide()`. But the physics engine still helps us out with collisions and calculating where the body is supposed to move based on the velocity.
			More information
			The Godot documentation also has a great write-up of the different physics bodies and how they can be used: [`docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-objects`](https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-objects).
			These were the three types of physics bodies available in Godot. But there is actually a fourth physics object, which is not a body. Let’s take a look at the **Area2D** node.
			The Area2D node
			The three physics bodies we just saw all collide with each other and react to this collision or make other bodies react to it. In essence, their movements get processed by the physics engine.
			The last physics object, **Area2D**, only detects and influences other physics objects. It is not subjected to physics calculation, like for movement. But it can detect if another physics object is overlapping it and throws a signal when these other physics objects enter or leave.
			We will use this functionality near the end of this chapter to make the player pick up health potions when they come near them.
			![Figure 9.9 – The four different physics objects as they are displayed in the scene tree](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_9.jpg)

			Figure 9.9 – The four different physics objects as they are displayed in the scene tree
			Now that you know about the three types of physics bodies currently available, we can start utilizing them to create proper collisions.
			Adding a collision shape to the player node
			Since we created the player node all the way back in *Chapter 6*, there has been this little orange triangle next to it. When we hover over it, the tool tip explains to us that this node is missing a shape. This makes sense, how can the physics engine detect collisions if it doesn’t know what shape a physics body is?
			Let’s solve this little warning:

				1.  Find and add the `Player` node:

			![Figure 9.10 – Add a CollisionShape2D to the player scene](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_10.jpg)

			Figure 9.10 – Add a CollisionShape2D to the player scene

				1.  Select this newly created **CollisionShape2D** node and click on the empty **Shape** field to reveal a drop-down menu with different shapes.
				2.  Select the **CapsuleShape2D** option.

			![Figure 9.11 – Select a CapsuleShape2D as the CollisionShape2D’s shape](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_11.jpg)

			Figure 9.11 – Select a CapsuleShape2D as the CollisionShape2D’s shape

				1.  A capsule-like blue shape will appear on the screen. This is the shape of the physics body. Use the orange circles on the periphery of the shape to change its size and try to cover most of the player sprite:

			![Figure 9.12 – Make sure the CapsuleShape2D covers the player sprite](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_12.jpg)

			Figure 9.12 – Make sure the CapsuleShape2D covers the player sprite
			The **CollisionShape2D** node in itself does not have a shape, but it will hold one for us. That is why we had to add one to the **Shape** property.
			Other shapes that are interesting as collision shapes are **RectangleShape2D** and **CircleShape2D**. The others are used in specialized situations, such as for very thin or disjointed objects, so don’t worry too much about them just yet.
			![Figure 9.13 – The CircleShape2D and RectangleShape2D](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_13.jpg)

			Figure 9.13 – The CircleShape2D and RectangleShape2D
			Running the game now will not result in the player colliding with the boulders or walls, simply because first, we’ll also need to add physics bodies and shapes to the scenes of these two.
			Creating static bodies for the boulders
			In the *The different physics bodies* section, we learned that **StaticBody2D** nodes don’t move; that sounds ideal for a boulder. So, let’s make them solid:

				1.  Go into our `boulder.tscn` scene.
				2.  Add a **StaticBody2D** node under the root node:

			![Figure 9.14 – Adding a StaticBody2D with a CollisionPolygon2D as a child of the boulder scene](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_14.jpg)

			Figure 9.14 – Adding a StaticBody2D with a CollisionPolygon2D as a child of the boulder scene

				1.  Add a `CollisionPolygon2D` node under this newly created static body.
				2.  Now add points to the collision polygon by clicking within the 2D editor. Try to cover the boulder completely:

			![Figure 9.15 – Cover the boulder with the PolygonShape2D](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_15.jpg)

			Figure 9.15 – Cover the boulder with the PolygonShape2D
			Note that we use a different kind of collision shape. Now we use `CollisionPolygon2D`. This shape lets us define our own arbitrary shape. The advantage is that we can create any shape we like. The disadvantage is that arbitrary polygons are a bit slower for the physics engine to handle. But this should not be a big problem in our game because we will not have thousands of objects that require complex physics calculations.
			Now that we know how to create static bodies, we can do the same for other static objects in our game, such as walls.
			Creating static bodies for the walls
			Let’s do something similar by adding collision to the walls within the game:

				1.  Open up `wall.tscn`.
				2.  Add a **StaticBody2D** node.

			![Figure 9.16 – The wall’s StaticBody2D has two CollisioniShape2D children](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_16.jpg)

			Figure 9.16 – The wall’s StaticBody2D has two CollisioniShape2D children

				1.  Instead of using a **CollisionPolygon2D**, add two **CollisionShape2D** nodes.
				2.  Give them each a **RectangleShape2D** in their **Shape** property.
				3.  Now make sure that the combination of these two shapes covers the wall:

			![Figure 9.17 – Covering the wall using the two RectangleShape2Ds](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_17.jpg)

			Figure 9.17 – Covering the wall using the two RectangleShape2Ds
			From this example, you can see that a single physics body can actually contain multiple collision shapes. This is very useful when constructing complex shapes without having to resort to a **CollisionPolygon2D**. Although we used two rectangle shapes, we could have used two different shapes if we wanted to, even combining regular shapes and polygons. We can add as many shapes under one physics body as we desire.
			In this section, we learned how to use different physics bodies to do collision detection and make sure the player doesn’t walk through walls and boulders. In the next section, we’ll extend this knowledge to also use the physics engine to detect whether the player is within a certain region or not.
			Creating collectibles
			Now, let’s create some collectibles for our hero to pick up. We’ll create two different collectibles:

				*   A health potion, which will replenish the health of the character
				*   A coin, which will add one gold to the player’s money

			We’ll start off by creating a base collectible, from which we can easily implement the two different behaviors we want the two collectibles to have.
			Creating the base collectible scene
			The base scene and class that we will build to inherit each specific collectible is very important; it should cover the use case of all other collectibles that we want to create. So let’s start:

				1.  Create a new scene called `collectible.tscn` in a new folder, `parts/collectibles`.
				2.  Set up the scene as shown in *Figure 9**.18*:
    1.  Make the root node a `Collectible`.
    2.  Add an **Area2D** node and a **Sprite2D** as direct children.
    3.  Add a **CollisionShape2D** to the area.

			![Figure 9.18 – The base scene structure for our collectibles](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_18.jpg)

			Figure 9.18 – The base scene structure for our collectibles

				1.  Fill the **Shape** property of the **CollisionShape2D** with a **CircleShape2D**.
				2.  Update the radius of the circle shape to be `25` pixels.

			We use a new node type, **Area2D**. An **Area2D** node can detect collisions when another physics body or area enters its shape. As we are using this physics object, the **Area2D** node will not act out any physics, nor will it influence the physics of the other physics body. **Area2D** nodes are used to detect whether other bodies or areas overlap their collision shape. We will use this functionality to detect if the player character overlaps the collectible because when this happens, we have to execute the code associated with the collectible.
			With the base collectible scene ready, we can easily inherit from it in the next section.
			Inheriting from a base scene
			If you were wondering why we didn’t add a texture to the collectible scene yet, that’s because we want to do that for specific collectibles, such as the health potion and coin, and not for the base.
			So let’s create a specific collectible:

				1.  Right-click on the `collectible.tscn` scene in the file manager and select **New** **Inherited Scene**.

			![Figure 9.19 - Right-clicking the collectible.tscn file and choosing New Inherited Scene](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_19.jpg)

			Figure 9.19 - Right-clicking the collectible.tscn file and choosing New Inherited Scene

				1.  A new scene will open up. Rename the root node to `HealthPotion`:

			![Figure 9.20 – The inherited nodes are greyed out](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_20.jpg)

			Figure 9.20 – The inherited nodes are greyed out

				1.  Save the inherited scene as `health_potion.tscn` in the same folder as the `collectible.tscn`, which is `parts/collectibles`.
				2.  Now add the `HealthPotion.png`, from `assets/visual/collectibles`, as a texture to the **Sprite2D** node.
				3.  The sprite is a little small, so set the scale to `(2, 2)`, as we did for the player’s sprite in *Chapter 6*:

			![Figure 9.21 – This is how our health potion collectible should look in the editor](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_21.jpg)

			Figure 9.21 – This is how our health potion collectible should look in the editor
			You can see that all the nodes, except for the root node, are greyed out, as in *Figure 9**.22*. That is because these nodes are managed by the scene we are inheriting from, the `collectible.tscn` scene in this case.
			![Figure 9.22 – When inheriting a scene, the inherited nodes are greyed out](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_22.jpg)

			Figure 9.22 – When inheriting a scene, the inherited nodes are greyed out
			Try it yourself
			Just as an experiment, go back to the `collectible.tscn` scene, put the sprite node in a different location, and save. If you have a look in the `health_potion.tscn` scene, you’ll see that the sprite moved to the same location too!
			With the technique of inheriting scenes, we can easily build out the functionality of collectibles without having to alter each collectible’s scene separately or copy-pasting. We can just define the base structure and functionality once.
			With our base health potion scene done, we can now add its logic. First, we need to know when the player is actually close enough to pick up the collectible. We’ll learn how to do this in the next section.
			Connecting to a signal
			In the *Creating the base collectible scene* section, I told you that we were going to use an **Area2D** node to detect when the player’s physics shape enters and thus when we know the collectible should be collected.
			To do this, we’ll learn about a new concept in Godot Engine: **signals**. All nodes can throw signals; a signal could be something such as *“a physics body entered my shape”*. We could listen, or connect, to this signal and run a piece of code whenever it happens.
			We will now do this for the signal that the **Area2D** node throws when a physics body enters its collision shape:

				1.  Go to the `collectible.tscn` scene.
				2.  Add an empty script to the root node. To connect to a signal, we first need a script. Make sure to delete all the code within the script except for the first one that says it extends the `collectible.gb`.
				3.  Now select the **Area2D** node. In the right panel, where we normally see the Inspector for a node, there is also a tab called **Node**. Click it.

			![Figure 9.23 – The list of signals an Area2D node can throw; body_entered is the one we need](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_23.jpg)

			Figure 9.23 – The list of signals an Area2D node can throw; body_entered is the one we need

				1.  This tab shows us the different signals a node can throw. The one we want to connect to is called `body_entered` because it gets thrown from the moment a physics body enters the **Area2D** node. Select this signal and press the **Connect** button in the bottom right.
				2.  A modal pops up asking us to which node in the current scene we want to connect this signal. The root `Collectible` node should already be selected, so just press the **Connect** button.

			![Figure 9.24 – Selecting the node we want to connect the signal with, the Collectible node in our case](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_24.jpg)

			Figure 9.24 – Selecting the node we want to connect the signal with, the Collectible node in our case

				1.  We will now directly be taken to the `collectible.gb` script and you can see that a new function, `_on_area_2d_body_entered`, got added for us:

			![Figure 9.25 – A new function will automatically be created for us after connecting the signal](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_25.jpg)

			Figure 9.25 – A new function will automatically be created for us after connecting the signal
			The connection of the signal is done; now, every time the `body_entered` signal is thrown by the `_on_area_2d_body_entered` function of that collectible will be executed.
			Also notice that the generated function has a parameter called `body`. This is the body object that overlapped the **Area2D**; for example, the player. Signals can give some context when they are being throw in the form of these parameters. Different signals have different parameters, and most have no parameters at all.
			Writing the code for collectibles
			Now we’ll finally write some real code to give the player some new health points when picking up the health potion, though it will not be that much, to be honest. Let’s write the code necessary to make our health potion functional:

				1.  First, go back to the `collectible.gd` script. We’ll make this script a named class by adding a line defining the class name at the top:

    ```

    类名 Collectible

    ```cpp

			Important note
			Creating a named class with `class_name` is not 100% necessary here, but it is good practice to name classes that you are going to inherit from.

				1.  Now in the `health_potion.tscn` scene, right-click on the root node, and select **Extend Script**.
				2.  Save the new script as `health_potion.gd` in the same folder as `health_potion.tscn`. This will create a script that inherits from the `Collectible` class and assigns it to the **HealthPotion** node for us.

			![Figure 9.26 – Right-clicking the HealthPotion node and selecting Extend Script](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_26.jpg)

			Figure 9.26 – Right-clicking the HealthPotion node and selecting Extend Script

				1.  Next, override the `_on_area_2d_body_entered()` function by defining a new one, like so:

    ```

    func _on_area_2d_body_entered(body):

    body.health += 5

    queue_free()

    ```cpp

			This function is used in the `Collectible` class to connect to the `body_entered` signal. By overriding it here, we effectively replace the function that will be executed.
			You can see that we take the body that is provided as an argument and simply update its health value by adding `5`.
			The last line introduces a new function that we can call on nodes: `queue_free()`. This function will queue the node for deletion so that the engine knows it can be removed from the scene tree. The engine will delete the node at the end of the current frame.
			Let’s try this out! Go back to the main scene and add a health potion somewhere by dragging and dropping the scene anywhere in the arena:
			![Figure 9.27 – Adding a HealthPotion in the main scene](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_27.jpg)

			Figure 9.27 – Adding a HealthPotion in the main scene
			If you put the health potion somewhere without boulders or walls, you will be able to walk over there with the player character and pick it up. But if you put the health potion too close to a boulder or wall, you’ll get an error! Oh no! Let’s learn how to solve this next.
			Using collision layers and masks
			There is one problem! The signal will now be thrown for every physics body that enters the **Area2D** of the collectible, so even for boulders and walls. But we only want to trigger the functionality when our player enters the area.
			Luckily, we can only trigger the overlap detection for certain bodies using collision layers and masks.
			Introducing collision layers and masks
			If you select the `collectible.tscn` scene, you’ll see the **Collision Layer** and **Collision Mask** properties in the inspector. These two dictate what other physics bodies and areas can interact with the area.

				*   **Collision Layers** dictates what layer the physics object is in and can be detected by other physics objects.
				*   **Collision Mask** dictates what layers this physics object is looking at for collision detection.

			![Figure 9.28 – There are 32 separate collision layers and masks](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_28.jpg)

			Figure 9.28 – There are 32 separate collision layers and masks
			This means that the collision layers are used to tell other bodies and areas that you exist, while the collision mask is used to detect other bodies and areas. Note that these don’t need to be the same. The layers could be different from the mask, and that one body or area can be active in multiple layers and can look at multiple masks.
			Each collision layer has a number associated with it, but we can actually give them a name that is easier to read for humans. We’ll do that in the next section.
			Naming collision layers
			What we are going to do is use one layer, `layer number 1`, as the layer for wall collisions and another layer, `layer number 2`, for collectible detection. Because it is difficult and non-descriptive to talk about `layer number 1` and `layer number 2`, we can name layers within the Godot Editor. This will help us in the long run:

				1.  Open up the **Project Settings**.
				2.  Navigate to **Layer Names** | **2D Physics**:

			![Figure 9.29 – Naming collision layers under the 2D Physics category](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_29.jpg)

			Figure 9.29 – Naming collision layers under the 2D Physics category
			Here, you can see the different collision layers and their names. None of them have a name yet.

				1.  Give `Collision` and `Collectible`:

			![Figure 9.30 – Naming two of the layers](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_30.jpg)

			Figure 9.30 – Naming two of the layers

				1.  If we now select the **Area2D** node from the collectible scene again and hover over the layer numbers, we’ll see the name pop up:

			![Figure 9.31 – Hovering over a collision layer number shows us its name](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_31.jpg)

			Figure 9.31 – Hovering over a collision layer number shows us its name

				1.  We can also click on the ellipses next to the layers for easier layer selection to see our names there.

			![Figure 9.32 – Pressing the ellipses makes it easy to select named collision layers](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_32.jpg)

			Figure 9.32 – Pressing the ellipses makes it easy to select named collision layers
			With these collision layers having a name, it will be easier to assign them in the future. So let’s do that in the next section.
			Assigning the right layers
			Now that we understand collision layers and masks and know how to name them, let’s use them so that only the player can trigger collectibles.
			We’ll have to adjust the collision layers and masks of all physics bodies in the game. Luckily, we made separate scenes for all of them, so this will go fast and, in the future, we can take these layers into account while making the scenes.
			For the `player.tscn` root node, configure the layers and mask as follows:
			![Figure 9.33 – The collision layer and mask configuration for the player](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_33.jpg)

			Figure 9.33 – The collision layer and mask configuration for the player
			For the `boulder.tscn` and `wall.tscn`, we want the following configuration:
			![Figure 9.34 – The collision layer and mask configuration for boulders and walls](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_34.jpg)

			Figure 9.34 – The collision layer and mask configuration for boulders and walls
			Lastly, for the `collectible.tscn` scene, set the configuration as follows:
			![Figure 9.35 – The collision layer and mask configuration for collectibles](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_09_35.jpg)

			Figure 9.35 – The collision layer and mask configuration for collectibles
			You can see that the player, boulders, and wall are in both the collision layer and collision mask. This is because they need to be able to interact with each other. On the other hand, the player is in the collectible layer and not the collectible mask, while the collectible scene does the opposite. We define the layer and mask for collectibles this way because the player doesn’t directly need to interact with collectibles and doesn’t need to detect them; the collectible scene does all the work for us.
			Your turn!
			Great, we created our health potion! Now you can implement the coin so the player can collect gold. Here are some of the steps you could take:

				1.  Make a new inherited scene from the `collectible.tscn` scene as we saw in the *Inheriting from a base* *scene* section.
				2.  Extend the collectible script, as we did in the *Writing the collectible* *code* section.
				3.  Track the amount of gold the player owns using a variable.
				4.  Show how many coins the player owns on the screen using a label.

			I’ll leave a possible implementation of all this in the repository of the project.
			We learned a lot in this section. We discovered what **Area2D** nodes are, and collision layers and masks are no longer a mystery but a useful tool for defining what bodies and areas we want to interact with. Let’s do some last exercises before summarizing and ending the chapter.
			Additional exercises – Sharpening the axe

				1.  Oh no! We added collisions to the boulders and inner walls of the arena, but not to the outer walls. Add a **StaticBody2D** that stops the player from escaping the arena.
				2.  Create a base scene for the boulders, inherit two boulders from that, and make their shapes different. Also, make sure you update the collision shape so that the player collides correctly with them.

			Summary
			We started this chapter learning all about the **Camera2D** node and making it smooth and usable for the player so that they don’t have to think about it while navigating around the playing field.
			After, we added colliders to the player and all solid objects within the game. We even used collision shapes to create little collectible items, such as a health potion.
			Along the way, we saw what signals are and how we can connect them to functions in a node’s script.
			In the next chapter, we’ll flesh out our game with enemies and menus so that we have a full game loop.
			Quiz time

				*   Why did we use a point in front of the player to position the camera?
				*   What does the last parameter of a `Vector2`’s `lerp` function represent? Here is an example:

    ```

    var position: Vector2 = Vector2(1, 1)

    var target_position: Vector2 = Vector2(3, 5)

    position.lerp(target_position, 0.5)

    ```cpp

    				*   Why did we use a **CharacterBody2D** for the player character and not a **RigidBody2D**?
				*   What are **Area2D** nodes used for?
				*   We have two objects: an **Area2D** node and a **CharacterBody2D** node. We want to be able to detect the **CharacterBody2D** with the **Area2D** node. How do we need to configure their collision layers and masks?
				*   The **Area2D** and **CharacterBody2D** nodes should be in the same collision layer.
				*   The **Area2D** node should be in the same collision mask as the **CharacterBody2D** node’s collision layer.
				*   The **Area2D** and the **CharacterBody2D** nodes should be in the same collision mask.
				*   Signals notify us of certain actions that happen in a node. To what signal did we connect to detect if a player entered the **Area2D** node of a collectible?

第八章:创建菜单、制作敌人和使用自动加载

虽然设置所有当前系统很有趣,但游戏本身还是有点无聊。没有真正的对手,没有阻止玩家捡起他们想要的全部金币。让我们通过创建攻击玩家并试图阻止他们通往荣耀和名声之路的敌人来增加一些挑战吧!

此外,我们还将创建一个小菜单,从那里开始我们的游戏。我们将使用 Godot 的用户界面UI)系统来完成这项工作,该系统使用控制节点。在本章中,我们将讨论以下主题:

  • 创建菜单

  • 制作敌人

  • 射击弹体

  • 在自动加载中得分

技术要求

对于每一章,你都可以在 GitHub 仓库的子文件夹中找到本章的最终代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter10

创建菜单

开发游戏最激动人心的部分当然是制作游戏本身!让事物移动、战斗、跳跃、射击、交互等等。但还有另一个同样重要的部分:用户界面(UI)。UI 将一切联系在一起。它向玩家告知正在发生的事情,并让他们轻松地在菜单之间导航,而无需思考如何从一个界面切换到另一个界面。

良好的用户体验、UI 或人机交互设计很难!但一切始于学习如何首先制作 UI。所以,让我们看看我们如何创建菜单和界面。

控制节点

Godot 引擎附带了一个广泛的界面节点库。我们已经在第六章中使用了一个,那就是标签节点。这些节点被称为控制节点,并以绿色进行标记:

图 10.1 – 一些控制节点可以通过其绿色颜色识别

图 10.1 – 一些控制节点可以通过其绿色颜色识别

如果你打开创建新节点菜单,你会看到有很多这些控制节点。我们可以将这些节点分为三个不同的组。让我们来看看一些节点以及它们在每个组中能为我们做什么。

显示信息的节点

第一组节点显示信息。在这个组中,你会找到我们在第六章中使用的标签节点,还有ColorRectTextureRect节点:

  • Label:显示一段简短的文本字符串。

  • RichTextLabel:显示一段较长的文本,可以以特定的方式格式化。

  • ColorRect:显示一个单色的矩形。

  • TextureRect:在一个矩形中显示纹理。这个节点与Sprite2D节点类似,它们都用于显示纹理,但在不同的上下文中使用。

在下面的图中,你可以看到这些节点在编辑器中的样子:

图 10.2 – 控制节点示例,显示信息

图 10.2 – 显示信息的控制节点示例

这些节点都向用户显示某些内容。

接收输入的节点

任何好的 UI 都可以接收输入,例如按钮。以下是一些 Godot 引擎 UI 节点提供的输入节点:

  • 按钮:一个可以点击的简单按钮。

  • 复选框:一个可以打开和关闭的复选框。

  • 复选框按钮:与复选框相同,但外观不同。

  • 文本输入框:一个简单的节点,可以接收单行文本输入并将其作为字符串提供。

  • 水平滑块垂直滑块:用于输入数字的滑块。水平滑块水平滑动,而垂直滑块垂直滑动。

在下面的图中,你可以看到这些节点在编辑器中的样子:

图 10.3 – 接收输入的控制节点示例

图 10.3 – 接收输入的控制节点示例

这些节点都以某种方式接收输入。

包含其他节点的节点

最后,还有一些你看不见但非常重要的节点,因为它们确保了所有其他 UI 元素都被正确放置。这些节点构成了一个骨架,其他控制节点可以在其中找到它们的位置。

容器节点帮助我们以我们想要的方式布局 UI。这种类型的节点可以很好地显示相邻的元素,在节点之间添加一些间距,等等。

这些容器还可以帮助我们保持界面在调整屏幕大小时仍然可用且美观。这种情况并不常见,但如今的游戏可以在许多不同的屏幕尺寸和宽高比下运行。只需想想电脑屏幕和手机屏幕之间的区别。

一些有趣的容器节点如下:

  • VBox 容器HBox 容器:垂直或水平地很好地组织所有子节点

  • 居中容器:居中其子节点

  • 网格容器:以整洁的网格组织所有子节点

  • 边距容器:在其子节点周围添加间距,以便它们有足够的空间呼吸

  • 面板:提供一个背景,显示这部分 UI 逻辑上属于一起

在下面的图中,你可以看到这些节点在编辑器中的样子:

图 10.4 – 可以包含其他节点的控制节点示例

图 10.4 – 可以包含其他节点的控制节点示例

容器节点都以特定的方式包含和放置它们的子节点。

本节中的节点列表并不全面;在添加节点时快速查看控制节点类别,这很明显。但这些都是你可能会首先使用的重要节点。其他节点更为专业。

惊人的是,完整的 Godot 编辑器本身也是由这些 Control 节点构建的,这只是为了展示它们在构建 UI 时的灵活性和强大功能。

现在我们对不同的控制节点有了基本了解,我们可以开始使用它们制作菜单。

创建一个基本的启动菜单

让我们创建一个在启动游戏时显示的启动菜单。这个菜单应简单地显示游戏名称、一个开始播放的按钮、一个退出游戏的按钮,最后,我们还可以添加一些关于谁创建了游戏的信息:

图 10.5 – 这就是我们的启动菜单将看起来

图 10.5 – 这就是我们的启动菜单将看起来

让我们回顾一下创建启动菜单的步骤,如图图 10.5所示:

  1. 在新的screens/ui文件夹下创建一个名为menu.tscn的新场景。

  2. 选择用户界面作为根节点类型:

图 10.6 – 将用户界面作为菜单的根节点

图 10.6 – 将用户界面作为菜单的根节点

  1. 将根节点重命名为Menu

  2. 让我们从向菜单添加一个ColorRect节点开始;这将是我们背景颜色。

  3. 现在,为了将ColorRect节点拉伸以覆盖整个屏幕,控制节点在顶部栏中有一个方便的小菜单。在场景树中选择ColorRect节点,并从锚点****预设列表中选择全矩形

图 10.7 – 选择全矩形以使 ColorRect 节点覆盖整个屏幕

图 10.7 – 选择全矩形以使 ColorRect 节点覆盖整个屏幕

  1. 现在,添加一个MainUIContainer

  2. 现在,添加一个TitleLabel。这个标签将显示我们游戏的标题:

图 10.8 – 我们菜单到目前为止的场景树

图 10.8 – 我们菜单到目前为止的场景树

  1. 为游戏想一个合适的标题,并将其放入TitleLabel节点的文本字段中。

  2. 现在,将60 px 移动到:

图 10.9 – 您可以在主题覆盖中更改标签的字体大小

图 10.9 – 您可以在主题覆盖中更改标签的字体大小

这只是为了为我们的游戏 UI 创建一个标题标签。这可能看起来步骤很多,但我们使用的某些节点将使在接下来的几个步骤中扩展 UI 变得容易。

让我们添加一个带有按钮和信用行的面板:

  1. 将一个PanelContainer节点添加到MainUIContainer节点中。

  2. 现在,在这个面板容器中创建以下结构:

图 10.10 – 从 PanelContainer 节点查看的场景树结构

图 10.10 – 从 PanelContainer 节点查看的场景树结构

  1. 将第一个按钮重命名为PLAY

  2. 将第二个按钮重命名为EXIT

  3. 将标签CreditLabel重命名,并将其文本更改为您希望显示的内容!

  4. 现在,进入第一个50 px。

  5. 20 px 更改为。

  6. 最后,将200 px 设置。

干得好 – UI 布局已完成。完整的场景树应如下所示:

图 10.11 – 我们菜单的完整场景树

图 10.11 – 我们菜单的完整场景树

剩下的唯一事情就是让按钮变得可用!让我们快速完成它:

  1. 将一个空脚本添加到根menu.gd

  2. 现在,将PlayButton节点的按下信号连接到这个节点。

    连接的函数体相当简单:

    func _on_play_button_pressed():
       get_tree().change_scene_to_file("res://screens/game/main.tscn")
    
  3. 此外,连接ExitButton节点的按下信号。

    这个函数体甚至更简单:

    func _on_exit_button_pressed():
       get_tree().quit()
    

在前面的代码片段中,我们使用get_tree()函数到达场景树的根。这个函数返回SceneTree对象,它在游戏运行时管理整个节点层次结构。

在连接到change_scene_to_file()函数的该对象上的函数中,该函数将当前运行的场景切换到我们提供给函数的路径指定的场景。因此,要启动主游戏场景,我们只需给它从项目根目录开始的路径,到main.tscn场景。

重要提示

值得注意的是,从它被调用的那一刻起,change_scene_to file()也会加载它应该切换到的场景文件。这意味着游戏将在加载这段时间内阻塞或冻结。当我们切换到大型场景时,这并不是很好,幸运的是,在我们的案例中我们没有这样做。

在连接到quit()函数的函数中,该函数简单地关闭运行时。

你现在可以通过运行它来尝试菜单!

设置主场景

为了确保我们的菜单是启动游戏的主体场景,我们很快需要进入项目设置来声明这一点。在项目设置中,menu.tscn下是主场景:

图 10.12 – 在项目设置中设置主场景

图 10.12 – 在项目设置中设置主场景

这将确保当我们运行游戏时,使用menu.tscn场景是默认启动的场景。

重要提示

记住,如果没有设置主场景,并且我们通过上述方法运行游戏,Godot 会询问我们是否想使用当前打开的场景作为主场景。

我们学到了很多关于控制节点以及如何快速构建 UI 的知识。让我们去制作一些敌人。

制作敌人

在现实生活中,制造敌人从来不是一个好主意。但在视频游戏开发的情况下,这通常是一种挑战玩家并让他们面对一些反对派的极好方式。

我们将要创建的敌人相当简单直接。但我们在过程中仍然会学到很多——例如,如何让敌人向玩家导航以攻击他们。

正如我说的,我们将保持敌人简单。我们将制作一个在竞技场随机位置随机时间生成的敌人,并开始向玩家冲锋。从敌人接触到玩家的那一刻起,我们将从玩家的生命值中扣除一个健康点,并将敌人从游戏中移除。这样,玩家有一些对手,但不应被一群敌人所淹没。

在下一节“射击弹丸”中,我们将开发一种让玩家进行防御的方法。但到目前为止,我们将只关注敌人和它的行为。

构建基础场景

就像我们的游戏中的任何新部分一样,让我们首先在场景树中为敌人创建基础结构,然后在后面的章节中添加代码和其他有趣的东西:

  1. 创建一个parts/enemy文件夹,并在其中创建一个名为enemy.tscn的新场景。

  2. 重新创建以下场景树。注意,根节点是一个CharacterBody节点:

图 10.13 – 我们敌人场景的场景树

图 10.13 – 我们敌人场景的场景树

  1. assets/sprites/enemies文件夹中选择一个精灵作为Sprite2D节点的纹理:

图 10.14 – 2D 编辑器中的敌人场景

图 10.14 – 2D 编辑器中的敌人场景

  1. 确保您已将精灵节点的比例设置为(``3, 3).

目前,Enemy场景非常简单。让我们看看我们如何进行导航来稍微复杂化它。

导航敌人

我们可以轻松地让敌人直接向玩家移动。问题是它们会卡在墙壁后面,撞到巨石,这感觉很不自然,也让它们看起来相当愚蠢。

幸运的是,Godot 引擎自带一个NavigationServer属性,它可以计算绕过所有这些障碍物的路径,使敌人移动更加自然流畅。

为了实现这一点,我们将查看两个新的节点:NavigationRegion2DNavigationAgent2D

创建一个 NavigationRegion2D 节点

首先,我们需要定义敌人可以在关卡中的哪个区域移动,然后我们希望从这个区域中移除墙壁或巨石所在的位置。这正是NavigationRegion2D节点所做的事情!让我们定义一个:

  1. 前往main.tscn游戏场景。

  2. 在名为Main的根节点中添加一个NavigationRegion2D节点。

  3. 点击空的Navigation Polygon属性并选择New NavigationPolygon

图 10.15 – 点击新建导航多边形

图 10.15 – 点击新建导航多边形

  1. 现在,我们将首先定义敌人可以移动的外部边界。在编辑器中点击绘制多边形形状。尽量紧密地追踪竞技场的边缘。别忘了通过点击放置的第一个点来闭合形状:

图 10.16 – 创建 NavigationRegion2D 节点的边界

图 10.16 – 创建 NavigationRegion2D 节点的边界

  1. 在窗口顶部点击烘焙 NavigationPolygon以创建导航多边形:

图 10.17 – 点击 Bake NavigationPolygon

图 10.17 – 点击 Bake NavigationPolygon

按照这些步骤操作后,NavigationRegion2D节点应该看起来像这样:

图 10.18 – 首次烘焙多边形后的 NavigationRegion2D 节点

图 10.18 – 首次烘焙多边形后的 NavigationRegion2D 节点

蓝绿色区域是敌人能够导航和移动的区域。但你已经可以看到一个问题——这个区域也跨越了我们的墙壁和巨石。我们不希望敌人认为他们可以穿过它们,因为,嗯,他们不能;它们是静态物理体。幸运的是,Godot 具有自动检测这些并以考虑它们的方式烘焙NavigationPolygon属性的功能。

通过单击NavigationRegion2D节点来展开它,并按以下方式配置:

  1. 几何 | 解析几何类型设置为静态碰撞体。我们这样做是为了在自动生成时只考虑静态碰撞体。

  2. 几何 | 源几何模式设置为与子节点组合。这样,自动生成将扫描节点的子节点以查找静态碰撞体。

  3. 设置40 px。通过这种方式,我们定义了在NavigationRegion2D节点中要使用的代理的半径,并且自动生成可以考虑到这一点,以便代理不会撞到它们应该能够避免的障碍物:

图 10.19 – 配置 NavigationPolygon 属性

图 10.19 – 配置 NavigationPolygon 属性

  1. 选择Arena节点并切换到节点标签页,它位于检查器标签页旁边:

图 10.20 – 转到节点标签页

图 10.20 – 转到节点标签页

  1. 切换到标签页,它位于信号标签页旁边:

图 10.21 – 切换到组标签页

图 10.21 – 切换到组标签页

  1. navigation_polygon_source_geometry_group粘贴到文本框中并按添加

图 10.22 – 添加 navigation_polygon_source_geometry_group 组

图 10.22 – 添加 navigation_polygon_source_geometry_group 组

  1. 现在,再次选择NavigationRegion2D节点并再次按烘焙 NavigationPolygon

当你完成时,导航区域应该看起来像这样:

图 10.23 – 生成的 NavigationRegion2D 节点

图 10.23 – 生成的 NavigationRegion2D 节点

现在的蓝绿色区域很好地避开了墙壁和巨石。你还可以看到障碍物和区域开始处之间有一些空白。这是我们定义代理的半径属性时设置的。这个空白确保路径查找不会太靠近障碍物,使敌人避免与之碰撞。

步骤 2步骤 4中,我们将Arena节点添加到navigation_polygon_source_geometry_group节点组中,并考虑其中的静态物体。让我们稍微偏离一下,来谈谈节点组。

什么是节点组?

在 Godot 引擎中,节点组就像其他软件中的标签。您可以为节点添加任意数量的组。我们可以通过选项卡简单地做到这一点,就像我们在上一节步骤中所做的那样。

组非常有用,因为例如,您可以执行以下操作:

  • 检查一个节点是否属于一个组。

  • 从树中获取组内的所有节点。

  • 在组内调用所有节点的函数。

我们稍后会再次使用组。

NavigationRegion2D节点已经准备好了,所以现在,让我们看看将NavigationAgent2D节点添加到Enemy场景的过程。

将 NavigationAgent2D 节点添加到敌人场景

在开始编写代码之前,我们需要在编辑器中添加一个NavigationAgent2D节点到enemy.tscn场景中。这个节点处理NavigationRegion2D节点内的路径查找和导航,这是我们上一节中创建的。

只需在根Enemy节点中添加一个NavigationAgent2D节点。我们不需要进行任何其他设置:

图 10.24 – 将 NavigationAgent2D 节点添加到敌人场景

图 10.24 – 将 NavigationAgent2D 节点添加到敌人场景

现在,我们可以开始编写我们的敌人代码了!

编写敌人脚本

我们敌人的代码将非常类似于我们玩家的代码。他们都是根据加速速度的物理特性移动。唯一的区别是,对于敌人,它想要移动的位置由NavigationServer属性定义。这个服务器查看NavigationRegion2D节点和NavigationAgent2D节点的当前位置,以计算到达地图上我们选择去的目的地的最佳路线。

让我们从编写一些样板代码开始,这些代码定义了我们敌人的一些移动:

class_name Enemy extends CharacterBody2D
@onready var _navigation_agent_2d: NavigationAgent2D = $NavigationAgent2D
@export var max_speed: float = 400.0
@export var acceleration: float = 1500.0
@export var deceleration: float = 1500.0
var player: Player
func _physics_process(delta: float):
   _navigation_agent_2d.target_position = player.global_position
   if _navigation_agent_2d.is_navigation_finished():
      velocity = velocity.move_toward(Vector2.ZERO, deceleration * delta)
   else:
      var next_position: Vector2 = _navigation_agent_2d.get_next_path_position()
      var direction_to_next_position: Vector2 = global_position.direction_to(next_position)
      velocity = velocity.move_toward(direction_to_next_position * max_speed, acceleration * delta)
   move_and_slide()

通常,这段代码与我们为player.gd脚本编写的移动代码非常相似。唯一的区别是,我们现在使用NavigationAgent2D节点来说明我们需要去哪里:

_navigation_agent_2d.target_position = target.global_position

如您所见,我们正朝着player变量的全局位置前进。我们将在稍后定义这个player变量。

位置和 global_position

另一方面,global_position变量的position属性是节点在世界空间中的位置,相对于场景树根。当节点在 2D 空间中移动时,两者都会自动更新;这基本上是相同的数据,但参考点不同。

我们需要在这里使用global_position变量,因为NavigationAgent2D节点的目标位置必须是一个全局位置。

然后,我们需要检查是否需要移动:

if _navigation_agent_2d.is_navigation_finished():

如果我们需要移动,我们会询问NavigationAgent2D节点下一个我们应该移动到的位置:

var next_position: Vector2 = _navigation_agent_2d.get_next_path_position()

然后,我们只需要计算从当前位置到下一个位置的方向,其余的代码与 第七章 中的 Player 场景的代码完全相同。

为了选择 Player 节点,我们将使用节点组,通过添加这个 _ready() 函数来实现:

func _ready():
   var player_nodes: Array = get_tree().get_nodes_in_group("player")
   if not player_nodes.is_empty():
      target = player_nodes[0]

要从场景树中获取玩家,我们做了一些新的操作。我们要求当前场景树返回所有在 player 组中的节点。此函数将返回一个包含属于此组的节点的数组。所以,如果有的话,我们将取第一个元素。

重要提示

当场景中只有一个玩家节点时,要求所有玩家节点可能看起来有些奇怪。我们这样做是为了在下一章处理多个玩家时,可以使用大致相同的代码来针对更多玩家。

这些节点组是 Godot 引擎的一个有用特性,因为引擎会跟踪组内所有节点,这样我们就可以轻松查询它们或检查一个节点是否属于某个组。

现在,这段代码还不能工作,因为,嗯,玩家实际上还没有被添加到 player 组中!为了将它们添加到这个组中,我们需要稍微修改一下 Player 场景:

  1. 进入 player.tscn 场景。

  2. 选择根节点。

  3. 在包含节点信号的窗口中,有一个名为 Groups 的按钮。按下它,你会看到 Groups 窗口:

图 10.25 – 将玩家的根节点添加到名为 player 的节点组中

图 10.25 – 将玩家的根节点添加到名为 player 的节点组中

  1. 在这里,在行输入中输入 player 并按下 Add

在主场景中放置一个敌人,你会看到它开始向玩家移动!这很棒。但是敌人应该能够伤害玩家,所以让我们接下来处理这个问题。

在碰撞中伤害玩家

为了检测敌人是否足够接近玩家以造成伤害,我们将使用一个 Area2D 节点,就像我们在 第九章 中为可收集物品所做的那样:

  1. 让我们从向 player.gd 脚本中添加一个 get_hit() 函数开始。当玩家被敌人击中时,此函数将被调用,并降低玩家的生命值:

    func hit():
       health -= 1
    
  2. 添加一个名为 enemy.tscn 的场景,并将其命名为 PlayerDetectionArea

  3. 在这个区域下添加一个 CollisionShape2D 节点:

图 10.26 – 将 Area2D 节点添加到敌人场景中

图 10.26 – 将 Area2D 节点添加到敌人场景中

  1. 将这个碰撞形状设置为比敌人精灵稍大的 CircleShape2D 节点:

图 10.27 – 使用 CollisionShape2D 节点覆盖整个敌人及其周围一些空间

图 10.27 – 使用 CollisionShape2D 节点覆盖整个敌人及其周围一些空间

  1. body_entered 信号连接到敌人的根节点。

  2. 现在,使用下一个代码片段作为敌人脚本中连接函数的主体:

    func _on_player_detection_area_body_entered(body: Node2D):
       if not body.is_in_group("player"):
          return
       body.get_hit()
       queue_free()
    

这个函数的代码很简单。首先,我们检查进入该区域的身体是否确实是玩家。我们可以简单地通过以下检查来完成:

body.is_in_group("player")

这样,我们就可以检查某个节点是否属于某个组。如果这个身体不在player组中,我们就从函数中返回。

但如果身体是一个玩家节点,那么我们就从它的健康值中减去一点,并释放与之接触的敌人。

太好了 – 我们现在可以让敌人在我们足够接近时伤害玩家。只有一个问题:敌人的数量只有我们能够拖放到场景中的数量。敌人应该能够自动且持续地生成!否则,游戏会很快结束。让我们创建一个自动生成器,它可以生成敌人,还可以生成健康药水。

生成敌人和可收集物品

在我们的游戏场中自动生成敌人或可收集物品实际上比乍一看要难。我们可以随机选择一个位置并在那里生成一些东西。然而,这样做可能会在墙壁或巨石内生成敌人或可收集物品。更糟糕的是,敌人或可收集物品可能会在竞技场和导航区域数英里之外生成,使它们变得无用。

我们可以用许多聪明和抽象的方法解决这个问题,但通常,最简单的方法是最好的起点。这就是为什么我们将构建自己的实体生成器,它可以生成不同类型的实体,敌人、可收集物品或任何其他东西。

创建场景结构

解决敌人生成位置问题的更简单的方法是在竞技场内定义某些点,在这些点上我们可以确保敌人可以安全生成。所以,这就是我们在以下步骤中要做的:

  1. 创建一个新的场景,它从EntitySpawner派生。

  2. 将此场景保存为entity_spawner.tscn,位于parts/entity_spawner下。

  3. EntitySpawner下添加另一个Positions。在这里,我们稍后会定义所有可以生成东西的位置:

图 10.28 – 我们 EntitySpawner 场景的结构

图 10.28 – 我们 EntitySpawner 场景的结构

  1. EntitySpawner的一个实例拖放到main.tscn场景中,并将其重命名为EnemySpawner

  2. 现在,右键单击EnemySpawner并选择可编辑子项

图 10.29 – 启用可编辑子项以直接编辑实例化场景的子项

图 10.29 – 启用可编辑子项以直接编辑实例化场景的子项

你将看到EnemySpawner场景:

图 10.30 – 添加用于定位敌人的 Marker2D 节点

图 10.30 – 添加用于定位敌人的 Marker2D 节点

  1. 现在,在位置节点下,添加多个Marker2D节点,并将它们放置在你想要敌人能够生成的地方:

图 10.31 – 我想要敌人生成的不同位置

图 10.31 – 我想要敌人生成的不同位置

到目前为止,EnemySpawner 节点的设置相当简单,但我们确实使用了一些新事物。首先,我们在一个完整的场景节点上启用了 可编辑子节点。这使我们能够访问整个场景的结构,并使我们能够轻松地编辑其中的单个节点。这对于直接重用场景非常有用。

注意,EnemySpawner 节点下的节点被灰色显示。这意味着我们可以编辑它们,移动它们,等等,就像我们从收集场景继承来制作生命药水时一样,但我们不能删除这些灰色节点。

在编辑子节点旁边,我们使用了一种新的节点类型:Marker2D。这是一个在游戏过程中实际上并不做任何特殊操作的节点,但在编辑器中,它将显示一个小十字来标记其位置。当你需要标记一个位置,就像我们在这里做的那样时,会使用这个节点。

编写基础代码

对于代码,我们将做一些相当简单的事情,提供一个 spawn_entity() 函数,该函数在定义的位置之一生成一个新实体,无论是敌人还是生命药水:

extends Node2D
@export var entity_scene: PackedScene
@onready var _positions: Node2D = $Positions
func spawn_entity():
   var random_position: Marker2D = _positions.get_children().pick_random()
   var new_entity: Node2D = entity_scene.instantiate()
   new_entity.position = random_position.position
   add_child(new_entity)

我们遇到的第一件新事物是 PackedScene 类型的导出变量。这个 PackedScene 变量基本上是任何场景的定义——一个场景文件。任何场景文件都可以填充这个变量。

PackedScene 变量和 Node 变量的区别

PackedScene 变量代表一个场景文件,例如 enemy.tscn 文件。它是一个模板,我们可以用它来创建新的节点。

与此相反,Node 变量是场景树的构建块,可以是 PackedScene 变量的实例。

你可以将 PackedScene 变量视为一个类,而 Node 变量是这个类的实例化对象。

然后,稍后,我们可以使用这个打包的场景来实例化一个新的实体:

var new_entity: Node2D = entity_scene.instantiate()

为了使这个新实例化的实体成为场景树的一部分,我们需要将其添加到树中的现有节点上,因为如果我们不在场景树中添加它,它就不会在游戏中或其执行中使用。

我们可以通过在树中的任何节点上调用 add_child() 函数并将这个新实体节点作为参数来将一个新节点作为另一个节点的子节点添加。实体随后将被添加为该节点的子节点。在这里,我们将实体节点添加到 EntitySpawner

add_child(new_entity)

现在,实体真正地被放入了树中,因此也位于游戏中。

为了选择一个随机位置,我们也做了一些新的操作。首先,我们从 get_children() 获取一个子节点数组,这是一个位置标记的数组。然后,为了从这个数组中随机选择一个元素,我们可以使用 pick_random() 函数轻松地选择一个随机位置标记:

var random_position: Marker2D = _positions.get_children().pick_random()

这将为我们提供一个随机的 Marker2D 节点,我们可以用它来生成敌人。

要使位于 main.tscn 场景中的 EnemySpawner 节点生成敌人,我们只需将 enemy.tscn 场景拖放到 EnemySpawner 节点上即可:

图 10.32 – 将 enemy.tscn 文件拖放到实体场景属性中

图 10.32 – 将 enemy.tscn 文件拖放到实体场景属性中

使用这个设置,我们可以以固定的时间间隔开始生成实体。

自动生成实体

现在我们有一个可以生成实体的函数,我们仍然需要在某个时刻触发它。为此,我们将利用定时器耗尽时的timeout信号。

让我们添加一个EntitySpawner场景:

  1. 添加一个名为entity_spawner.tscn的场景文件,并将其命名为SpawnTimer

  2. 现在,将超时信号连接到EntitySpawner根节点。

  3. 在连接函数中,只需调用spawn_entity()函数:

    func _on_spawn_timer_timeout():
       spawn_entity()
    
  4. 在脚本顶部添加对SpawnTimer节点和一个表示我们将要生成实体的间隔的export变量的引用:

    @onready var _spawn_timer: Timer = $SpawnTimer
    @export var spawn_interval: float = 1.5
    
  5. 现在,我们可以添加两个额外的函数来帮助我们开始和停止定时器:

    func start_timer():
       _spawn_timer.start(spawn_interval)
    func stop_timer():
       _spawn_timer.stop()
    
  6. 最后,为了在游戏开始时自动启动定时器,将此_ready()函数添加到EntitySpawner脚本中:

    func _ready():
       start_timer()
    

重要提示

记住——当我们谈论一个场景时,我们是在谈论一个整个场景文件,例如entity_spawner.tscn文件。当我们谈论一个节点时,我们是在谈论场景文件中的特定节点,例如EntitySpawner节点。

开始和停止函数将有助于我们在玩家死亡时停止生成敌人,例如。在主体中,它们只是直接开始和停止_spawn_timer。你可以看到,当启动定时器时,我们可以给出一个以秒为单位的时间,这将作为定时器耗尽前的时间量。

现在运行游戏,我们每 1.5 秒就会得到一个新的敌人。太棒了!现在我们有一连串的敌人进入,让我们生成一些药水,以便玩家可以治疗自己。

生成生命药水

要生成生命药水收集品,我们可以轻松地使用我们刚刚构建的相同的EntitySpawner节点!下面是如何做的:

  1. main.tscn场景中添加一个新的EntitySpawner节点,并将其命名为HealthPotionSpawner

  2. 使这个生成器的子节点可编辑,并在你想要生成生命药水的Positions节点上添加Marker2D节点:

图 10.33 – 在我想生成生命药水的地方添加 Marker2D 节点

图 10.33 – 在我想生成生命药水的地方添加 Marker2D 节点

  1. health_potion.tscn场景拖放到生成器的Entity Scene属性中。

  2. 将生成器的Spawn Interval值设置为更大的数字,例如 20,这样我们就不会生成太多的生命药水:

图 10.34 – 将 Spawn Interval 值设置为 20 秒

图 10.34 – 将 Spawn Interval 值设置为 20 秒

就这样!如果我们创建一个场景,EntitySpawner,它很容易被重复使用,生成新事物就变得简单了,不是吗?

制作一个游戏结束屏幕

现在敌人可以伤害玩家,玩家的生命值下降,我们需要考虑玩家生命值达到 0 的情况。这意味着游戏的结束。我们将添加一个“游戏结束”屏幕,玩家在死亡后可以选择重试或返回主菜单。

创建基本场景

和往常一样,我们将从创建场景结构开始:

  1. 创建一个新的场景,其中包含GameOverMenu,并将场景保存为parts/game_over_scene中的game_over_menu.tscn

  2. 重新创建以下场景结构:

图 10.35 – 游戏结束菜单的场景树

图 10.35 – 游戏结束菜单的场景树

  1. 将每个元素填充为正确的文本,放大GameOverLabel节点,并为包含两个按钮的VBoxContainer节点添加一些间隔。使 UI 看起来像这样:

图 10.36 – 游戏结束菜单的外观

图 10.36 – 游戏结束菜单的外观

  1. 现在,选择GameOverMenu根节点并将其锚点预设类型设置为全矩形

图 10.37 – 从锚点预设列表中选择全矩形

图 10.37 – 从锚点预设列表中选择全矩形

现在我们有一个小菜单,让我们将其添加到main.tscn场景中:

  1. main.tscn场景中,添加一个CanvasLayer节点。

  2. 在此CanvasLayer节点下,添加我们刚刚创建的GameOverMenu节点:

图 10.38 – 已添加到场景树中的 GameOverMenu 节点

图 10.38 – 已添加到场景树中的 GameOverMenu 节点

  1. 现在,通过点击节点名称旁边的眼睛符号隐藏GameOverMenu节点。我们只想在玩家死亡时显示此菜单:

图 10.39 – 通过点击节点名称旁边的眼睛符号隐藏 GameOverMenu 节点

图 10.39 – 通过点击节点名称旁边的眼睛符号隐藏 GameOverMenu 节点

我们使用CanvasLayer节点来显示我们的菜单,因为这个节点确保其所有子节点都显示在其他所有内容之上。CanvasLayer节点不遵循由节点在场景树中的顺序确定的显示顺序。在CanvasLayer节点内部,其子节点再次遵循此顺序。这使得CanvasLayer节点非常适合游戏内的 UI。

基本场景结构已经完成;现在,我们应该向菜单添加一些逻辑。

添加游戏结束菜单的逻辑

GameOverMenu节点的脚本非常简单。我们只想在按钮被按下时添加功能。当播放按钮被按下时,我们重新加载主游戏场景,当菜单按钮被按下时,我们返回主菜单。

因此,连接两个按钮,并在它们连接的函数中加载正确的场景:

extends CenterContainer
func _on_retry_button_pressed() -> void:
   get_tree().reload_current_scene()
func _on_menu_button_pressed() -> void:
   get_tree().change_scene_to_file("res://screens/ui/menu.tscn")

重要提示

注意,我们在树上使用了一个新函数reload_current_scene()。这个函数与change_scene_to_file()非常相似,但它只会切换到我们当前所在的场景,我们不需要加载场景文件,因为它显然已经加载了。

游戏结束菜单已经准备好了;现在,我们只需要在游戏中使用它。

当玩家死亡时显示游戏结束菜单

我们已经看到了如何连接到节点抛出的信号。但我们可以创建并抛出自己的信号!我们将利用这一点来检测玩家何时死亡:

  1. player.gd脚本中,在包含extends关键字的行下面添加我们新的信号:

    class_name Player extends CharacterBody2D
    health setter when health equals 0:
    
    

    set(new_value):

    var new_health: int = clamp(new_value, 0, MAX_HEALTH)

    if health > 0 and new_health == 0:

    died.emit()

    set_physics_process(false)

    health = new_health

    update_health_label()

    
    

你可以看到,要定义一个新的信号,我们只需要使用signal关键字,后跟信号名称。

然后,稍后,我们只需通过在它上面调用emit()函数来发出这个信号。从某种意义上说,信号也是一个变量。

要检查玩家是否死亡,我们检查当前的health值是否大于 0,以及new_health值是否为 0。这样,我们就可以确保我们只在玩家从活着的状态变为死亡状态时触发一次died信号。我们不希望这个信号被多次抛出,因为这会向游戏发出信号,表明玩家死亡了不止一次,并产生不希望出现的副作用。

然后,我们还使用set_physics_process()函数,并给它false作为唯一参数。这告诉节点是否应该停止执行_physics_process()函数,并将有效地阻止玩家移动,因为所有我们的移动代码都生活在这里。

现在当Player节点在死亡时抛出信号,我们可以通过main.tscn场景来连接到这个信号:

  1. main.tscn场景中,选择Player节点。你会看到出现了一个新的信号——我们在player.gd脚本中定义的died信号:

图 10.40 – 我们在玩家脚本中定义的信号也出现在信号菜单中

图 10.40 – 我们在玩家脚本中定义的信号也出现在信号菜单中

  1. main.tscn场景的Main节点中添加一个空的脚本,并将died信号连接到它。

  2. 在连接函数中,我们应该显示GameOverMenu节点并停止EnemySpawner节点和HealthPotionSpawner

    extends Node2D
    @onready var _game_over_menu: CenterContainer = $CanvasLayer/GameOverMenu
    @onready var _enemy_spawner: Node2D = $EnemySpawner
    @onready var _health_potion_spawner: Node2D = $HealthPotionSpawner
    func _on_player_died() -> void:
       _game_over_menu.show()
       _enemy_spawner.stop()
       _health_potion_spawner.stop()
    

这个脚本相当简单,因为它只需要处理菜单并停止一些生成器。

在本节中,我们覆盖了大量的内容。我们学习了如何使用NavigationRegion2DNavigationAgent2D节点使敌人向玩家角色导航。我们使用PackedScene变量在代码中实例化场景。我们使用CanvasLayer节点在游戏上方显示游戏结束菜单。我们创建了一个自定义信号并连接到它。我们玩得很开心,现在是玩家学习如何自卫的时候了!

射击投射物

我们向玩家发送了足够的敌人,但他们无法自卫。让我们在本节中改变这一点!我们将创建玩家角色自动向敌人射击以将其消灭的投射物。为了保持简单,我们将使投射物专注于我们试图击中的目标;这样,它永远不会错过。

创建基本场景

在我们可以射击投射物之前,我们必须构建我们将从中工作的基本场景。让我们现在按照以下步骤进行:

  1. 创建一个新的场景,其中包含一个Projectile

  2. 创建如图所示的场景结构:

图 10.41 – 投射物场景的场景树

图 10.41 – 投射物场景的场景树

  1. 使用assets/sprites/projectils/中的一个纹理作为精灵的纹理。请记住将精灵的缩放设置为(``3, 3)

图 10.42 – 投射物

图 10.42 – 投射物

  1. 现在,使用CapsuleShape2D节点作为CollisionShape2D节点的形状,并确保它覆盖了精灵:

图 10.43 – 使用 CollisionShape2D 节点覆盖投射物的精灵

图 10.43 – 使用 CollisionShape2D 节点覆盖投射物的精灵

  1. 我们将使用EnemyDetectionArea

  2. 为了检测Enemy节点进入EnemyDetectionArea区域节点,将第三个2D 物理层命名为投射物

  3. EnemyDetectionArea区域节点的碰撞掩码属性设置为检测投射物层:

图 10.44 – EnemyDetectionArea 区域节点的碰撞层配置

图 10.44 – EnemyDetectionArea 区域节点的碰撞层配置

  1. enemy.tscn场景中,将Enemy节点的碰撞层属性设置为与投射物层相同:

图 10.45 – 敌人的碰撞层配置

图 10.45 – 敌人的碰撞层配置

在场景结构方面,我们只需要这些,所以让我们开始编写投射物的行为。

编写投射物的逻辑

接下来是引导投射物向目标移动、在碰撞时销毁它并通知敌人它已被击中的代码。我们将使投射物始终直奔其目标;这使得我们在代码上更容易实现:

  1. 将名为projectile.gd的脚本附加到Projectile根节点,并填充以下代码以移动它:

    class_name Projectile
    extends Node2D
    @export var speed: float = 600.0
    var target: Node2D
    func _physics_process(delta: float):
       global_position = global_position.move_toward(target.global_position, speed * delta)
       look_at(target.global_position)
    

    我们已经看到了大部分代码,除了look_at()函数。这个函数将一个节点旋转到我们提供的空间点,使其朝向该点。所以在这里,它将弹丸节点旋转到目标位置。

  2. 现在,将EnemyDetectionArea节点的body_entered信号连接到弹丸的脚本。在连接的函数中,我们只需要通知敌人它被击中,并销毁弹丸本身:

    func _on_enemy_detection_area_body_entered(body: Node2D):
       body.get_hit()
       queue_free()
    
  3. 最后,在enemy.gd脚本中,添加我们希望在弹丸击中敌人时使用的get_hit()函数:

    func get_hit():
       queue_free()
    

在弹丸本身这一侧,我们需要的代码就这些。

生成弹丸

我们希望弹丸能够每隔一段时间自动发射。为了实现这一点,我们需要在PlayerEnemy场景中做一些修改:

  1. Player场景中添加一个Timer节点,并将其命名为ShootTimer

  2. 将这个ShootTimer节点的时间设置为0.5并启用自动启动

图 10.46 – 将名为 ShootTimer 的 Timer 节点添加到 Player 场景

图 10.46 – 将名为 ShootTimer 的 Timer 节点添加到 Player 场景

  1. 接下来,在玩家脚本中,在顶部预加载弹丸场景:

    @export var projectile_scene: PackedScene = preload("res://parts/projectile/projectile.tscn")
    
  2. 当选择Player节点时,将projectile.tscn文件拖放到Inspector选项卡中的Projectile Scene属性。

就像EntitySpawner节点一样,我们导出一个PackedScene类型的变量,我们可以从编辑器中填充它,并在需要时实例化它。这次,我们直接用projectile.tscn场景填充它。preload()函数加载这个场景并将其放入projectile_scene变量中,以便使用。但这个变量也是导出的,这意味着如果有一天我们想让玩家发射不同类型的弹丸,我们可以在玩家的Inspector选项卡中拖放这个场景。

我们现在将添加实际生成弹丸的逻辑:

  1. enemy.tscn场景中,将根节点添加到enemy组中,就像我们对玩家所做的那样。这将确保我们以后可以访问所有敌人节点:

图 10.47 – 将敌人节点添加到敌人组

图 10.47 – 将敌人节点添加到敌人组

  1. player.gd脚本顶部添加一个新的export变量。这个变量将代表玩家可以射击多远的像素:

    @export var shoot_distance: float = 400.0
    
  2. 现在,将ShootTimer节点的超时信号连接到Player节点的脚本。

    这应该是连接信号的代码体:

    func _on_shoot_timer_timeout():
       var closest_enemy: Enemy
       var smallest_distance: float = INF
       var all_enemies: Array = get_tree().get_nodes_in_group("enemy")
       for enemy in all_enemies:
          var distance_to_enemy: float = global_position.distance_to(enemy.global_position)
          if distance_to_enemy < smallest_distance:
             closest_enemy = enemy
             smallest_distance = distance_to_enemy
       if not closest_enemy:
          return
       if smallest_distance > shoot_distance:
          return
       var new_projectile: Projectile = ProjectileScene.instantiate()
       new_projectile.target = closest_enemy
       get_parent().add_child(new_projectile)
       new_projectile.global_position = global_position
    
  3. 我们还应该在玩家死亡时停止ShootTimer节点,在玩家脚本顶部缓存ShootTimer节点,并在玩家的生命值达到 0 时停止它:

    @onready var _shoot_timer = $ShootTimer
    @export_range(0, MAX_HEALTH) var health: int = 10:
       set(new_value):
          # Code to update the health
          if health > 0 and new_health == 0:
             # Code when player dies
             shoot_timer.stop()
    

这个函数体的高级解释是,我们首先使用分组功能获取所有敌人的列表。然后,我们逐一检查它们与玩家之间的距离。在执行这个循环的过程中,我们始终保留距离最近的敌人及其距离。这样,我们就知道最终会得到距离玩家角色最近的敌人。

这个算法的结果可能导致没有敌人被选中。这就是为什么我们需要确保closest_enemy不会意外为空,并且如果它是空的,我们需要从函数中返回。

在完成所有这些之后,我们创建一个新的投射物,设置其目标,将其添加到场景树中,并将其位置设置为玩家的位置。

投射物的创建就到这里了!你现在可以运行游戏,并开始尝试尽可能长时间地生存。我们还看到了一些更复杂的代码,包括一个从任何其他节点找到最近节点的算法,以及如何在脚本中预加载场景。

在自动加载节点中存储高分

现在玩家可以反击并生存下来,我们可能需要给玩家一个目标去实现——这会让他们一次又一次地玩游戏。我们可以添加一个高分——例如,玩家能够生存的时间。然后,玩家可以尝试改善自己的时间或与朋友比较时间。

为了实现这一点,我们将使用一个自动加载节点。这是一个在游戏开始时初始化并在整个游戏执行过程中存在的节点。

使用自动加载

生存时间应该存储在某个地方,这样就可以在游戏中的任何地方轻松访问。这样,我们可以在玩家死亡后更改它,也可以在主菜单上显示分数,例如。

正常节点和场景必须由我们程序员来管理。但还有一种我们可能使用的节点:自动加载节点。自动加载节点是指始终被加载的场景或脚本。Godot 引擎在我们运行游戏时,会为我们初始化这个场景。

一个被自动加载的节点或脚本将存在于游戏运行期间。之前,当使用get_tree().change_scene_to_file()来更改场景时,当前场景的所有内容都会从场景树中移除,并替换为新的场景。然而,自动加载节点并不共享相同的命运;它们保持原位,并保留所有变量的值。

重要提示

虽然自动加载节点很棒,但不应该误用或过度使用。它们应该仅用于真正全局的系统,例如我们将在本节中创建的HighscoreManager自动加载节点。

我们现在不会将高分自动加载节点存储在文件中;我们将在第十五章中这样做。现在,我们只想在游戏运行时保存和加载高分自动加载节点。

创建 HighscoreManager 自动加载节点

要创建一个自动加载,我们首先需要创建一个正常的场景或脚本。因为我们实际上不需要一个完整的场景来跟踪分数,分数基本上只是一个数字,我们将编写一个脚本。当 Godot 引擎初始化我们的游戏时,它将创建一个节点并将我们的脚本附加到它上。以下步骤说明了创建自动加载的过程:

  1. 在项目的根目录下创建一个新的 autoloads/ 文件夹。

  2. 在此文件夹中添加一个名为 highscore_manager.gd 的新脚本。

    HighscoreManager 脚本将会非常简单直接:

    extends Node
    var highscore: int = 0
    func set_new_highscore(value: int):
       if value > highscore:
          highscore = value
    

上述代码定义了一个 highscore 变量和一个 set_new_highscore() 函数。此函数检查新分数是否大于当前最高分。如果是,我们保存这个新的更高分数;否则,我们不需要麻烦。

现在,让我们将此脚本设置为自动加载:

  1. 打开项目设置并导航到 自动加载 选项卡。

  2. 点击文件图标按钮以搜索文件:

图 10.48 – 点击文件夹图标以选择要作为自动加载加载的文件

图 10.48 – 点击文件夹图标以选择要作为自动加载加载的文件

  1. 导航到 autoloads/highscore_manager.gd 脚本。

  2. 选择它并按 打开

  3. 现在,回到项目设置中的 自动加载 面板,点击 添加 按钮。

设置我们的自动加载就到这里了。你会看到 Highscore 自动加载现在显示在自动加载列表中:

图 10.49 – highscore_manager.gd 脚本作为自动加载被加载

图 10.49 – highscore_manager.gd 脚本作为自动加载被加载

除了在自动加载列表中看到脚本外,我们还有另一种方法可以检查自动加载是否存在。

使用场景作为自动加载

脚本和完整场景都可以作为自动加载。要使用场景,就像我们现在为脚本做的那样加载场景。

远程树中的自动加载

如前所述,自动加载在游戏开始运行时由 Godot 引擎实例化。因此,我们无法在单独的场景中看到它们,但当我们运行游戏时,它们应该在远程树中。

使用 运行项目 按钮或使用 运行当前场景 按钮运行游戏。打开远程树,你会看到一个名为 HighscoreManager 的节点。这是我们 HighscoreManager 自动加载!

图 10.50 – 我们可以在远程树中看到 HighscoreManager 节点

图 10.50 – 我们可以在远程树中看到 HighscoreManager 节点

现在我们已经设置了 HighscoreManager 自动加载,让我们在游戏中使用它并保存一些高分吧!

在主菜单和游戏场景中添加 UI

首先,我们需要确保玩家在玩游戏时知道他们的分数。因为我们说分数将是玩家能够生存的时间,我们将通过在屏幕上添加计时器来显示这个分数:

  1. main.tscn场景中,在现有的CanvasLayer节点下添加一个TimerUI

  2. 对于TimerUI节点,选择顶部宽锚点,以便它保持在屏幕顶部:

图 10.51 – 从锚点预设列表中选择“顶部宽”选项

图 10.51 – 从锚点预设列表中选择“顶部宽”选项

  1. 添加一个TimerUI并命名为TimeLabel

图 10.52 – 我们计时器的场景结构

图 10.52 – 我们计时器的场景结构

  1. 让我们用假时间"123"填充这个标签,这样我们就可以看到当它被填充时分数会看起来怎样。

  2. 将这个标签的字体大小改为更大,例如30 px。

现在,我们需要在main.gd游戏脚本中考虑计时器:

  1. 首先,缓存脚本顶部的TimeLabel节点引用,并添加一个变量来保存当前已过时间:

    @onready var _time_label: Label = $ CanvasLayer/TimerUI/TimeLabel
    var _time: float = 0.0:
       set(value):
          _time = value
          _time_label.text = str(floor(_time))
    
  2. 现在,我们只需要更新这个_time变量的值。我们将在_process()函数中通过添加 delta 到当前时间来完成这个操作:

    func _process(delta: float):
       _time += delta
    
  3. 最后,每当玩家死亡时,我们需要提交这次时间并停止游戏计时。因此,更改与玩家died信号连接的函数,包括以下两行:

    func _on_player_died() -> void:
       _game_over_menu.show()
       _enemy_spawner.stop()
       _health_potion_spawner.stop()
       set_process(false)
       HighscoreManager.set_new_highscore(_time)
    

这就是将高分链接到游戏本身的方法。现在,我们将解决在主菜单中显示高分的问题。

在主菜单中使用高分

现在我们能够创建新的高分,让我们在菜单中显示最高分:

  1. 打开menu.tscn场景。

  2. 添加一个新的HighscoreLabel

  3. 现在,在menu.gd脚本中添加以下代码:

    @onready var highscore_label: Label = $CenterContainer/VBoxContainer/PanelContainer/MarginContainer/VBoxContainer/VBoxContainer/HighscoreLabel
    func _ready():
       highscore_label.text = "Highscore: " + str(HighscoreManager.highscore)
    

结果是,菜单现在将显示当前最高分:

图 10.53 – 添加了 Highscore 标签的主菜单

图 10.53 – 添加了 Highscore 标签的主菜单

我们对这段代码并不陌生。首先,我们保存highscore_label。接下来,当菜单场景准备就绪时,我们用包含当前最高分的字符串填充HighscoreLabel。

这就是我们对自动加载的探索。我们看到了如何轻松地将脚本或场景作为节点添加,这个节点在游戏开始时总是被加载,而不需要我们自行管理这个节点。然后,我们通过其全局变量使用这个自动加载来在不同场景之间保存信息。

额外练习 – 锋利斧头

  1. 敌人以缓慢、固定的速率生成。这可能会有些无聊,因为难度从未真正增加。让敌人在每一轮之后更快地生成。为了简单起见,你可以按照以下步骤操作:

    1. start_intervalend_intervaltime_delta作为导出变量添加到EntitySpawner节点。start_interval变量将是游戏开始时生成实体之间使用的时间,end_interval将是最终值,而time_delta是我们将从start_interval变量增加到end_interval变量的增量:

图 10.54 – EntitySpawner 节点的新的导出变量

图 10.54 – EntitySpawner 节点的新的导出变量

  1. 现在,在单独的变量 _current_spawn_interval 中跟踪下一个敌人生成的间隔时间。在游戏开始时将 _current_spawn_interval 设置为 start_interval 变量。此变量取代了旧的 spawn_interval 变量。

  2. 每次我们在 spawn_entity 函数中生成实体时,都将 time_delta 变量添加到 _current_spawn_interva 变量中。不过,请确保不要超过 end_interval 变量。

  3. 然后,仍然在 spawn_entity() 函数中,重新启动 _spawn_timer,但使用新的 _current_spawn_interval 变量:再次调用 start_timer()。对于 HealthPotionSpawner 节点,您需要将 time_delta 设置为 0.0

  4. 当玩家死亡时出现的菜单信息相当匮乏。添加一个漂亮的标签来显示玩家刚刚获得的分数。

摘要

在本章中,我们学习和创建了如此多的不同事物。首先,我们学习了有关 NavigationServer 属性的所有内容。为了给玩家提供自卫的机会,我们创建了自动定时发射的弹体。最后,我们添加了一个小的得分系统,将当前最高分存储在自动加载中,这样玩家就有动力重玩游戏并尝试打破自己的最佳成绩。

在下一章中,我们将做一些非常有趣的事情:使我们的游戏成为多人游戏!

测验时间

  • 控制节点用于创建菜单等 UI。对于以下每个场景,给出一个可以完成任务的控制节点:

  • 显示一段长文本

  • 将其他控制节点组合到屏幕中心

  • 显示一个按钮以开始游戏

  • 我们在 Enemy 场景中添加了哪个节点来使其找到玩家的路径?

  • 假设我们有一段代码,其中定义了一个名为 shot 的信号,用来指示我们发射了一个弹体:

    signal shot
    

    编写发出此信号的所需代码行。

  • 你如何在代码中从场景加载到变量中?

  • 我们如何使脚本全局可用?

第九章:与多人一起游戏

独自玩游戏很有趣。我花了很多时间探索异国世界,获得新技能,并在自己的故事中体验深刻的剧情。但与其他媒体形式相比,游戏真正闪耀的地方在于玩家能够创造自己的故事。没有什么比让他们与另一个人一起玩游戏更能让玩家创造自己的故事了。从在像《魔兽世界》或《火箭联盟》这样的游戏中合作和紧张的时刻互相帮助,到在像《使命召唤》或《Gran Turismo》这样的游戏中竞争和恐吓对方。人类行为仍然是一种比与一个完全虚构的世界互动更能引发情感的东西。

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

  • 计算机网络速成课程

  • 使用MultiplayerSynchronizerMultiplayerSpawner

  • 在多台计算机上运行游戏

在本章中,我们将实现网络化多人游戏。这意味着两个人将能够通过互联网一起玩游戏。现在,由于网络的工作方式,我们仍然想要保持安全,我们只能通过局域网LAN)来玩游戏。这意味着连接到同一 Wi-Fi 网络的人将能够一起玩游戏。

你不想从你的个人电脑上运行一个全球可访问的服务器的原因很简单:你不想冒别人黑客攻击你的电脑的风险。尽管有安全地这样做的方法,但这超出了本书的范围。

技术要求

对于每一章,你都可以在 GitHub 仓库的该章节子文件夹中找到最终代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter11.

计算机网络速成课程

在本节中,我想给你提供一个关于计算机网络的基础速成课程。因为 Godot 提供了很多开箱即用的功能,所以我们不需要成为完整的网络大师就能实现简单的多人游戏。这意味着你可以跳过这一节,直接开始实际的多玩家节点和代码实现。然而,如果你想要至少对为什么要以这种方式做事有一个高级的解释,我建议你继续阅读。

网络中的计算机通过分层模型相互通信。在最顶层,是最终的应用程序,即游戏。我们的游戏需要将信息从一个运行在某一台计算机上的游戏实例发送到另一个运行在另一台计算机上的游戏实例,也称为另一台机器。这一层被称为应用层。在这些计算机之间可能是一个庞大的互联服务器、路由器和其他网络基础设施的网络。这个网络是最低层,称为物理层

图 11.1 – 计算机网络的七层

图 11.1 – 计算机网络的七层

在应用层和物理层之间,还有多个其他层。这些层确保数据能够在需要传输此数据包从计算机 A 到计算机 B 的所有链路之间发送和接收,并且每个层都服务于不同的目的。

虽然 Godot 为我们提供了大量的灵活性,但并非每一层对我们来说都同等重要。让我们更仔细地看看两个网络层:传输层和应用层。

传输层是什么?

我们首先将研究的是传输层,这是计算机网络的第四层。这个层在许多方面都负责决定如何将我们想要发送的数据切割成更小的数据包,并确保数据包从一端无损坏地接收至另一端。

图 11.2 – 传输层是计算机网络中的第四层

图 11.2 – 传输层是计算机网络中的第四层

为了履行这些职责,已经发明了不同的协议,它们能够以不同程度的可靠性来处理这些问题。协议基本上是一套规则,通过这些规则计算机可以相互通信。

例如,如果我们从计算机 A 向计算机 B 发送一组数据包,我们只需将其发送出去,并寄希望于最好的结果。然而,我们的数据包可能会在浩瀚的互联网中某个地方意外丢失。服务器忘记将数据从一条链路发送到另一条链路,电缆被拔掉,或者可能发生任何其他错误。

现在,我们如何确保我们发送的数据实际上到达了目的地呢?嗯,我们可以要求接收计算机进行确认。但是,如果确认信息丢失了呢?嗯,我们可以进行双重确认,每个通信参与者都进行一次。

所有这些规则只是解决了确保数据包被发送和接收的问题,但我们还需要克服许多其他问题。你可以看到,这些协议很快就会变得复杂。幸运的是,聪明的人已经为我们考虑到了所有这些。

在游戏领域,有两个主要的协议被使用:

  • 传输控制协议TCP):TCP 是一种传输层协议,确保每个发送的数据包都会被接收。但要实现这一点,该协议需要花费更多的时间,来回发送确认信息。

  • 用户数据报协议UDP):UDP 是一种传输层协议,它不关心数据包是否到达。它只是将它们发送到连接中,希望它们能够到达,其中大部分应该能够到达。这比 TCP 快得多,但可靠性较低。

Godot 引擎可以使用 TCP 或 UDP 操作,甚至可以根据保证交付的重要性在不同类型的数据之间切换。对于我们的游戏,我们将使用 UDP 和 TCP 来处理不同类型的数据。

什么是应用层?

应用层是网络层中的最高层。这是我们实际上在游戏中使用我们接收到的数据的时候。此外,在这里,我们有一个选择要做;尽管我们有数据,但我们如何组织我们连接的计算机呢?

图 11.3 – 应用层是计算机网络中的第七层

图 11.3 – 应用层是计算机网络中的第七层

对于游戏,有两种主要的网络架构:对等或客户端-服务器。

对等网络

在对等网络中,每台计算机都可以与其他任何计算机通信并询问它。它们都是平等的,都是对等体。例如,计算机 A 可以要求计算机 B 告诉它的玩家角色位于何处。然后计算机 B 将发送这些数据,以便计算机 A 可以向其用户显示计算机 B 的玩家角色在游戏世界中的位置。

图 11.4 – 在对等网络中,每台计算机都可以与其他计算机通信

图 11.4 – 在对等网络中,每台计算机都可以与其他计算机通信

这种解决方案相当优雅,因为每台计算机都是平等的,并且拥有相同数量的权利。然而,我们还需要保持警惕,因为如果计算机 B 被黑客使用并欺骗其他计算机怎么办?计算机 B 不会根据游戏规则报告玩家的位置,而是给出无法到达的位置;也许它们会将其玩家角色传送到其他地方。这是一个相当大的问题。下一个网络架构试图解决这个问题。

客户端-服务器网络

而不是将每台计算机视为平等,我们可以将其中一台计算机作为所有通信的中心。每当网络中的任何计算机需要信息,例如另一台计算机的玩家角色的位置时,它们必须询问这台中央计算机。然后中央计算机将为其他计算机做出回答。

在这种情况下,我们将中央计算机称为服务器,将连接的计算机称为客户端。

图 11.5 – 在客户端-服务器网络中,每台计算机都与服务器通信

图 11.5 – 在客户端-服务器网络中,每台计算机都与服务器通信

使用这种架构,服务器可以检查所有客户端,并确保它们中没有作弊的。

Godot 引擎中的网络

再次强调,Godot 引擎支持对等网络和客户端-服务器网络架构。为了简化操作,我们将采用客户端-服务器方法。这样,我们可以确保游戏的重要部分只在服务器上运行,而我们的客户端不必担心这些部分。例如,考虑计分——客户端可以轻易地撒谎,而现在服务器将是唯一记录分数的计算机。

好的,在这次关于计算机网络简短介绍之后,尽管还有很多东西要学习,但我们已经拥有了足够的底层结构知识,可以开始在游戏中实现多人游戏。

了解 IP 地址

在现实生活中,要给另一个人寄信,你需要知道他们的家庭地址。对于计算机网络来说,这几乎是相同的。要在计算机之间发送消息,我们需要知道它们的IP 地址。这是一个独特的地址,确保你可以找到任何连接到互联网的计算机。

目前,正在使用两种 IP 地址版本:用点分隔的0255,如下所示:

166.58.155.136

这种版本原本能够为 43 亿个设备提供唯一的地址。但结果是,人类已经如此高效,43 亿个设备可能还不够!如今,几乎任何电器设备都可以连接到互联网,甚至冰箱、烤面包机和手表。这就是为什么我们正在逐步过渡到 IPv6 地址,它支持 340 个十亿亿个设备。那就是 340 万亿个设备。

IPv6 地址看起来是这样的:

e9fd:da7d:474d:dedb:d152:dce2:1294:2560

根据你的计算机如何连接到互联网,这个 IP 地址会时不时地改变,所以不要依赖它保持不变。

IP 地址就像一封可以寄出的信的邮政地址,但然后,我们仍然需要知道信是寄给家庭中的谁的。在计算机网络中,端口用于在计算机内部指定确切的应用程序。让我们接下来谈谈端口。

使用端口号

一个 IP 地址,无论是 IPv4 还是 IPv6,仅表示数据要发送到的位置。但是,计算机有众多应用程序,每个都需要自己的连接。因此,从数据接收的那一刻起,我们应该将其发送到哪个应用程序呢?嗯,每个应用程序都可以使用不同的端口,这些端口就像火车站的不同平台。尽管每列火车都到达同一个车站,但它们到达的是不同的站台。

每个应用程序都可以选择一个端口,它只是一个从065535的数字。然而,前 1,024 个是为标准计算机功能保留的,我们无法选择这些。

要指定发送数据到哪个端口,我们可以在 IP 地址的末尾添加端口号,后面跟着一个冒号:

166.58.155.136:5904
e9fd:da7d:474d:dedb:d152:dce2:1294:25605904.
			Now that we know about the basic mechanisms of computer networking, such as the different layers and how IP addresses work, we are able to start implementing multiplayer into our game. So, let’s give that a shot!
			Setting up the base networking code
			In the *A crash course in computer networking* section, we saw that we wanted to set up a client-server network architecture and that we could use IP addresses and ports to find computers over the internet. In this section, we’ll start implementing this.
			We’re going to make our multiplayer game work like this: every time you start playing, it spins up a server in the background. This way, anyone can join after one person starts the match.
			Creating the client-server connection
			If we want to connect our players through a client-server model, we need to be able to set up one computer as a server and the others as clients that connect to this server. Let’s start by writing some code.

				1.  In the `menu.gd` script, add a constant at the top that indicates which port we want to use:

    ```

    const PORT: int = 7890

    ```cpp

    				2.  Now add these two functions to the bottom of the script:

    ```

    func host_game():

    var peer = ENetMultiplayerPeer.new()

    peer.create_server(PORT)

    if peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED:

    return

    multiplayer.multiplayer_peer = peer

    func connect_to_game(ip_address: String):

    var peer = ENetMultiplayerPeer.new()

    peer.create_client(ip_address, PORT)

    if peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED:

    return

    multiplayer.multiplayer_peer = peer

    ```cpp

    The `host_game()` function will use the `ENetMultiplayerPeer` class to create a new server using the `create_server()` function that is defined on it. To create this server, we only have to specify on which port we want to receive the data. Once this is done, we check whether the connection status is disconnected; if we are not connected, then we need to return from the function. We can check the connection status using the `get_connection_status()` function on the `peer` object.

    				3.  Lastly, we set this peer as `multiplayer_peer`, which is defined on the multiplayer global variable.

    The `connect_to_game()` function does largely the same but creates a client using the `create_client()` function on the `ENetMultiplayerPeer` `peer` object. The `create_client()` function takes an IP address and port. These will, of course, be the IP address and port of the server.

			With these two functions in place, we can add some more UI to connect to the right server.
			Adding UI
			Now, for the menu, we want to be able to start a game that will set up a server or input an IP address to join an already hosted game. We won’t have to let the player choose a port, both because it’s less of a hassle for the player and because we don’t want them to accidentally choose an invalid port number. We, the programmers, decide we are going to use port `7890`.
			![Figure 11.6 – The main menu with an input field to specify an IP address](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_06.jpg)

			Figure 11.6 – The main menu with an input field to specify an IP address

				1.  Open up the `menu.tscn` scene.
				2.  Add a `LineEdit` node in `VBoxContainer`, which holds the play and exit buttons, and rename it `IpAddressLineEdit`.
				3.  Place `IpAddressLineEdit` under the `PlayButton` node, but not as a child.

			![Figure 11.7 – The main menu scene tree with the added IpAddressLineEdit](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_07.jpg)

			Figure 11.7 – The main menu scene tree with the added IpAddressLineEdit

				1.  Select the `IpAddressLineEdit` node and set `IP ADDRESS`. This will show some placeholder text that will get replaced the moment the user puts anything into the line edit.

			![Figure 11.8 – Setting Placeholder Text in a LineEdit node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_08.jpg)

			Figure 11.8 – Setting Placeholder Text in a LineEdit node

				1.  Now, in the `menu.gd` script, cache the `IpAddressLineEdit` at the top:

    ```

    @onready var _ip_address_line_edit = $CenterContainer/MainUIContainer/PanelContainer/MarginContainer/VBoxContainer/VBoxContainer/IpAddressLineEdit

    ```cpp

    				2.  Lastly, we need to change the `_on_play_button_pressed()` function to host or connect to a game:

    ```

    func _on_play_button_pressed():

    if _ip_address_line_edit.text.is_empty():

    host_game()

    else:

    connect_to_game(_ip_address_line_edit.text)

    get_tree().change_scene_to_file("res://screens/game/main.tscn")

    ```cpp

			With all this in place, we have all that is needed to set up the client-server architecture. One computer will be the server and the others, the clients. Before we dive into the things we have to change in the code of the game itself, such as spawning playable characters for every person joining and then making sure the position of each player is synchronized between each computer, we can try out what we have already created.
			Running multiple debug instances at the same time
			To debug a multiplayer game, we need to be able to run our game multiple times in debug mode. Luckily, Godot Engine has a handy feature that allows us to run as many instances of our game as we want at the same time.

				1.  Click **Debug** in the top menu bar.
				2.  Under the **Run Multiple Instances** menu, choose **Run** **2 Instances**.

			![Figure 11.9 – In the Debug dropdown menu, we set the number of instances we want to run](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_09.jpg)

			Figure 11.9 – In the Debug dropdown menu, we set the number of instances we want to run

				1.  Run the project. This will make two instances of the game pop up at the same time.
				2.  In one instance, just press **Play**. The game should start up normally.
				3.  In the other instance, type `::1` in the IP address input field and then press **Play**.

			![Figure 11.10 – Specifying the ::1 IP address will loop back to the same computer](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_10.jpg)

			Figure 11.10 – Specifying the ::1 IP address will loop back to the same computer
			Unfortunately, you won’t see anything special happen. We still need to account for multiple players in our game code, but normally, there should be no errors in the bottom **Debug** panel.
			Local host IP address
			There is a special IP address that does not go to another computer but rather loops back to the same computer again. In the IPv6 format, this address is `::1`, and for IPv4, it is `127.0.0.1`.
			You’ll also see that there are now multiple tabs in the **Debug** panel, one for each instance of the game. This way, we will be able to debug each separately.
			![Figure 11.11 – When running multiple instances, we’ll also have multiple Debug tabs](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_11.jpg)

			Figure 11.11 – When running multiple instances, we’ll also have multiple Debug tabs
			Now that we are able to create a server and connect clients, let’s start by making our game multiplayer compatible and synchronizing spawned scenes between both games.
			Synchronizing different clients
			Until now, we learned about computer networking and set up a connection between multiple instances of our game. The next step is to change the scenes and code within our game to account for multiple players. We want to accomplish two things:

				*   Firstly, if the server instances a new scene, such as a new projectile, we want that scene to be instanced on every client
				*   Secondly, we want to synchronize values, such as the position of each player character, between all clients

			We’ll first look at which Godot Engine nodes can help us achieve these two goals while updating the player character to be used in multiplayer. After that, we’ll update the entity spawner, enemy, collectible, and projectile scenes, too. Most of these changes will be quite small.
			Updating the player scene for multiplayer
			Because the player is the most important entity in the game, let’s start by updating them for multiplayer. This way, we can quickly make sure everything is working correctly, too.
			Using MultiplayerSpawner to spawn player scenes
			To synchronize instanced scenes between the server and the clients, Godot Engine has a node called `MultiplayerSpawner`. It will listen to the scenes that are getting added to the scene tree and will replicate them on each of the other clients, too. Let’s add one to the main game scene:

				1.  Open the `main.tscn` scene.
				2.  Under the root `MultiplayerSpawner` node, and call it `PlayerMultiplayerSpawner`, because it will be spawning new player characters.

			![Figure 11.12 – The main.tscn scene tree with an added PlayerMultiplayerSpawner](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_12.jpg)

			Figure 11.12 – The main.tscn scene tree with an added PlayerMultiplayerSpawner

				1.  Now, in the inspector window for `PlayerMultiplayerSpawner`, press `player.tscn` scene into that element.

			![Figure 11.13 – Add the player.tscn scene as an element in PlayerMultiplayerSpawner](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_13.jpg)

			Figure 11.13 – Add the player.tscn scene as an element in PlayerMultiplayerSpawner

				1.  Now, to specify positions at which our players can spawn, add `Node2D`, called `PlayerStartPositions`, under the `Main` node with different `Marker2D` nodes where we can spawn players. Place each marker at a good spot to start a player from.

			![Figure 11.14 – The PlayerStartPositions node with Marker2D to spawn players at](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_14.jpg)

			Figure 11.14 – The PlayerStartPositions node with Marker2D to spawn players at

				1.  In the `main.gd` script, we’ll cache the player scene in an export variable. So, add the following line of code at the top and drag the `player.tscn` scene into this export variable in the inspector, too:

    ```

    @export var player_scene: PackedScene

    ```cpp

    				2.  Also, cache the `PlayerMultiplayerSpawner` node in a variable called `_player_multiplayer_spawner` and `PlayerStartPositions` in a variable called `_player_start_positions`:

    ```

    @onready var _player_multiplayer_spawner: MultiplayerSpawner = $PlayerMultiplayerSpawner

    @onready var _player_start_positions: Node2D = $PlayerStartPositions

    ```cpp

    				3.  We’ll also add a variable at the top of the script that specifies what position we will spawn the next player at. With this variable, we will select which `Marker2D` to use as to location to spawn each player at:

    ```

    main.gd 脚本:

    ```cpp
    func add_player(id: int):
       _player_multiplayer_spawner.spawn(id)
    func spawn_player(id: int):
       var player: Player = player_scene.instantiate()
       player.multiplayer_id = id
       player.died.connect(_on_player_died)
       var spawn_marker: Marker2D = _player_start_positions.get_childr(_player_spawn_index)
       player.position = spawn_marker.position
       _player_spawn_index = (_player_spawn_index + 1) % _player_start_positions.get_child_count()
       return player
    ```

    ```cpp

    				4.  To use these functions, we’ll add a `_ready()` function to the `main.gd` script:

    ```

    func _ready():

    _player_multiplayer_spawner.spawn_function = spawn_player

    if multiplayer.is_server():

    multiplayer.peer_connected.connect(add_player)

    add_player(1)

    ```cpp

    				5.  Lastly, but very importantly, delete the `Player` node that is already in the `main.tscn` scene. We do this because we’ll spawn each player character from code and so they don’t need the node to be in there already.

			In the `add_player()` function, we simply ask `_player_multiplayer_spawner` to spawn a new instance of the player scene.
			Then, in the `spawn_player()` function, which will be used by the `PlayerMultiplayerSpawner` to spawn new `Player` scenes, we instantiate a new player scene and set its `multiplayer_id` property to the  ID that we received as a parameter. This ID is used to determine which client owns that particular player node. We’ll use it in the next section. Afterward, we must return the new player instance so that the `PlayerMultiplayerSpawner` can handle the rest for us.
			We use the `_player_spawn_index` variable to select which `Marker2D` to select in `PlayerStartPositions`. After each player spawned, we increment this variable with `1` and make sure it loops back around with the `%` operator. This makes sure that we don’t spawn players on top of each other.
			In the `_ready()` function, first, we set `spawn_function` for `_player_multiplayer_spawner` to be the `spawn_player()` function that we defined. This way, the multiplayer spawner knows how to create new instances of the player scene.
			Then, you see that we check the `multiplayer` object if this code is being run on the server, using `multiplayer.is_server()`. This `is_server()` function returns `true` if the code is run on the server.
			If we are running on the server, we do the following:

multiplayer.peer_connected.connect(add_player)


			`peer_connected` is a signal that is thrown by the `multiplayer` object when a new peer (a new client) connects to the server. Instead of connecting through the editor, like we used to do for detecting whether the player is close to the collectibles, we directly call the `connect()` function on this signal and pass along the function that we want to execute when a player connects to the server, which is the `add_player()` function.
			After connecting to the `peer_connected` signal, we call the `add_player()` function with `1` as `id`, which is the default ID for the server.
			We will not yet be able to run the game for now, first, we need to update the player scene.
			Updating the player code for multiplayer
			When you try running the game with multiple instances at the end of the last section, you will notice that there are some things off, mainly that, on each client separately, you control both players at the same time.
			This behavior happens because, although we spawn a player per client, all code gets run all the time on each client separately. We have to specify that the movement code for each player character should only be run on the client associated with that player character, not all at once on all clients. Afterward, we should synchronize the position to the server.
			We’ll do this by setting the **multiplayer authority** of the player character node. This authority “owns” this node and decides how it behaves.
			So, let’s alter our code so the players work properly:

				1.  Firstly, add the `multiplayer_id` variable that we used in the last section somewhere at the top of the `player.gd` script:

    ```

    var multiplayer_id: int

    ```cpp

    				2.  Add an `_enter_tree()` function; this function is a life cycle function that gets called when the node enters the tree, right before the `_ready()` function. In this function, we set the multiplayer authority to the client that has the same ID as `multiplayer_id` of this player node:

    ```

    func _enter_tree():

    set_multiplayer_authority(multiplayer_id)

    ```cpp

    				3.  Cache the `CameraPosition` node at the top of the script:

    ```

    @onready var _camera_position: Node2D = $CameraPosition

    ```cpp

    				4.  Now, update the `_ready()` function like this:

    ```

    func _ready():

    update_health_label()

    if not multiplayer.is_server():

    _shoot_timer.stop()

    if not is_multiplayer_authority():

    _camera_position.queue_free()

    set_physics_process(false))

    ```cpp

			In *step 2*, we set the multiplayer authority for a node, which means that we determine which client is the owner of this node. For most nodes in multiplayer, the server should be the owner. But the player character is so important to each client that we give the authority of each to their respective client.
			After that, we use `multiplayer.is_server()` to stop `_shoot_timer` when we are not running on the server. This way, we make sure that projectiles only get spawned on the server side and replicated to all clients from there.
			Next, we use `is_multiplayer_authority()` to check whether we are the authority of this specific player node. If we are not, we free `_camera_position`. We don’t need multiple cameras, only the one that is used to track the player we want to see, and we also disable the `_physics_process()` function. Only the client that owns this node will have to calculate this player’s position and then report back to the server where the player is.
			Disabling the _process() and _physics_process() functions
			By default, the `_process()` and `_physics_process()` functions get called on each frame and physics frames, respectively. However, we can choose to enable or disable them manually by calling `set_process()` and `set_physics_process()` along with a Boolean that says whether they should run or not.
			After all this, you can run the game with multiple instances, like we saw in the *Running multiple debug instances at the same time* section, and you should see a second player spawn! Each player is able to move properly, but their positions are unfortunately not synchronized. We’ll do that next.
			Synchronizing the players’ positions and health
			We can spawn scenes across clients and determine on which client certain pieces of code should run. The last piece of the puzzle is to synchronize certain variables, like the position and health of our players. Luckily, this is actually very easy to do using the `MultiplayerSynchronizer` node. We are going to use two of these, one for the position and one for the health. Although one synchronizer can synchronize multiple variables, we want the position to be managed by each client individually and the health to be managed by the server:

				1.  In the `player.tscn` scene, add two `MultiplayerSynchronizer` nodes under the root `Player` node. Call on `PositionMultiplayerSynchronizer` and the other `HealthMultiplayerSynchronizer`.

			![Figure 11.15 – The player.tscn scene tree after adding two MultiplayerSynchronizer nodes](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_15.jpg)

			Figure 11.15 – The player.tscn scene tree after adding two MultiplayerSynchronizer nodes

				1.  Select `PositionMultiplayerSynchronizer` and a new panel should appear at the bottom of the editor.

			![Figure 11.16 – The Replication panel that opens up when selecting MultiplayerSynchronizer](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_16.jpg)

			Figure 11.16 – The Replication panel that opens up when selecting MultiplayerSynchronizer

				1.  Here, press **+ Add property** **to synchronize**.
				2.  Select the `Player` node and press **OK**.

			![Figure 11.17 – Select the Player node to synchronize one of its values](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_17.jpg)

			Figure 11.17 – Select the Player node to synchronize one of its values

				1.  Now, search for the `position` property and press **Open**.

			![Figure 11.18 – Select the position property to synchronize its value](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_18.jpg)

			Figure 11.18 – Select the position property to synchronize its value

				1.  Do *steps 2* to *5* again but add the `health` property to `HealthMultiplayerSynchronizer` this time.

			![Figure 11.19 – The Replication panel tracking the position value of the Player node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_19.jpg)

			Figure 11.19 – The Replication panel tracking the position value of the Player node

				1.  Now, update the `_enter_tree()` function of the player so that we give the multiplayer authority of `HealthMultiplayerSpawner` to the server:

    ```

    func _enter_tree() -> void:

    set_multiplayer_authority(multiplayer_id)

    1. 因此,为了将权限赋予服务器,我们将 HealthMultiplayerSynchronizer 的多玩家权限设置为 1。

    ```cpp

			That is all we need to do to synchronize values between different clients. `MultiplayerSynchronizer` simply tracks them for us.
			Running two instances of the game and connecting them finally shows that if we move one player character in one client, it also moves that player character in the other client.
			Now that we updated the hardest scene to multiplayer, the player scene, we have all the knowledge to do the same for the remaining scenes. Let’s dive in so that we have a complete multiplayer game at the end!
			Synchronizing EntitySpawner
			To make sure the enemy and health potion scenes are spawned on each client when the entity spawner wants to, we’ll have to make a few little adjustments to the `EnitySpawner` scene:

				1.  In the `entity_spawner.tscn` scene, add a `MultiplayerSpawner` node.

			![Figure 11.20 – The EntitySpawner scene tree after adding MultiplayerSpawner](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_20.jpg)

			Figure 11.20 – The EntitySpawner scene tree after adding MultiplayerSpawner

				1.  In the `entity_spawner.gd` script, cache the `MultiplayerSpawner` node:

    ```

    @onready var _multiplayer_spawner = $MultiplayerSpawner

    ```cpp

    				2.  Then, in the `_ready()` function, let’s add the scene this spawner uses to this `MultiplayerSpawner` node and only start the timer if we are running on the server. This ensures that not every client is spawning new entities, only the server:

    ```

    func _ready():

    _multiplayer_spawner.add_spawnable_scene(entity_scene.resource_path)

    if multiplayer.is_server():

    start_timer()

    ```cpp

    				3.  One last thing we need to do is change the exact way we add the `new_entity` to the scene. So, change the line with `add_child(new_entity)` to the following:

    ```

    add_child(new_entity, true)

    ```cpp

			In *step 3*, we add a spawnable scene to the `MultiplayerSpawner` node. This is very convenient as now we can add any scene on the fly.
			In *step 4*, we supply the Boolean `true` as a second parameter to the `add_child()` function, next to the node that we want to add to the scene tree. This indicates that we want to use human-readable names for each node, names that are easy for humans to read. When we don’t set this Boolean to `true`, the engine will pick a name for the node. These names look like `@Node2D@2`. These are reserved names that cannot be synchronized using a `MultiplayerSpawner` node. When we do set this Boolean to `true`, each new instance gets nicely named, for example, `Enemy2`, `Enemy3`, and so on. In a multiplayer scenario, this is important for the server to properly synchronize scenes and values between them.
			Now that we can synchronize the spawned entities of enemies and collectibles between clients, let’s synchronize their behavior, too.
			Synchronizing the enemy and collectibles
			For both the enemy and all collectibles, making them work with multiplayer is quite easy and straightforward:

				1.  Add `MultiplayerSynchronizer` to the `enemy.tscn` and `collectible.tscn`.

			![Figure 11.21 – The scene tree of the Collectible after adding MultiplayerSynchronizer](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_21.jpg)

			Figure 11.21 – The scene tree of the Collectible after adding MultiplayerSynchronizer

				1.  Now, add the `position` property of the root node in the **Replication** menu at the bottom.

			![Figure 11.22 – The Replication panel tracking the position of the Collectible node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_22.jpg)

			Figure 11.22 – The Replication panel tracking the position of the Collectible node
			That is about it for `Collectible`, while for `Enemy`, we need to do some last things in the code:

				1.  Cache `PlayerDetectionArea` at the top of the `enemy.gd` script:

    ```

    @onready var _player_detection_area: Area2D = $PlayerDetectionArea

    ```cpp

    				2.  Now, update the `_ready()` function like this:

    ```

    func _ready():

    if not multiplayer.is_server():

    set_physics_process(false)

    _player_detection_area.monitoring = false

    return

    var player_nodes: Array = get_tree().get_nodes_in_group("player")

    if not player_nodes.is_empty():

    target = player_nodes.pick_random()

    ```cpp

			The first thing we do in the `_ready()` function of the enemy is disable the `_physics_process()` function and `_player_detection_area` if we are not running them from the server. This makes sure that enemies are fully controlled by the server.
			The `Area2D` nodes have a property, `monitoring`, that stops looking for collisions with other areas or bodies when set to `false`. This is what we are using here to disable `_player_detection_area` on other clients than the server.
			Lastly, we want to be able to target any of the players in the game, so we change how to target a player. The `pick_random()` function on an array will pick any element within that array at random and return it. This is ideal for picking a random player within the scene!
			Let’s now look at how we can synchronize the projectiles.
			Synchronizing the projectile
			The last scene we need to synchronize between the multiple clients is the one of the projectiles. So, let’s do that with the following steps:

				1.  In the `projectile.tscn` scene, add `MultiplayerSynchronizer`.

			![Figure 11.23 – The scene tree of Projectile after adding MultiplayerSynchronizer](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_23.jpg)

			Figure 11.23 – The scene tree of Projectile after adding MultiplayerSynchronizer

				1.  This time, synchronize both the `position` and `rotation` properties.

			![Figure 11.24 – The Replication panel tracking the position and rotation of the Projectile node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_24.jpg)

			Figure 11.24 – The Replication panel tracking the position and rotation of the Projectile node
			Cache `EnemyDetectionArea` at the top of the `projectile.gd` script:

@onready var _enemy_detection_area: Area2D = $EnemyDetectionArea


				1.  Now, add a `_ready()` function as follows:

    ```

    func _ready():

    if not multiplayer.is_server():

    set_physics_process(false)

    _enemy_detection_area.monitoring = false

    ```cpp

    				2.  We need to change the way the projectile is added to the scene within the `player.gd` script from `get_parent().add_child(new_projectile)` to the following:

    ```

    get_parent().add_child(new_projectile, true)

    ```cpp

			Important note
			Remember that the last parameter of the `add_child()` function is a Boolean that determines that the name of the new node should be human readable.

				1.  Lastly, we need to make sure that the `projectile.tscn` scene is replicated in the main scene, just like we did for the `player.tscn` scene. Add a `MultiplayerSpawner` node in the `main.tscn`, call it `ProjectileMultiplayerSpawner`, and add `projectile.tscn` in **Auto** **Spawn List**.

			![Figure 11.25 – Main scene with ProjectilMultiplayerSpawner with the projectile.tscn scene](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_25.jpg)

			Figure 11.25 – Main scene with ProjectilMultiplayerSpawner with the projectile.tscn scene
			That is it for the `Projectile` scene and thereby all the scenes important to playing the game itself! You can now run multiple instances of the game and everything within the game should be synchronized. The last thing we’ll need to look at is synchronizing the timers within the game and the game-over menu for both players.
			Fixing the timer and end game
			The last thing we need to adjust for multiplayer is the timer that times our run and the end of the game, stopping the entity spawners and showing the game-over menu. So, let’s get started on this last effort.
			Synchronizing the timer
			To synchronize the score timer, we simply have to do the following three things:

				1.  Add `MultiplayerSynchronizer` to the `main.tscn` scene.

			![Figure 11.26 – The Main scene tree after adding MultiplayerSynchronizer](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_26.jpg)

			Figure 11.26 – The Main scene tree after adding MultiplayerSynchronizer

				1.  Synchronize the `_time` property of the `Main` node.

			![Figure 11.27 – The Replication panel tracking the _time property of the Main node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_27.jpg)

			Figure 11.27 – The Replication panel tracking the _time property of the Main node

				1.  Now, disable the `_process()` function from within `_ready()` if we are not running on the server:

    ```

    func _ready():

    # 其他代码

    if not multiplayer.is_server():

    set_process(false)

    ```cpp

			That is all we need to do synchronize the timer across all clients.
			Synchronizing the end of the game
			To make sure that when the game ends, it ends for all clients, let’s do the following:

				1.  In the `main.gd` script, let’s connect to each player character’s `died` signal in the `add_player()` function:

    ```

    func add_player(id: int):

    var player: Player = player_scene.instantiate()

    player.name = str(id)

    add_child(player)

    _on_player_died() 函数和添加一个新函数 end_game():

    ```cpp
    func _on_player_died() -> void:
       end_game.rpc()
    @rpc("authority", "reliable", "call_local")
    func end_game():
       _game_over_menu.show()
       _enemy_spawner.stop_timer()
       _health_potion_spawner.stop_timer()
       set_process(false)
       Highscore.set_new_highscore(_time)
    ```

    ```cpp

    				2.  Then, in the `menu.gd` script, change the `_ready()` function to the following:

    ```

    func _ready():

    _highscore_label.text = "Highscore: " + str(Highscore.highscore)

    if multiplayer.has_multiplayer_peer():

    multiplayer.multiplayer_peer.close()

    ```cpp

			In the first step, we simply connect to each player’s `died` signal through code.
			Important note
			Note that only the server connects to the `died` signal because it is the server that manages the game loop.
			In the second step, we do something very interesting. We call the `end_game()` function through **RPC**, which means that we call it on every client at the same time!
			Important note
			**Remote procedure call** (**RPC**) is a protocol that makes functions directly callable over different clients. This makes it easy to execute the same code on all connected instances of the game at the same time.
			You can see that we use the `@rpc` annotation right before the `end_game()` function. This is to indicate how we would like this function to be handled when calling on every client at once. The strings we pass it along mean the following:

				*   `"authority"`: Only the one with authority, the server, in this case, can call this function.
				*   `"reliable"`: We want this command to be sent reliably over the network, using TCP.
				*   `"``call_local"`: This function, when called, should be executed on all clients, including the one that called it.

			This means that the game-over menu will be shown on every client from the moment one of the players dies.
			In the third step, we simply close the multiplayer connection, when there is one, and we open up the main menu. This way, we make sure we don’t stay connected while we are not playing anymore.
			Now that the whole game is ready to be played in multiplayer, let’s get started on actually running it on multiple machines at the same time!
			Running the game on multiple computers
			Until this point, we’ve been running multiple instances of our game on the same machine. But the strength of multiplayer comes from playing with multiple people over multiple machines.
			In this section, we’ll start off by showing the server’s IP address on screen and then look into how we can run a debug instance on multiple computers at the same time so they can connect.
			Showing the IP address of the server
			We have been using `::1` as the IP address that loops back to the same computer so that we can debug our game. However, before we can connect to another computer over a network, we need to know their real IP address. To do this, we’ll show the server’s IP address on the screen when they are hosting a game.
			![Figure 11.28 – The server has an IP address displayed at the bottom of the screen to connect](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_28.jpg)

			Figure 11.28 – The server has an IP address displayed at the bottom of the screen to connect
			In *Figure 11**.28*, you can see that we want to show the IP address at the bottom of the screen. Let’s get to it:

				1.  In the `main.tscn` scene, add `CenterContainer` with `Label` as a child, just like we did for the timer. Give them names like in *Figure 11**.29*.

			![Figure 11.29 – The CanvasLayer node with NetworkUI](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_11_29.jpg)

			Figure 11.29 – The CanvasLayer node with NetworkUI

				1.  Now, in the `main.gd` script, cache `IPLabel` at the top:

    ```

    @onready var _ip_label = $CanvasLayer/NetworkUI/IPLabel

    ```cpp

    				2.  Next, add this function that shows the local IP address:

    ```

    func show_local_ip_address():

    var addresses = []

    for ip in IP.get_local_addresses():

    if ip.begins_with("10.") or ip.begins_with("172.16.") or ip.begins_with("192.168."):

    addresses.push_back(ip)

    if not addresses.is_empty():

    _ip_label.text = addresses[0]

    ```cpp

    				3.  Now, call this function in the `_ready()` function, but only if we’re running on the server:

    ```

    func _ready():

    if multiplayer.is_server():

    show_local_ip_address()

    # …

    ```cpp

			Don’t worry too much about the implementation of the `show_local_ip_address()` function. The basis is that it will search for the local IP address by scanning all the network addresses of the current computer and saving the ones that start with `"10."`, `"172.16."`, or `"192.168."`, which are the know beginnings for local IP addresses. The reasons why it works are a little obscure and beyond the scope of this book.
			Now that we know what IP address the server has, let’s see how we can actually set everything up to connect two computers together.
			Connecting from another computer
			The big caveat for now, which we already mentioned in the introduction of the chapter, is that we will not be able to play over the real worldwide internet. This is because of multiple security reasons; you wouldn’t want strangers to have direct access to your computer. However, we will be able to play on the same local network. This means that two computers that are connected to the same router, the same Wi-Fi network, and so forth, will be able to connect to each other in the game! All we’ll have to do is the following:

				1.  Transfer the complete Godot project to another computer. You can do this any way you like. With a USB, using an online platform such as Dropbox, Google,Drive, or any other means of transferring files.
				2.  Make sure both computers are connected to the same local network.
				3.  Open the project in the same Godot Engine version as you are using.
				4.  Run a debug instance of the game on each computer.
				5.  Press play on one computer, making it the server. Use the IP address the server displays to connect to the other clients.

			Now, you should be able to play together over the network!
			That is all for connecting multiple computers. We’ll proceed with a summary of the chapter, but first, here are some additional exercises to solidify our knowledge.
			Additional exercises – Sharpening the axe

				1.  When the game ended, we got a menu with a `add_player()` function for each pair that is connected in the `_ready()` function of the `main.gd` script. You can get a list of all the peer IDs with `multiplayer.get_peers()`.

			Summary
			The joy in playing video games is sharing the experience and nothing makes that easier than directly playing together!
			In this chapter, we started by taking a crash course in computer networking where we learned the basics of how computer networks, such as the internet, work. After this, we started to implement multiplayer into our own game using the `MultiplayerSpawner` and `MultiplayerSynchronizer` node. Lastly, we tried out playing the game over a real network.
			This chapter marks the end of *Part 2* of the book, where we focused on learning how to develop our game and doing so. Starting from the next chapter, we’ll learn how to export a game, go a little deeper into more advanced programming topics, and see how we can save or load the game.
			Quiz time

				*   What is the difference between the TCP and UDP?
				*   If we take the example of a residence with flats, where the port number is the flat number, what does the IP address represent?
				*   What did we use `MultiplayerSpawner` for?
				*   What did we use `MultiplayerSynchronizer` for?
				*   What function would we use to check whether the current script is running on the server?

第三部分:深化我们的知识

在学习如何编程并从头开始创建你自己的游戏之后,你现在将退一步,学习一些更高级的编程和游戏开发技术。

在本最终部分的结束时,你将把你的游戏导出并分发到网络上的各种不同平台,以便每个人都可以在他们的浏览器中玩。你还将学习更多高级的面向对象编程概念和不同的编程模式,这些模式将有助于你在未来的游戏项目中。文件系统也将被涵盖,这样你就可以保存和加载数据。最后一章将指导你进行下一步,你可以咨询哪些资源来学习更多,以及如何加入游戏开发社区。

本部分包含以下章节:

  • 第十二章导出到多个平台

  • 第十三章面向对象编程的继续与高级主题

  • 第十四章高级编程模式

  • 第十五章使用文件系统

  • 第十六章接下来是什么?

第十章:导出到多个平台

制作游戏后,我们应该将其交给玩家。毕竟,游戏是用来玩的!在以前,这意味着烧制成千上万的 CD,并将它们分发到世界各地的实体游戏店,希望人们会购买。这需要巨额的资金和人力。大型工作室通常只能看到 10% 的利润,因为其余的利润都消耗在购买实体 CD 和支付分销和店铺折扣上。即使你开发了一款成功的热门游戏,分销的前期投资也可能使其功亏一篑。

随着互联网和游戏平台如 SteamItch.io 的兴起,分发成本已经大幅降低,有时甚至免费,而且更加容易,几乎不需要前期投资。

在本章的整个过程中,我们将学习关于为我们的游戏导出生产构建的方方面面,甚至将其上传到 Itch.io(如果你愿意的话)。

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

  • 为 Windows、Mac 和 Linux 导出游戏

  • 将我们的游戏上传到 Itch.io

  • 将我们的游戏导出到其他平台

技术要求

就像每一章一样,你可以在 GitHub 仓库的子文件夹中找到本章的最终代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter12

导出到 Windows、Mac 和 Linux

导出是将我们正在开发的游戏制作成可在 Godot 之外可执行的过程。是的,我们可以在引擎编辑器中以调试模式运行我们的游戏,但理想情况下,我们不想与玩家分享我们的游戏代码。我们也不想让玩家首先下载包含编辑器的整个引擎,然后才是我们的游戏。

当为电脑导出时,我们需要为每个不同的操作系统进行单独的导出,例如 Windows、macOS 和 Linux。操作系统是一种管理计算机所有硬件和常见功能的软件。

无论你使用什么类型的 操作系统OS),Windows、Mac 还是 Linux,Godot 都允许我们将游戏导出到任何其他平台。这样,我们可以轻松地为所有用户制作导出版本,无论他们更喜欢哪个平台。

在我们可以导出到任何平台之前,我们需要下载导出模板。

下载导出模板

要导出任何内容,Godot 引擎使用一个模板来告诉它如何实际导出我们的游戏,这个模板对于每个 Godot 引擎版本都是独特的,称为 导出模板。一旦我们有了模板,我们就可以导出到任何内置平台。我们可以轻松地从 Godot 编辑器本身下载这个导出模板,如下所示:

  1. 在顶部菜单栏中,打开 编辑器 下拉菜单,然后点击 管理导出 模板...

图 12.1 – 通过编辑器下拉菜单访问导出模板管理器

图 12.1 – 通过编辑器下拉菜单访问导出模板管理器

  1. 点击下载 并安装

图 12.2 – 在导出模板管理器中,我们可以下载和安装导出模板

图 12.2 – 在导出模板管理器中,我们可以下载和安装导出模板

下载和安装导出模板需要一些时间,但您只需为每个使用的 Godot 引擎版本做一次即可。现在,我们已准备好进行适当的导出。

制作游戏的实际导出

在放置了我们给定 Godot 引擎版本的导出模板后,我们最终可以导出游戏。我们将使用导出菜单来完成这项工作。让我们开始吧:

  1. 在顶部菜单栏中,打开项目下拉菜单并点击导出...

图 12.3 – 通过项目下拉菜单访问导出…菜单

图 12.3 – 通过项目下拉菜单访问导出…菜单

  1. 这将打开导出菜单,其中包含每个导出预设的导出设置。目前,它不包含任何导出预设。

  2. 点击添加...并选择您现在正在使用的计算机平台(Linux/X11macOSWindows 桌面)。

图 12.4 – 在导出菜单中添加特定平台的预设

图 12.4 – 在导出菜单中添加特定平台的预设

  1. 该平台将被添加到预设列表中。

图 12.5 – 将 Windows 桌面平台添加为预设之一

图 12.5 – 将 Windows 桌面平台添加为预设之一

  1. 根据您添加的平台配置导出:

    • com.survivor.game或类似的内容。

图 12.6 – 对于 macOS,您必须指定捆绑标识符

图 12.6 – 对于 macOS,您必须指定捆绑标识符

  • 仅限 Windows 和 Linux:启用嵌入 PCK选项。这将确保我们的游戏 PCK 文件(包含所有游戏数据的包文件,如代码和艺术)嵌入到游戏的可执行文件中。

图 12.7 – 对于 Windows 和 Linux,启用嵌入 PCK 选项

图 12.7 – 对于 Windows 和 Linux,启用嵌入 PCK 选项

  1. 现在,点击导出项目...以导出游戏。

  2. exports文件夹下创建一个新的文件夹,其中您可以创建每个平台的文件夹。因此,如果您为 Windows 导出,请将其放在exports/windows下。这只是为了给我们的导出提供一些结构。

  3. 禁用带有调试的导出选项。

图 12.8 – 禁用带有调试的导出选项

图 12.8 – 禁用带有调试的导出选项

  1. 保存

我们现在将在我们刚刚创建的文件夹中有目标平台的游戏导出。为每个平台重复本节中的步骤。

图 12.9 – Windows 平台的导出游戏

图 12.9 – Windows 平台的导出游戏

重要提示

你可能已经注意到导出菜单中还有一个名为 导出全部... 的按钮。当所有你想要导出的平台都设置好时,你可以按下这个按钮来同时导出所有平台。

导出后,就是时候发布游戏并与全世界分享它了。

将我们的游戏上传到 Itch.io

制作完一个游戏后,与认识的人或完全陌生的人分享它,看看他们如何互动和玩你制作的东西,这非常有趣。在本节中,我们将介绍将游戏上传到名为 Itch.io 的在线平台的过程。如果你出于任何原因不想分享你的游戏,现在可以跳过这一节;当你准备好时再回来。但不要过于担心分享游戏是否太早。玩家的反馈总是好事,无论你处于开发哪个阶段,创建 Itch.io 页面的这些步骤也是很好的练习。

什么是 Itch.io?

Itch.io 是一个在线平台和商店,人们可以在这里分发游戏、资产(制作游戏用)、或其他任何数字资源、文件或程序。它在游戏开发社区中非常知名,因为许多开发者都在那里发布了他们的小型游戏实验,但你也可以找到更大的项目。

图 12.10 – Itch.io 的首页

图 12.10 – Itch.io 的首页

Itch.io 也是参加 游戏快闪 的好地方。游戏快闪是一个在线小活动,你需要在一定时间限制内围绕一个特定主题制作游戏。时间限制和主题因游戏快闪而异。之后,所有参与者都会玩彼此的游戏并给出反馈。这是练习创建游戏并从其他游戏开发者那里获得反馈的好方法。

我们将把我们的电脑构建上传到 Itch.io 平台,但酷的是我们还可以上传一个可以在浏览器内玩的游戏构建,这样人们就不需要下载任何文件 – 他们可以直接开始玩。如果我们上传了正常导出和网页导出,人们仍然可以选择他们想要如何玩,这给了他们更多的选择。

将我们的游戏导出到网页

将游戏导出到网页意味着我们制作了一个可以包含在任何网站中并在任何浏览器中(包括移动浏览器)可玩的游戏导出。

重要提示

注意,当我们制作网页导出时,它只有在通过服务器运行时才能玩。你无法在没有在电脑上运行服务器或将其托管在在线平台上时直接在你的浏览器中打开导出。幸运的是,我们将在 Itch.io 上托管我们的游戏,所以你将能够在那里玩它。

我们首先需要做的是暂时移除游戏的多人模式。

移除多人模式

由于安全原因,网站永远不应该完全访问您的计算机。这就是为什么网络导出有一些限制,例如性能和网络略有下降。这意味着我们的游戏多人模式在网页导出中不会工作。有方法可以使多人模式在网页导出中工作,但这些超出了本书的范围。只需进行一个小小的更改,它仍然可以很好地用于单人游戏:

menu.gd脚本的_ready()函数中,添加以下行:

func _ready():
   _ip_address_line_edit.visible = OS.get_name() != "Web"
   # Rest of the _ready function

OS.get_name()函数给我们提供了游戏当前运行的操作系统名称。这确保了当玩家在网页上玩游戏时,IP 地址的输入字段不再可见,这样他们就不会卡在尝试连接上,这可能会破坏他们的游戏体验。

在禁用多人模式后,让我们看看如何制作网页导出。

制作实际的网页导出

制作网页导出的过程与导出到计算机平台一样简单;我们只需为Web平台添加一个新的预设,并正常导出项目。

图 12.11 – 为 Web 平台添加导出预设

图 12.11 – 为 Web 平台添加导出预设

我们必须确保的一件事是,在保存文件时,将其命名为index.html,如图图 12**.12所示。Itch.io 要求这个名称,因为它会寻找一个index.html文件在浏览器中运行游戏。

图 12.12 – 确保将网页导出保存为 index.html

图 12.12 – 确保将网页导出保存为 index.html

现在我们有了导出文件,我们需要将其制作成 ZIP 文件。让我们学习如何做到这一点。

压缩网页导出

Itch.io 将需要一个包含所有导出文件的 ZIP 文件,用于网页导出。ZIP 文件是一种将多个其他文件捆绑成压缩格式的文件,这使得它们更容易传输,同时也使内容更小。

根据您使用的平台,过程略有不同。

对于 Windows 和 macOS,请按照以下步骤操作:

  1. 选择所有导出文件。

图 12.13 – 选择所有导出的文件

图 12.13 – 选择所有导出的文件

  1. 现在,右键单击它们,以便出现选项菜单。

    • 对于 Windows:在发送到下,选择压缩(zipped)文件夹

图 12.14 – 在 Windows 平台上,选择“压缩(zipped)文件夹”

图 12.14 – 在 Windows 平台上,选择“压缩(zipped)文件夹”

  • 对于 macOS:选择压缩

图 12.15 – 在 macOS 平台上,选择“压缩”

图 12.15 – 在 macOS 平台上,选择“压缩”

  1. 现在,将 ZIP 文件命名为Survivor Game Web

图 12.16 – 生成的 ZIP 文件

图 12.16 – 生成的 ZIP 文件

对于 Linux,请按照以下步骤操作:

  1. 在您的计算机上打开终端应用程序。

  2. 使用cd ~/path/to/game/export/folder命令(确保路径正确),导航到游戏的网页导出文件夹。

  3. 现在,运行zip -r "Survivor Game Web.zip" .命令来创建一个包含此文件夹内容的 ZIP 文件。

图 12.17 – 创建 ZIP 文件后 Linux 终端的界面

图 12.17 – 创建 ZIP 文件后 Linux 终端的界面

所有导出文件都已准备好并压缩后,是时候将游戏上传到 Itch.io 了。

上传到 Itch.io

现在一切准备就绪,我们可以将我们的游戏上传到 Itch.io 平台,并创建一个人们可以玩游戏和下载游戏的页面:

  1. itch.io/register创建一个账户。请确保勾选旁边我感兴趣在 itch.io 上分发内容的复选框,因为这正是我们想要做的。

图 12.18 – 在注册 Itch.io 时,指明我们想要分发内容

图 12.18 – 在注册 Itch.io 时,指明我们想要分发内容

  1. 注册后,您首先需要通过打开 Itch.io 发送到您提供的账户电子邮件地址的电子邮件,并点击按钮点击以验证您的 电子邮件地址来验证您的电子邮件地址。

图 12.19 – 验证您的电子邮件地址

图 12.19 – 验证您的电子邮件地址

  1. 在浏览器中,您应该已经被带到创作者仪表板页面。点击那个写着创建 新项目的大红色按钮。

图 12.20 – 点击创建新项目以开始创建游戏页面

图 12.20 – 点击创建新项目以开始创建游戏页面

  1. 给项目命名为Survivor Game

图 12.21 – 给项目命名为 Survivor Game

图 12.21 – 给项目命名为 Survivor Game

  1. 项目类型下,选择HTML。这将确保人们可以在他们的浏览器中玩游戏。

图 12.22 – 将项目类型设置为 HTML

图 12.22 – 将项目类型设置为 HTML

  1. 现在,在上传部分,上传每个导出的 ZIP 文件。

图 12.23 – 上传所有导出文件

图 12.23 – 上传所有导出文件

  1. 接下来,指明每个导出文件针对的平台。这将帮助人们了解他们需要下载哪个文件来适配他们的平台。

图 12.24 – 对于 Windows、macOS 和 Linux 平台,我们应该指明平台

图 12.24 – 对于 Windows、macOS 和 Linux 平台,我们应该指明平台

  1. 对于包含网页导出的 ZIP 文件,选择此文件将在 浏览器中播放。

图 12.25 – 对于网页导出的 ZIP 文件,指明它可以在浏览器中播放

图 12.25 – 对于网页导出的 ZIP 文件,指明它可以在浏览器中播放

  1. 1152 px × 648 px。这是我们游戏在 Itch 页面上的窗口大小。这些尺寸是我们项目设置中的确切尺寸。

图 12.26 – 对于 Web 导出 ZIP,指明可以在浏览器中播放

图 12.26 – 对于 Web 导出 ZIP,指明可以在浏览器中播放

  1. 帧选项下,启用SharedArrayBuffer 支持;这对于 Godot Engine 4 的 Web 导出是必需的。

图 12.27 – 启用 SharedArrayBuffer 支持

图 12.27 – 启用 SharedArrayBuffer 支持

  1. 点击保存并查看页面

图 12.28 – 点击保存并查看页面

图 12.28 – 点击保存并查看页面

  1. 我们将预览我们的页面看起来是什么样子。你会看到游戏需要首次加载。要公开发布,我们需要回到编辑页面。

图 12.29 – 首次加载时,游戏将需要额外的时间

图 12.29 – 首次加载时,游戏将需要额外的时间

  1. 这次,在可见性与访问下选择公开

图 12.30 – 在可见性与访问下选择公开

图 12.30 – 在可见性与访问下选择公开

  1. 再次按保存

游戏现在有自己的页面,并且对所有人可见!向你的朋友发送链接,并在社交媒体上分享 – 你发布了一款游戏!

图 12.31 – 我们游戏的 Itch.io 页面

图 12.31 – 我们游戏的 Itch.io 页面

按下运行游戏将在浏览器内运行我们的游戏,而页面底部的下载按钮可以帮助我们下载我们想要的任何平台的游戏。

我们知道如何为大多数计算机和网页进行基本导出,但其他平台,如移动设备和游戏机呢?让我们接下来看看。

将我们的游戏导出到其他平台

现在我们的游戏已经发布,任何人都可以玩,让我们快速看看如何导出到不是常规计算机的其他平台。

移动平台

对于移动设备,如 Android 和 iOS 设备,过程稍微复杂一些。然而,一旦设置好,它将非常可靠。你可以在官方 Godot Engine 文档中找到导出到移动平台的指南:

除了简单地导出游戏外,你还需要考虑到移动设备,主要是没有外部按钮,所以游戏玩法应该考虑到触摸屏控制。

控制台

让我们来直面问题——关于导出至游戏机,例如PlayStationXboxNintendo游戏机怎么办?

好消息是,这是可能的!坏消息是,由于 Godot 引擎是开源的,而导出至这些游戏机所需的代码库是闭源的,因此这些导出选项不能包含在基础 Godot 版本中。所以,默认情况下,它们不在引擎中。

然而,有一些公司提供专门针对 Godot 引擎的版本,用于导出至游戏机以及/或帮助移植整个游戏。这些公司包括W4(一家雇佣了许多原始 Godot 引擎开发者的公司)、Pineapple WorksLone Wolf Technology。您可以在 Godot 文档网站上找到最新的列表:docs.godotengine.org/en/stable/tutorials/platform/consoles.html

概述

在本章中,我们学习了如何将我们的游戏导出至多个计算机平台,例如 Windows、Mac 和 Linux。我们还看到了如何将游戏导出至网页并上传到 Itch.io。现在,我们已经准备好创建一个完整游戏并发布它了!

在下一章中,我们将学习面向对象编程的更多高级技术。

测验时间

  • 导出模板是什么?

  • 为什么在导出至 Windows 和 Linux 时我们启用了嵌入 PCK

  • 我们可以使用 Godot 引擎导出至哪些平台?

第十一章:面向对象编程的继续和高级主题

在这本书的整个过程中,我们学习了大量的编程知识,从基本的变量、控制流和类到 GDScript 特有的内容,例如访问场景树中的节点和特定的注释。然而,不要误解——还有更多知识可以帮助我们更容易、更快地解决问题。

经过多年的学习和专业应用我的编程技能,我可以自信地说,计算机科学是一个深奥且值得继续学习的领域。此外,每隔几年就会有一种新技术崭露头角,等待我们去研究。

在本章中,我们将探讨一系列更高级的技术和概念,这将使你的编程技能达到新的高度!

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

  • super关键字

  • 静态变量和函数

  • 枚举

  • Lambda 函数

  • 通过值或引用传递参数

  • @tool注释

技术要求

与每一章一样,你可以在 GitHub 仓库的该章节子文件夹中找到最终代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter13

super关键字

第四章中,我们学习了继承以及如何在继承类中覆盖基类中的函数。这种覆盖用全新的主体替换了函数,并丢弃了基类的原始实现。然而,有时我们仍然希望执行在父类中定义的原始逻辑。

为了实现这一点,我们可以使用super关键字。这个关键字让我们直接访问当前类基于的父类的所有函数。考虑以下示例,我们想在游戏中拥有不同种类的箭来射击敌人:

class BaseArrow:
   func describe_damage():
      print("Pierces a person")
class FireArrow extends BaseArrow:
   func describe_damage():
      super()
      print("And sets them ablaze")

在这里,我们定义了一个BaseArrow类,它是所有种类箭的基础。它有一个函数describe_damage(),该函数只是通过将Pierces a person打印到控制台来描述箭造成的伤害。

当我们覆盖FireArrow类的describe_damage()函数时,我们首先将super()作为一个函数调用。这将执行BaseArrow类的原始describe_damage()函数,然后再执行其余部分。

让我们执行一些使用这些类的代码:

var fire_arrow: FireArrow = FireArrow.new()
fire_arrow.describe_damage()

结果将如下所示:

Pierces a person
And sets them ablaze

你可以看到,使用super()关键字执行了基类中的describe_damage()函数,以及FireArrow类其余的实现。

super关键字提供了访问我们继承的底层类的方法;无论我们是否覆盖了该函数,它总是会返回原始的函数。让我们继续探讨另一个新关键字——static

静态变量和函数

我们接下来要查看的下一个关键字是static。我们可以通过在这个关键字前放置它来声明一个变量或函数为静态:

class Enemy:
   static var damage: float = 10.0
   static func do_battle_cry():
      print("Aaaaaargh!")

静态变量和函数是在类本身上声明的。这意味着它们可以在不创建类实例的情况下访问:

print(Enemy.damage)
Enemy.do_battle_cry ()

静态变量是为了包含与整个对象类绑定在一起的信息。但要注意 – 以下是静态变量和函数的两个大陷阱:

  • 在 GDScript 中,静态变量可以被赋予新的值,你可以在游戏执行过程中更改它们。理想情况下,你不想这样做,因为这可能会以难以调试的方式影响你的程序。

  • 从一个静态函数中,你可以调用其他函数并使用类的成员变量,但前提是它们也被定义为静态。因为静态函数是在类本身上定义的,它们没有初始化对象的全部上下文。静态函数需要非常自包含。

总的来说,在 GDScript 中,你不会经常看到静态变量和函数,但它是一个在许多面向对象编程语言中,如 C++或 Java,广为人知的概念。接下来,让我们看看枚举。

枚举

枚举,简称枚举,是一种变量类型,它定义了一组需要组合在一起的常量。与我们要存储特定值的普通常量不同,枚举会自动为常量分配值。

第二章第五章中,我们看到了拥有良好命名的变量非常重要。这样,我们总能知道它们将包含什么。实际上,我们也可以为变量的值使用命名值。使用命名值,我们可以将一个可读性强的名称与某个值关联起来,使代码更易于阅读。它还从代码中移除了魔法数字。看看这个枚举:

enum DAMAGE_TYPES {
   NONE,
   FIRE,
   ICE
}

这里,我们创建了一个名为DAMAGE_TYPES的枚举,它定义了三个命名值 – NONEFIREICE。你可以这样访问这些值:

DAMAGE_TYPES.FIRE

让我们尝试将它们打印出来:

print(DAMAGE_TYPES.NONE)
print(DAMAGE_TYPES.FIRE)
print(DAMAGE_TYPES.ICE)

你会看到它打印出以下内容:

0
1
2

这是因为枚举内的每个名称都与一个整数值相关联。然而,我们不再使用这些粗略的整数,现在我们可以使用易于阅读的名称。第一个命名值与0相关联,每个随后的值递增 1。

枚举也可以用来类型提示变量;这样,我们知道变量需要被分配来自某个类型的枚举值:

var damage_type: DAMAGE_TYPES = DAMAGE_TYPES.FIRE
match damage_type:
   DAMAGE_TYPES.NONE:
      print("Nothing special happens")
   DAMAGE_TYPES.FIRE:
      print("You catch fire! ")
   DAMAGE_TYPES.ICE:
      print("You freeze!")

在这个例子中,我们将damage_type变量类型提示为DAMAGE_TYPES。然后,例如,我们可以匹配这个变量并确定要执行的操作。

枚举与字符串的比较

现在,你可能会想,“为什么我们不用字符串来读取值呢?”简单来说,这是因为字符串与整数(枚举值的底层数据类型)相比,处理起来更慢且占用更多内存。另一个原因是易用性。枚举有一组有限的值,即我们定义的值,而字符串可以有任意数量的字符。因此,当我们使用枚举时,我们可以确信我们只处理我们知道的值。

我们还可以从完全不同的类中访问在一个类中定义的枚举;就像静态变量和函数一样,它们可以直接从类类型访问:

class Arrow:
   Enum DAMAGE_TYPES {
      NONE,
      FIRE
   }
func _ready():
   var damage_type: DAMAGE_TYPES enum from within the Arrow class. Later, we can access this enum by using Arrow.DAMAGE_TYPES directly.
			In this section, we looked at enums, named values that help us by providing human-readable labels. Next, we’ll take a look at lambda functions.
			Lambda functions
			So far, every function we have written belonged to a class or file, which could be treated as a class, but there is actually a way to define functions separately from any class definition. These kinds of functions are called **lambda functions**.
			Creating a lambda function
			Let’s take a look at a lambda function:

var print_hello: Callable = func(): print("Hello")


			You can see that we’ve defined a function, just as we normally do, but this time without a function name. Instead, we assigned the function to a variable. This variable now contains the function in the form of the `Callable` object type. We can call a `Callable` object later on, like this:

print_hello.call()


			This will run the function that we defined and, thus, print out `Hello` to the console.
			Lambda functions, just like normal functions, can take arguments too:

var print_largest: Callable = func(number_a: float, number_b: float):

if number_a > number_b:

print(number_a)

else:

print(number_b)


			In this example, you can also see that lambda functions can contain multiple lines of code in the form of a code block, where each line has the same level of indentation.
			Where to use lambda functions
			So, where would we use lambda functions? Well, they are very useful in scenarios where you need a relatively small function but don’t want to have it as a permanent residence in the class.
			One great application of lambda functions is connecting signals. If we have a button for example, then we can connect to its pressed signal using a lambda function, as follows:

button.connect("pressed", pressed signal, 我们的自定义 lambda 函数被调用并打印出 Button pressed!。

        它的另一个用例是`filter()`或`sort_custome()`函数,这些函数可以使用 lambda 函数来过滤或排序数组中的元素:
[0, 1, 2, 3, 4].filter(func(number: int): return number % 2 == 0)
[0, 3, 2, 4, 1].sort_custome(func(number_a: int, number_b: int): return number_a < number_b)
        每个数组都有一个`filter()`和`sort_custome()`函数,这些函数接受`Callable`作为参数。`filter()`函数将过滤掉数组中任何返回`false`的元素,从而得到一个只包含返回`true`的元素的数组。在上面的例子中,这导致了一个只包含偶数的数组。

        `sort_custome()`函数使用我们提供给它的`Callable`对数组内的元素进行排序。lambda 函数应该接受两个元素,如果第一个元素应该在第二个元素之前排序,则返回`true`;否则,应返回`false`。这样,我们可以定义自己的规则来排序数组的元素。

        使用我们的 lambda 函数运行`filter()`和`sort_custome()`后的结果数组如下:
[0, 2, 4]
[0, 1, 2, 3, 4]
        更多信息

        更多关于 lambda 函数的信息,请参阅官方文档:[`docs.godotengine.org/en/stable/classes/class_callable.html`](https://docs.godotengine.org/en/stable/classes/class_callable.html)。

        现在我们已经知道了 lambda 函数是什么,让我们看看我们可以以不同的方式向函数传递值。

        按值或按引用传递参数

        当向函数传递参数时,实际上有两种不同的方式可以使这些参数到达函数体中——按值或按引用。作为程序员,我们并不选择使用哪一种;GDScript 会根据我们提供给函数的值的类型来做出这个决定。让我们更深入地了解一下两种传递值的方法,适用于每种数据类型,以及为什么了解这些差异很重要。

        按值传递

        按值传递意味着 GDScript 将值的精确副本发送到函数中。这种方法非常简单且可预测,因为我们得到了在函数中调用的新变量。然而,由于复制数据需要时间,对于大数据类型来说可能会相当慢。

        按值传递的数据类型包括任何简单的内置数据类型,如整数、浮点数和布尔值。还有一些稍微复杂一些的类,如字符串、`Vector2`和`Colors`,也是按值传递的。这个列表并不全面。一般规则是包含任何不是数组、不是字典且不是从`Object`类继承的任何东西。

        让我们看看按值传递在实际中是什么样子:
func _ready():
   var number: int = 5
   print("Number before the function: ", number)
   function_taking_integers(number)
   print("Number after the function: ", number)
   var string: String = "Hello there!"
   print("String before the function: ", string)
   function_taking_strings(string)
   print("String after the function: ", string)
func function_taking_integers(number: int):
   number += 10
   print("Number during the function: ", number)
func function_taking_strings(string: String):
   string[0] = "W"
   print("String during the function: ", string)
        在这里,你可以看到我们有两个函数,分别接受一个整数和一个字符串,并在其执行过程中修改参数的值。我们还打印出整数和字符串的值,在函数执行之前、期间和之后,以查看原始变量(来自`_ready()`函数)是否被更改。运行此代码将打印出以下内容:
Number before the function: 5
Number during the function: 15
Number after the function: 5
String before the function: Hello there!
String during the function: Wello there!
String after the function: Hello there!
        我们可以看到,尽管在函数执行过程中值以某种方式被更改了,但原始值并没有改变。这就是按值传递的乐趣;我们不需要担心副作用。

        函数的副作用

        在程序员的语言中,副作用意味着函数以不是直接明显的方式改变程序的状态,改变其作用域之外的变量。你应尽可能避免这种情况,以便更容易理解函数的作用。

        这就是按值传递的工作方式——只是数据的直接复制。现在,我们将看到对比的概念——按引用传递。

        通过引用传递

        向函数传递值的另一种方式是通过引用。这意味着 GDScript 不会复制整个值,而是发送一个指向值的引用。这个引用指向实际值存储的位置,并可用于访问和更改它。

        这种传递参数的模式用于数组、字典以及从`Object`类继承的任何类,这包括所有类型的节点。它本质上用于传递更大的数据类型,因为复制它们的完整值会花费太多时间并减慢游戏的执行速度。

        下面是一个按引用传递的例子:
func _ready():
   var dictionary: Dictionary = { "value": 5 }
   print("Dictionary before the function: ", dictionary)
   function_taking_dictionary(dictionary)
   print("Dictionary after the function: ", dictionary)
func function_taking_dictionary(dictionary: Dictionary):
   dictionary["a_value"] = "has changed"
   print("Dictionary during the function: ", dictionary)
        再次,我们使用之前相同的设置,在每一步打印出我们的字典。我们运行以下代码:
Dictionary before the function: { "value": 5 }
Dictionary during the function: { "value": 5, "a_value": "has changed" }
Dictionary after the function: { "value": 5, "a_value": "has changed" }
        如预期的那样,我们可以看到在函数运行后,`_ready()`函数中的原始字典也被更改了!这是副作用在起作用。

        通常,一个好的做法是永远不要更改传入函数的值和变量,并且始终复制它们或直接使用它们来计算另一个变量的值。如果有疑问,最好测试一个值是按值传递还是按引用传递;这样,你永远不会遇到意外的错误。

        复制数组或字典

        如果你真的想复制一个数组或字典,那么你可以使用定义在这些数据类型上的 `duplicate()` 函数。这个函数将返回一个数组或字典的副本,你可以安全地对其进行修改。

        查看文档以获取更多详细信息:[`docs.godotengine.org/fr/4.x/classes/class_array.html#class-array-method-duplicate`](https://docs.godotengine.org/fr/4.x/classes/class_array.html#class-array-method-duplicate)。

        现在,我们将转换方向,看看我们如何从编辑器内部创建编辑器工具。

        @tool 注解

        除了在游戏执行期间使用 GDScript 运行代码外,我们实际上还可以在编辑器本身中运行代码。在编辑器中运行代码赋予我们可视化事物的能力,例如角色的跳跃高度,或自动化我们的工作流程。通过这样做,我们扩展了 Godot 编辑器以满足我们自己的特定需求。在编辑器中运行 GDScript 代码有多种方式,从运行单独的脚本到编写整个插件,但最简单的方法是使用 `@tool` 注解。

        `@tool` 注解是一种可以添加到任何脚本顶部的注解。它的作用是,具有该脚本的节点将在编辑器中运行它们的脚本,就像它们在游戏中实例化一样。这意味着它们的所有代码都是在编辑器中运行的。

        当我们编辑场景并希望在编辑器中预览事物时,例如玩家的健康状态,或使用代码创建新节点,这非常有用。

        了解这一点后,我们可以通过在顶部添加 `@tool` 注解来调整我们的玩家脚本,以更新编辑器中的健康标签:
@tool
class_name Player extends CharacterBody2D
const MAX_HEALTH: int = 10
@onready var _health_label: Label = $Health
@export var health: int = 10:
   set(new_value):
      health = new_value
      update_health_label()
func _ready():
   update_health_label()
func update_health_label():
   if not is_instance_valid(_health_label):
      return
   _health_label.text = str(health) + "/" + str(MAX_HEALTH)
        这个示例是更新编辑器中健康标签所需的最小代码量。然而,你只需在现有的玩家脚本顶部添加 `@tool` 注解,它就会发挥作用。现在,每次你在编辑器中更改玩家的健康状态时,健康标签都会自动反映这一变化。

        @tool 的风险

        `@tool` 注解非常强大,但并非没有风险。如果不小心,它可能会永久删除场景中的事物,并轻松更改节点的值,所以请谨慎使用。

        然而,有时你希望在游戏中使用一个节点,并在编辑器中运行一些代码。当我们这样做时,我们需要一种方法来区分代码是在游戏还是编辑器中运行的。这可以通过使用 `Engine.is_editor_hint()` 函数来实现。这个全局 `Engine` 对象上的函数,如果我们从编辑器中运行代码,则返回 `true`;如果从游戏中运行,则返回 `false`:
if Engine.is_editor_hint():
   # Code to execute in editor.
if not Engine.is_editor_hint():
   # Code to execute in game.
        这个代码示例向我们展示了在编辑器或游戏中运行代码之间的区别是多么容易区分。

        更多信息

        想了解更多关于 `@tool` 注释和在编辑器中运行代码的信息?请查看官方文档:[`docs.godotengine.org/en/stable/tutorials/plugins/running_code_in_the_editor.html`](https://docs.godotengine.org/en/stable/tutorials/plugins/running_code_in_the_editor.html)。

        使用 `@tool` 注释明智地,我们可以使我们的工作流程更简单、更快。可能性是无限的;你甚至可以从这些脚本之一中访问和更改 Godot 编辑器几乎每一个方面,但这超出了本书的范围。

        摘要

        本章深入探讨了使用 GDScript 编程的一些更高级的主题。我们通过 `super` 和 `static` 关键字以及按值或按引用传递的区别,扩展了我们对面向对象编程的知识。然后,我们看到了 GDScript 编程语言的更多功能,例如枚举和 lambda 函数。我们以在 Godot 编辑器本身中运行代码的方式结束了本章,使用的是 `@tool` 注释。

        测验时间

            +   假设我们有一个名为 `Character` 的类,它有一个名为 `move()` 的函数。现在,我们创建一个 `Player` 类,它继承自这个 `Character` 类并重写这个 `move()` 函数。但是,我们不想完全重写它,而是想扩展 `Character` 类的 `move()` 函数的原始功能。我们可以在 `Player` 类中调用 `Character` 类的原始 `move()` 函数的哪个关键字?

            +   标记为 `static` 的函数能否调用未标记为 `static` 的函数?

            +   以下代码片段将打印出什么?

```cpp
enum COLLECTIBLE_TYPE {
   HEALTH,
   UPGRADE,
   DAMAGE,
}
print(COLLECTIBLE_TYPES.DAMAGE)
```

                +   容器类型,如数组和字典,是唯一按引用传递的类型吗?

            +   如果我们想在编辑器中运行脚本,我们在脚本顶部使用什么注释?

第十二章:高级编程模式

虽然计算机科学作为一个科学领域相对较新,不到 80 年历史,但许多聪明的人已经研究过它。这意味着大多数编程问题已经在某种程度上被遇到。这些问题包括如何在不硬编码的情况下连接程序的一部分,或者如何创建和销毁成千上万的对象,例如子弹,而不会减慢游戏速度。

这些聪明人,通常被称为软件架构师,提出了聪明的解决方案,以优雅的方式解决了这些问题。然后,他们意识到他们可以将这些解决方案概括成一种食谱,一种模板,其他人也可以使用。这就是我们所说的编程模式。在本章中,我们将学习编程模式究竟是什么,并查看游戏开发中最常用的三种模式。

你的编程模式词汇量越大,你解决自己问题的能力就越强,向他人传达你的想法也就越容易。

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

  • 编程模式的基础

  • 事件总线

  • 对象池

  • 状态机

技术要求

与每一章一样,您可以在 GitHub 仓库的子文件夹中找到本章的最终代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter14.

您可以在我们游戏中实现对象池所需的代码在这里找到:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter14-objectpool.

什么是编程模式?

我得坦白——我们并不是第一个创造游戏的人,或者更确切地说,并不是第一个编写软件的人。但事实上,这是一个好事;这意味着在我们之前,许多人已经遇到了我们可能遇到的问题。他们以某种方式想出了这些问题的解决方案,现在我们可以将这些解决方案用于我们自己的游戏和软件中。

编程模式,或软件设计模式,是描述或模板,告诉我们如何在编程时解决某些问题。它们不是完全实现的解决方案;它们只是给我们提供了如何应对我们试图解决的问题的指导。编程模式告诉我们如何组织我们的代码以实现不同的结果。

为了表达这些模式,有一些重要的部分:

  • 名称:这个模式是如何被称呼的。

  • 问题:这个模式试图解决什么。

  • 解决方案:这个模式将如何工作。

除了提供解决方案之外,编程模式还给我们提供了谈论我们软件的手段。当像软件开发一样,每个问题都可以用多种不同的方式解决时,沟通我们所做的工作可能会很困难。设计模式给我们提供了一种谈论解决方案的方式,而无需深入到实际的实现中。

最后,如果我们根据一个或多个模式来组织我们的代码,我们就知道可以期待什么。我们知道代码将如何对新变化做出反应,以及如何与其他程序的各个部分进行通信。这有助于我们理解我们如何进行更改,无论是解决一个错误、添加新功能还是重构旧代码。

重构旧代码

有时候,你会发现你解决问题的方法可能不够快,不够可扩展,或者不足以满足需求。在这种情况下,你可以选择重写一些代码。我们称之为重构代码。

信不信由你,任何事物都可以是一个模式,即使你没有意识到,你可能已经在使用现有的模式了。

然而,这并不像挑选任何模式并将其强制应用到我们的游戏代码中那么简单。我们应该仔细考虑是否使用某种模式。当我们使用某种不适合我们试图解决的问题的模式,并且实际上使我们的软件变得更差时,我们称之为反模式

为了不使本章内容过于冗长,代码示例将更多地作为介绍和演示如何使用模式。我们不会在我们的游戏中实现所有模式,因为这需要太多的文字。本章的真正目标是让你意识到最有用的编程模式。

让我们开始我们的第一个模式——事件总线。

探索事件总线

我们将要探讨的第一个编程模式是事件总线。它将帮助我们解耦代码,这意味着两段代码不需要过度依赖对方,同时仍然能够进行通信。

让我们看看事件总线模式试图解决的问题是什么。

问题

如果我们将代码的不同类和部分解耦,它们在以后重用时会更容易。例如,我们在第九章中就做了这件事,使用了 Godot 引擎提供的可连接的信号。发布信号的代码并不关心谁在监听或者谁想要接收这个信号。

但信号只能在非常局部的地方工作,而全球范围内使用它们可能会成为一个挑战。一个经典的例子是成就系统。成就是一些玩家在游戏中完成某些任务后获得的微小奖励。这些甚至可以与 Steam 或 PlayStation Network 等外部成就系统相链接。解锁这些成就所需的任务通常与游戏中的非常不同的系统相关联——“击败最终 Boss,” “跳 250 次,” “倒放 2 分钟,”等等。由于不同成就的这种变化,成就系统需要从代码的许多不同部分获取信息。然而,我们不想直接从每个系统的代码中访问成就系统,反之亦然,因为这会为成就系统创建一个硬依赖,使得成就系统必须始终存在。例如,在 Nintendo Switch 上,就没有成就系统,所以所有这些成就代码都将毫无用处。

既然我们已经知道了我们正在试图解决的问题类型,让我们深入探讨解决方案,即事件总线。

解决方案

这就是事件总线模式发挥作用的地方。它是一个我们自动加载的类,其他代码片段可以订阅或发布事件。基本结构看起来像这样:

图 14.1 – 事件总线的基本结构

图 14.1 – 事件总线的基本结构

这与信号非常相似,但这次是在全球范围内。让我们看看一个非常简单的例子:

extends Node
var _observers: Dictionary = {}
func subscribe(event_name: String, callback: Callable):
   if not event_name in _observers:
      _observers[event_name] = []
   _observers[event_name].append(callback)
func publish(event_name: String):
   if not event_name in _observers:
      return
   for callable: Callable in _observers[event_name]:
      callable.call()

将此脚本添加到项目的自动加载中,就像我们在第十章中为高分管理器所做的那样,这样我们就可以全局访问它。

假设一个非常简单的 Boss 战例子,其中我们有一个节点,它的脚本看起来像这样:

extends Node
func _ready():
   while randf() < 0.99:
      print("You're still fighting the boss!")
   print("The boss dies x.x")
   killed_boss.
			Now, we can create a little achievement system and subscribe to this event, to be notified when the boss battle is over:

extends Node

func _ready():

EventBus.subscribe("killed_boss", on_boss_killed)

func on_boss_killed():

print("成就解锁:击败 Boss")


			Add this script as an autoload, named `AchievementSystem`, and then you can run the project. In the console, you’ll see that it works perfectly:

你正在与 Boss 战斗!

...

你正在与 Boss 战斗!

老大已死 x.x

成就解锁:击败 Boss


			The signals, which are default in Godot, and the Event Bus patterns are both close cousins of the Observer pattern. The big difference between signals and the Event Bus is that with signals, you can only subscribe to one specific entity, such as when we subscribe to one enemy’s `died` signal to signify that the enemy died, whereas an Event Bus is global. It doesn’t matter what node or object threw the event; everyone who is subscribed to the event will get notified, such as when any node (it doesn’t matter which) throws the `game_over` event to signify that the game ended. The Observer pattern and all its different forms are widely known.
			Learn more
			Learn more about the Observer pattern here: [`gameprogrammingpatterns.com/observer.html`](https://gameprogrammingpatterns.com/observer.html).
			The Event Bus programming pattern is ideal for decoupling your code. Let’s now explore a pattern that has a completely different purpose, namely optimizing load times.
			Understanding Object Pooling
			The second programming pattern we’ll see is Object Pooling. The purpose of this pattern is to keep up the frame rate of our game while still being able to create and destroy many objects or nodes. Let’s take a deeper dive into what we are trying to solve – that is, the problem.
			The problem
			In some games, we want to be able to spawn and remove objects really quickly. In the little game that we have constructed over the course of the book, for example, we want to be able to spawn and remove projectiles and arrows fast and reliably. With the rates our arrows are being shot at now, this is not a big issue, but it could become one if we increased this rate, especially in multiplayer. Creating new nodes – for example, by using the `instantiate()` function we saw in *Chapter 10* and adding them to the scene tree – is pretty slow. The game needs to load the scene file from disk and then allocate new memory every time we create a new node. Then, when the node gets freed, the game has to free up that memory again.
			To optimize this process, we can use an Object Pool, which we will discuss in the next section.
			The solution
			Theses loading problems can be solved by the Object Pooling pattern. Object Pooling basically means that we keep a list, also called a pool, of already initialized nodes somewhere. For example, with a bunch of arrows, when we need an arrow, we can simple take one from this list. When it is not needed anymore, we return it to that list so that it can be reused later on.
			![Figure 14.2 – Any class that wants an instance can ask the Object Pool](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_14_02.jpg)

			Figure 14.2 – Any class that wants an instance can ask the Object Pool
			Because we do not actually delete or remove the arrow node from the scene tree, we will need to make sure, through code, that the node stops working in the background when it is supposed to be stored away in the Object Pool. When an object is in use, we say it is alive because it lives within the game. When it is in the Object Pool, it is dead because it is not in use anymore. When we want to return a live object to the pool, we say that it gets killed.
			![Figure 14.3 – The Object Pool sets an instance as alive. When the instance is dead, it returns to the pool](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_14_03.jpg)

			Figure 14.3 – The Object Pool sets an instance as alive. When the instance is dead, it returns to the pool
			Here is an example of what an Object Pool’s script could look like:

class_name ObjectPool extends Object

var _pool: Array

func _init(scene: PackedScene, pool_size: int, root_node: Node):

for _i in pool_size:

var new_node: Node = scene.instantiate()

_pool.append(new_node)

new_node.died.connect(kill_node.bind(new_node))

root_node.add_child(new_node)

func kill_node(node: Node):

node.set_dead()

_pool.append(node)

func get_dead_node() -> Node:

if _pool.is_empty():

return null

var node: Node = _pool.pop_back()

node.set_alive()

return node

func free_nodes():

for node in _pool:

node.queue_free()


			You can see that we keep an array, called `_pool`, which will contain all of our dead nodes. First, we create a number of objects within the `_init()` function of type `scene` and add these new objects as children to `root_node`, which we can pass to this `_init()` function. The number of objects we populate the pool with is defined by `pool_size`.
			In the `_init()` function, we also connect to the `died` signal of each node using the `kill_node()` function. This means that the node, when it dies, needs to emit the `died` signal. The `kill_node()` function, in turn, will call the `set_dead()` function on that node. This function should disable the node and will be different for each kind of node, so we need to implement that later on in the definition of the node script itself. After this, the node is returned to the `_pool`.
			You can also see that I called a function called `bind()` on the `kill_node` callable – `kill_node.bind(new_node)`. This binds arguments to `Callable`, which means that if the signal is emitted and this `Callable` gets called, the arguments we bind here are given to the `kill_node()` function. This way, we know what object is being killed in the `set_dead()` function.
			When we need an instance from the pool, we call the `get_dead_node()` function, which will first check whether there are still objects in the pool; if not, we return nothing. If there are still objects in the pool, we remove the first element from e `_pool`, set it as alive, and then return it.
			Lastly, we implemented a `free_nodes()` function that frees all the nodes that are present in the pool. This way, we can free them all conveniently when we stop the game.
			Implementing the Object Pool in our game
			Let’s implement the Object Pool in our own game! The obvious nodes to pool from our Vampire Survivor like game are the projectile and the enemy. We’ll use a pool to deal with the projectiles here. You can always take a stab at making an Object Pool that deals with the enemies:
			Create a script, `object_pool.gd`, that has the exact content of the script from the previous section. Save it under a new folder, `parts/object_pool`.
			Let’s prepare the `projectile.gd` script so that it can be in a pool:

				1.  At the top, add a new custom signal, `died`. This will be called when the projectile can go back into the pool.

    ```

    信号已消失

    ```cpp

    				2.  Then, add two functions, `set_alive()` and `set_dead()`, which we call from the Object Pool:

    ```

    func set_alive():

    if multiplayer.is_server():

    set_physics_process(true)

    _enemy_detection_area.monitoring = true

    show()

    func set_dead():

    set_physics_process(false)

    _enemy_detection_area.set_deferred("monitoring", false)

    hide()

    ```cpp

    The `set_alive` function turns on the `_physics_process` and the collision detection for the projectile, but only if this code is run from the server. Then it shows the projectile, no matter if we are running from the server or not so that everyone can see it. The `set_dead` function undoes all these changes to make sure the projectile is unusable while dead.

			Important note
			We use the `set_deferred()` function on the `_enemy_detection_area` to set `monitoring` to `true` or `false` because this change has to be incorporated by the physics engine and we need to wait until all physics calculations for that frame are executed. The `set_deferred()` function sets the value to our desired value at the end of the current frame.

				1.  Now, replace the original `_ready()` function with the one in the next code snipper which makes sure new instances don’t start acting when they are created and put into the scene tree:

    ```

    func _ready():

    set_dead()

    ```cpp

    				2.  Lastly, replace the mentions of `queue_free()` with `died.emit()` because the Object Pool will manage how the node gets created:

    ```

    func _physics_process(delta: float):

    if not is_instance_valid(target):

    died.emit()

    return

    # Rest of _physics_process

    func _on_enemy_detection_area_body_entered(body: Node2D) -> void:

    body.get_hit()

    main.gd script to have an Object Pool of projectiles.At the top, add a `projectile_pool` variable and preload the `projectile.tscn` scene:

    ```cpp
    var projectile_pool: ObjectPool
    var projectile_scene: PackedScene = preload("res://parts/projectile/projectile.tscn")
    ```

    ```cpp

    				3.  Now, we only want to initialize this variable when we run from the server. The server will manage all the projectiles. Add the following line to `_ready()`:

    ```

    Func _ready():

    # ...

    if multiplayer.is_server():

    # Code for server setup

    _exit_tree() function of the main script:

    ```cpp
    func _exit_tree():
       if projectile_pool:
          projectile_pool.free_nodes()
          projectile_pool.free()
    ```

    ```cpp

			Important note
			Nodes in the scene tree will be freed automatically when we close the game. But objects that are not inside of the tree, like our `projectile_pool` or nodes we take out of the scene tree, are not managed by the same process. So, we need to manage when to delete them ourselves.

				1.  Lastly, we’ll need to update the `player.gd` script to access the Object Pool for a projectile and set its target and position. Replace the original way we created a new projectile with this code:

    ```

    func _on_shoot_timer_timeout():

    # Shooting code to select a target enemy

    var new_projectile: Projectile = get_parent().projectile_pool.get_dead_node()

    if new_ projectile:

    new_projectile.target = closest_enemy

    new_projectile.position = global_position

    ```cpp

			This is all we need to do to implement our Object Pool in our multiplayer game. When you look at the **Remote Tree** while running the game, you’ll see that 50 projectiles have been created at the start, ready to be launched by the players.
			![Figure 14.4 – 50 projectiles are created, ready to be used](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_14_04.jpg)

			Figure 14.4 – 50 projectiles are created, ready to be used
			That’s it for the Object Pool pattern. It is very useful to keep frame rates in check when you need lots of objects to spawn and disappear often. Let’s look at yet another completely different pattern in the next section.
			Working with State Machines
			Games are massive pieces of code that can get quite complex. To lower the complexity of code, we can try to separate different pieces so that they only perform one action very well. That is exactly what we are going to do with a State Machine. Let’s first start with a better problem statement.
			The problem
			Agents, such as the player or enemies, often have to operate in very different scenarios. In a platformer game, such as **Super Mario Bros** for example, the character needs to be able to walk, run, jump, dive, wall slide, fly, and so on. This is a lot of different kinds of code. If we try to fit this into one big class for the player, we’ll end up with a jumble of code that is very hard to understand, debug, or extend.
			Ultimately, we want our game’s code to be easily understood and maintained. That’s why we will learn about the State Machine in the next section.
			The solution
			A great way to combat this complexity is by separating the behavior for each of these wanted behaviors (walking, jumping, etc.) into different files and classes. This is exactly what the State Machine pattern does. The State Machine swaps out part or the complete behavior of an object with a different behavior, depending on the state it is in.
			Each of the behaviors we identified earlier (walking, jumping, etc.) is defined as a totally independent state that alters the behavior of the agent and is saved in a separate file.
			![Figure 14.5 – An example of how states could connect with each other](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_14_05.jpg)

			Figure 14.5 – An example of how states could connect with each other
			The State Machine can only have one active state at one time. This rule makes sure that we don’t mix up behaviors or code.
			Each of these states knows which other states it can transition to. This transition gets triggered from the code of that state by asking the State Machine directly to transition.
			Now that we have a surface-level idea of what a State Machine can do, let’s quickly list all the things it should do. The State Machine should do the following:

				*   Have a list of all possible states
				*   Designate one active state
				*   Be able to transition from one state to another
				*   Update the current active state and provide it with direct input

			With that in mind, let’s take a look at the code for the actual State Machine itself:

class_name StateMachine extends Node

@export var starting_state: String

var states: Dictionary

var current_state: State

func _ready():

for child in get_children():

states[child.name] = child

child.state_machine = self

if not starting_state.is_empty:

transition_to(starting_state)

func transition(state_name: String):

if current_state:

current_state.exit()

current_state = states[state_name]

current_state.enter()

func _physics_process(delta: float):

if not current_state: return

current_state.process(delta)

func _input(event: InputEvent):

if not current_state: return

current_state.input(event)


			You can see that in the `_ready()` function, we scan all the children of the State Machine and add it to a dictionary of `states`. This dictionary will help us to quickly look up states when we need them in the `transition()` function. This also means that we will add each state as a child node to the State Machine itself, like so:
			![Figure 14.6 – The State Machine with each state as a child node](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_14_06.jpg)

			Figure 14.6 – The State Machine with each state as a child node
			At the end of the `_ready()` function, we transition to `starting_state`, which is an export variable that we can use to set the initial state of the State Machine.
			In the `transition()` function that is used to transition to a new state, we first check whether we have `current_state`; if we do, we’ll first have to call the `exit()` function on it to make sure it can clean itself up. After that, we use  `state_name`, which is provided as an argument to look up the next state, assign it as `current_state`, and call the `enter()` function on it.
			The `_physics_process()` and `_input()` methods are used to directly feed into the `process()` and `input()` functions of `current_state`, if there is a current state.
			Now, let’s have a look at the `state` class itself:

class_name State extends Node

var _state_machine: StateMachine

func enter():

pass

func exit():

pass

func process(delta: float):

pass

func input(event: InputEvent):

pass


			The state class is a simple skeleton with functions that we have to implement when we inherit from it. This means that if we have a jump state, for example, we’ll need to make sure that the `enter()`, `exit()`, `input()` and `process()` functions all work as they should during the jumping behavior of our character.
			If we want to go from one state to another, we can simply use `_state_machine.transition()` from within the state and provide the name of the state we want to transition to.
			We can now create specialized states and connect them through code, by calling the `transition()` function on the `_state_machine` object.
			An example state
			Let’s take a quick look at an example state, `Walk`, for the player. This is the state when the player moves freely around:

extends State

var _player: Player = owner

@export var max_speed: float = 500.0

@export var acceleration: float = 2500.0

@export var deceleration: float = 1500.0

func process(delta: float):

var input_direction: Vector2 = Input.get_vector("move_left", "move_right", "move_up", "move_down")

if input_direction != Vector2.ZERO:

_player.velocity = _player.velocity.move_toward(input_direction * max_speed, acceleration * delta)

else:

_player.velocity = _player.velocity.move_toward(Vector2.ZERO, deceleration * delta)

_player.move_and_slide()

func input(event: InputEvent):

if event.is_action_pressed("jump"):

_state_machine.transition("Jump")


			You can see we extend the `State` script from earlier. Then, we implement the `process()` function to do our movement calculations, which are specific to walking around, and the `input()` function to detect when we want to transition from this state to the `Jump` state.
			We don’t need to override every function from the `State` script, just the ones that we need, which in this case are the `process()` and `input()` functions.
			State Machines, in one way or another, are used in almost every game you ever played. It is a very important concept to understand. They abstract complex behavior into separate classes that are easy to understand and maintain.
			Let’s conclude the chapter with some additional exercises.
			Additional exercises – Sharpening the axe

				1.  The implementation of our Event Bus makes it possible to subscribe to an event, but not to unsubscribe when the receiver doesn’t want to be subscribed anymore. Implement an `unsubscribe()` function that unsubscribes a `Callable` from an event:

    ```

    func unsubscribe(event_name: String, callback: Callable):

    # Your code

    ```cpp

    				2.  The Object Pool we have implemented returns nothing when we try to call `get_dead_node()` while the pool is empty. A smarter way of dealing with this would be to create a new object, basically extending the Object Pool on the fly. Create a new function, `get_dead_node_or_create_new()`, in such a way that when the pool is empty, it creates a new object that is correctly connected and returned to the pool when it dies.

			Summary
			After learning how to program and make a game, we finally took a step back and learned about higher-level patterns that help us structure our project and code nicely. First, we learned about what programming patterns are in general. Then, we learned about the Event Bus, Object Pool, and State Machine patterns that can help us in different ways. These three are some of the widely used patterns in gaming and are applied outside of game development too.
			From here, you can start to investigate more niche programming patterns, such as the following:

				*   **Components, also known as** **Composition**: [`gameprogrammingpatterns.com/component.html`](https://gameprogrammingpatterns.com/component.html)
				*   **Commands**: [`gameprogrammingpatterns.com/command.html`](https://gameprogrammingpatterns.com/command.html)
				*   **Service** **Locators**: [`gameprogrammingpatterns.com/service-locator.html`](https://gameprogrammingpatterns.com/service-locator.html)

			In the next chapter, we’ll look at the filesystem and learn how to save the state of our game so that our players can start a game from where they left off.
			Quiz time

				*   Programming patterns are standardized ways of solving problems in a program or game. What is the advantage of knowing them?
				*   Any piece of code can be considered as a pattern. But when we call something an anti-pattern, does this mean that it works in our favor?
				*   The Signals and Event Bus patterns are very similar because, in both, we subscribe to events, but what is their fundamental difference?
				*   Why would we use an Object Pool pattern in our game?
				*   What is the line of code with which we can transition from one state to another using the State Machine pattern?

第十三章:使用文件系统

在早期,街机游戏永远不会存储玩家的进度。每次你投入一个 25 美分的硬币,游戏就会从零开始,除非有一个系统允许你在同一轮游戏中购买更多生命。但总的来说,你无法在第二天回来并从前一天停止的地方继续游戏。

即使早期的家用游戏机在保存进度方面功能有限。有些游戏会有一个代码系统,你可以从击败一个关卡的那一刻起获得一个秘密代码。后来,你可以使用这个代码直接从那里开始。但这些游戏仍然没有真正保存你的进度。

这种限制部分是因为存储空间,比如硬盘或闪存,非常昂贵。如今,几乎每台电脑和游戏机都标配了几百吉字节,甚至太字节,的存储空间。保存数据变得非常便宜且容易,玩家们已经习惯了在游戏会话之间跟踪某种进度。

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

  • 文件系统是什么?

  • 创建一个保存系统

技术要求

与每一章一样,你可以在 GitHub 仓库的子文件夹中找到本章的最终代码:github.com/PacktPublishing/Learning-GDScript-by-Developing-a-Game-with-Godot-4/tree/main/chapter15

文件系统是什么?

文件系统是一个管理系统,它管理文件、文件内容以及这些文件的元数据。例如,文件系统会管理文件存储在哪些文件夹中。它确保我们可以访问这些文件来读取内容、元数据,并将新数据写回。对于 Godot 来说,这意味着 Godot 引擎管理我们在游戏中可能需要的所有资源,从场景到脚本,以及图像和声音。

元数据

当我们有数据,比如一个文本文件时,它通常伴随着元数据。这是关于数据的数据。虽然文本文件包含实际数据,即文本,但元数据包含诸如创建日期、作者是谁、存储位置以及哪些账户可以访问文件等信息。

让我们在下一节中从文件路径开始探索文件系统。

文件路径

为了能够定位一个文件,文件系统为每个文件提供一个唯一的路径。在我们的电脑上,我们可以通过文件夹,也称为目录,找到文件,我们将它们以良好的顺序存储。在基于 Windows 的系统上,这个路径可能看起来像这样:

C:\Users\user_name\Documents\my_text_file.txt

或者,在 macOS 和基于 Linux 的系统上,它可能看起来像这样:

~/Documents/my_text_file.txt

对于资源和项目相关的其他文件,Godot 引擎的路径相对于项目project.godot文件的位置是相对的。该文件的路径被视为根目录。在 Godot 文件系统中访问资源文件的路径始终以res://开头。例如,要访问项目中的一个文件,路径可能看起来像这样:

res://parts/player/player.tscn

重要提示

为了方便和兼容性,Godot 文件系统始终使用正斜杠(/)。即使在通常使用反斜杠(\)的基于 Windows 的系统上也是如此。

我们实际上在第十章中预加载弹头时已经使用过这些路径之一。

用户路径

我们可以轻松地使用res://路径访问所有项目文件,这非常方便,但存在一个问题。我们无法向res://域中的任何文件写入;当游戏从导出的构建中运行时,我们只能从中读取文件。为了帮助开发者解决这个问题,Godot 引擎提供了另一个根路径,user://,文件可以写入并从中读取。

Godot 引擎会在计算机上的某个位置自动创建一个文件夹来存储这些用户数据。这个文件夹的位置取决于游戏运行的系统,因此对于每个操作系统都会不同:

  • Windows: %APPDATA%\Godot\app_userdata\<项目名称>

  • macOS: ~/Library/Application Support/Godot/app_userdata/<``项目名称>

  • Linux: ~/.local/share/godot/app_userdata/<``项目名称>

重要提示

在项目设置中,我们甚至可以指定在每个三个操作系统中的文件夹位置,但现在不需要这样做,因为 Godot 会为我们处理并隐藏到某个安全的地方。

您可以通过打开项目菜单并选择打开用户 数据文件夹来访问给定项目的user://文件夹。

图 15.1 - 打开用户数据文件夹将我们带到 user://文件夹

图 15.1 - 打开用户数据文件夹将我们带到 user://文件夹

我们将在本章下一节中将使用user://路径来将保存数据写入。所以,让我们来实际实现我们自己的小保存系统。

创建一个保存系统

理论上,我们只需要打开一个文件,将我们想要保存的数据写入其中,然后,稍后,当我们需要数据时,再读取相同的文件。实际上,在 Godot 引擎中,读取和写入文件确实很容易。

我们首先将看到如何写入外部文件。

将数据写入磁盘

让我们通过在autoloads文件夹下创建一个名为save_manager.gd的新脚本来实现这一点。然后,为了保存数据,将此代码放入脚本中:

extends Node
const SAVE_FILE_PATH: String = "user://save_data.json"
var save_data: Dictionary = {
   "highscore": 0
}
func write_save_data():
   var json_string: String = JSON.stringify(save_data)
   var save_file: FileAccess = FileAccess.open(SAVE_FILE_PATH, FileAccess.WRITE)
   if save_file == null:
      print("Could not open save file.")
      return
   save_file.save_data, that we’ll use to store all the data in. For now, it only contains highscore. If we want to access the saved data later on during the game, we can just use this variable.
			Then we have the `write_save_data()` function, which actually writes our data to a file. This function starts by converting our `save_data` dictionary to a JSON string using the `JSON.stringify()` function.
			The JSON standard
			JSON is a data format that is widely used on the web and other platforms. The name **JSON** stands for **JavaScript Object Notation**. It is a very lightweight way of storing data and has the added benefit of being easy to read and adjust once our data is stored in the file.
			Next, we use the `FileAccess` class to open the file in which we would like to write our data. We stored the path to the file as a constant, `SAVE_FILE_PATH`, at the top of the script. Because we want to write to the file, we need to open it with write access by providing `File.Access.WRITE` to the `open()` function. This mode of accessing the file will also create the file for us if it doesn’t exist yet. The opened file gets stored in the `save_file` variable.
			Next, we check whether `save_file` opened properly. If, for any reason, the file could not be opened, this variable’s value will be `null` and we should stop executing the function.
			More information
			For more on the `FileAccess` class, check out the official documentation: [`docs.godotengine.org/en/stable/classes/class_fileaccess.html`](https://docs.godotengine.org/en/stable/classes/class_fileaccess.html).
			The last thing we need to do, when the file is properly opened, is actually write the JSON data to it. We do this using `store_string()` on `save_file`, passing along `json_string`.
			That is all we need to write data to a file in the `user://` folder. Now we can write a function that reads this data back in.
			Reading data from disk
			To read data from the `user://` folder, we follow the same steps as writing it, but in reverse. Add this function to the `save_manager.gd` script:

func read_save_data():

var save_file: FileAccess = FileAccess.open(SAVE_FILE_PATH, FileAccess.READ)

if save_file == null:

print("无法打开保存文件。")

return

var file_content: String = save_file.get_as_text()

save_data = read_save_data函数加载保存的文件并解析内容,以便我们可以在游戏中使用。首先,我们使用FileAccess.open打开保存的文件,提供文件的路径和FileAccess.READ以指示我们只想读取它。之后,我们检查文件是否已正确打开,否则我们需要再次退出函数。

        接下来,我们将整个文件作为字符串读入一个名为`file_content`的变量中。我们将不得不将这个字符串从保存时的 JSON 格式解析为 GDScript 可以处理的格式,即字典。解析后的值直接存储在我们之前定义的`save_data`变量中。

        更多信息

        关于在 Godot 引擎中保存和加载数据的更多信息,请参阅官方文档:[`docs.godotengine.org/en/stable/tutorials/io/saving_games.html`](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html)。

        这太棒了,我们有两个函数可以为我们的小游戏写入和读取保存的数据。现在我们还需要添加一些函数来确保脚本可以被游戏使用。

        为游戏使用保存管理器做准备

        保存管理器几乎准备好了,但我们仍然需要添加这两个函数:
func _ready():
   read_save_data()
func save_highscore(new_highscore: float):
   save_data.highscore = new_highscore
   write_save_data()
        第一个函数,即`_ready()`函数,确保我们从玩家启动游戏的那一刻起加载保存的数据。

        第二个函数提供了一个方便的方式来存储新的高分。它将新的高分添加到`save_data`字典中,然后将数据写入磁盘。

        现在,为了确保我们可以从任何地方访问保存管理器,将此脚本添加到项目的自动加载中。我们希望我们的保存管理器是第一个执行的自动加载,这将确保在游戏的任何其他部分执行之前,保存的数据被加载。为此,请确保`save_manager.gd`脚本位于自动加载列表的顶部。您可以通过拖放**SaveManager**的条目或通过点击右侧的箭头直到它位于顶部来实现这一点。

        ![图 15.2 - 确保 SaveManager 是列表中的第一个自动加载](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_15_2.jpg)

        图 15.2 - 确保 SaveManager 是列表中的第一个自动加载

        完成并放置此脚本作为自动加载后,我们终于可以将游戏连接到使用它。让我们在下一节中这样做。

        调整游戏以使用保存管理器

        现在,我们只需要从`highscore_manager.gd`脚本中获取高分,并在玩家获得新高分时保存高分。将以下`_ready`函数添加到`highscore_manager.gd`脚本中,并添加`SaveManager.save_highscore()`函数调用:
func _ready():
   highscore = SaveManager.save_data.highscore
func set_new_highscore(value: int):
   if value > highscore:
      highscore = value
      SaveManager.save_highscore(highscore)
        在这些准备工作就绪后,我们最终可以玩一会儿游戏,得到一个高分,关闭游戏,当我们再次打开它时,看到我们之前的高分出现。

        ![图 15.3 - 打开游戏时加载高分](https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/lrn-gdscp-devgm-gd4/img/B19358_15_3.jpg)

        图 15.3 - 打开游戏时加载高分

        现在我们的游戏已经真正准备好让用户在多天内努力打破自己的高分记录,而无需自己跟踪它。在下一节中,我们将简要查看保存文件的实际内容。

        查看保存文件

        目前,我们将保存文件视为一个黑盒,不知道其实际内容。我们以 JSON 格式将其保存到其中,然后读取它,将其解析回 GDScript 可用的数据。

        黑盒

        当我们不知道事物本身是如何工作时,我们说我们在与黑盒交互。我们向系统提供输入,它就会输出一些输出。

        当然,我们也可以使用文本编辑器,如 Windows 上的记事本,查看保存文件的内容。只需打开我们在本章早些时候在*用户路径*部分中提到的`user://`文件夹。从这里,打开我们在保存管理器中创建的`save_data.save`文件。

        你会发现这个文件中的数据非常易于阅读,看起来很像我们在`save_manager.gd`脚本中定义的实际字典。这是因为 JSON 也有字典数据结构的概念,其语法与 GDScript 中的字典语法非常相似。文件看起来是这样的:
{"highscore":56}
        如果你愿意,你可以从这里更改保存数据并作弊,通过填写一个不可能的高分。不幸的是,如果用户知道在哪里查找保存文件,他们也会这样做。

        更多信息

        有加密保存文件的方法,但这些超出了本书的范围。更多信息请参阅官方文档:[`docs.godotengine.org/en/stable/classes/class_fileaccess.html#class-fileaccess-method-open-encrypted`](https://docs.godotengine.org/en/stable/classes/class_fileaccess.html#class-fileaccess-method-open-encrypted)。

        太棒了,随着我们的游戏保存玩家的最高分,我们已经到达了这一章的结尾。关于加载和保存游戏状态,还有很多技巧要学习,但到目前为止,这已经足够了。

        摘要

        在本章中,我们学习了关于 Godot 和计算机的文件系统的一切。它使我们能够编写一个小型保存系统,保持我们游戏的高分,并在每次启动游戏时加载它。

        这本书的这一部分已经结束了。在过去的五章中,我们深入探讨了编程概念、模式和文件系统。

        你们都已经准备好去开发自己的游戏了。但在你这样做之前,我想给你一些最后的提示和步骤,告诉你在这本书的最后一章中接下来要做什么。我们那里见。

        测验时间

            +   在 Godot 引擎中,`res://`和`user://`文件路径之间有什么区别?

            +   为了保存数据,我们使用了 JSON 格式。JSON 格式是 Godot 引擎独有的格式吗?在哪些其他领域广泛使用 JSON 格式?

第十四章:接下来是什么?

真是一次难忘的旅程!我们从对编程一无所知,到在书中获得中级理解,并从头开始创建了一个完整游戏。我知道并不是每次都能轻易地理解所有内容,但这就是我们学习的方式。我们必须在某件事上失败,然后一次又一次地尝试——每次都会在我们试图学习的事情上变得更好。

开始一个新项目总是充满挑战。它就像画家的空白画布或作家的空白纸张。开始新事物总是很难。这就是为什么我喜欢提供一些想法来帮助你开始。而且不用担心哪个项目是理想的。从现在开始,任何你感兴趣的项目都是值得一试的。只需确保它们小巧并完成它们,这样人们就可以玩你所制作的。

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

  • 你下一个项目的想法

  • 学习新主题

  • 加入社区

你下一个项目的想法

如本章引言中所述,对空项目的恐惧是真实存在的。从哪里开始,要做什么,总是充满挑战。让我们更深入地看看这两个问题。

开始一个新项目

这里有一些我通过艰难的方式学到的关于开始新项目的建议,这将帮助你专注于创建你想要创建的游戏:

  • 从创建主要游戏循环开始:你应该做的第一件事是创建游戏的基础游戏玩法。如果你在制作一个平台游戏,确保在开始添加更复杂的系统之前,移动和跳跃本身就是有趣的。

  • 先让它变得实用,再让它变得美观:很容易陷入制作游戏过于美观的陷阱,但这会显著减缓游戏的生产进度。更糟糕的是,如果游戏不好玩,你需要重做很多系统,你还需要重做所有让你游戏变得美观的事情。

  • 专注于你想要学习的东西:如果你想要学习某个特定主题,比如菜单和用户界面,那么就把它作为你项目的重点。有一些以 UI 为重点的游戏类型,比如视觉小说或策略游戏,你可以制作。

  • 保持小巧:大多数人更喜欢玩一个小巧有趣的游戏,而不是一个漫长而半成品体验。这也会确保你能够完成游戏。你玩过的任何游戏都是完成的(或者被认为足够完成),而未完成的游戏从未被任何人玩过。

  • 制作一个 GDD:在项目开始时,创建一个简短的游戏设计文档。这可能是一页纸,只是为了说明项目的总体意图。它将在开发过程中引导你,并帮助你在以后做出决策。

这些是一些关于如何应对任何新项目的宏观建议。很容易陷入细节,所以最重要的是开始。毕竟,学习发生在我们犯错之后。现在,我们将转向更具体下一步要做什么的想法。

扩展生存类游戏

合理的下一步是将我们在这本书中创建的游戏进行扩展。以下是一些想法:

  • 引入新的敌人类型:可能是一个移动缓慢但造成大量伤害的敌人,或者一个停留在原地并在 5 秒后爆炸的炸弹。

  • 创建不同类型的投射物:可能是一把射程较短但能对敌人造成更多伤害的匕首。

  • 添加更多可拾取物品:可能是一个能保护玩家五秒钟的盾牌,或者能让玩家移动速度加快的靴子。

然而,我要给你一个警告,那就是你不应该在一个项目上花费太多时间。在学习创建和设计游戏时,最好保持项目小,不要过度打磨单个游戏。通常,将某个项目视为完成,让人们来玩,然后转到下一个项目会更好——直到你对自己的技能足够自信,可以处理更大的游戏。

视频游戏中的打磨

在视频游戏开发中,我们将任何不是游戏核心体验的东西称为打磨——那些不是体验核心但能让体验看起来更美观或感觉更流畅的东西。打磨对游戏的最终体验非常重要。

在创建许多小型游戏的话题上,让我们看看你可以接下来工作的游戏想法。

创建另一款游戏

在游戏开发者社区中,我们经常说“你的前 10 个游戏会很糟糕”,这是一种严厉的说法,意思是你在创建的前 10 个项目可能不会很出色。但这没关系。我们都有过这样的经历。但每个你完成的项目都会教会你一些东西。

你可能会在一个项目中专注于创建引人入胜的游戏循环,而在另一个项目中则专注于创建清晰的菜单。

尝试制作许多小项目,每个项目都能教会你一些有用的东西;这样,你就能在展示成果的同时提升自己的技能。

你可以尝试解决以下一些游戏想法:

  • 平台游戏:学习如何在二维物理中工作,其中重力将玩家向下拉。

  • 回合制策略游戏:学习如何将游戏玩法分成离散的步骤。

  • 卡牌游戏:学习如何处理更复杂的用户界面以及如何抽象卡牌能力。

  • 解谜游戏:学习如何设计既不太难也不太容易的有趣谜题。

  • 视觉小说:了解所有关于菜单以及如何将对话集成到游戏中的知识。

  • 无尽跑酷游戏:学习如何即时生成随机关卡。

在进行这些项目时,查找你不知道如何做或不理解的部分。这本书中的信息是一个很好的起点,但它不足以解决所有问题。试图做到这一点的书会非常冗长。

免费游戏资源

如果你不是视觉艺术家或音乐家,那么为你的游戏创建资源可能会很困难。幸运的是,周围有很多免费选项;查看以下链接:

  • Kenney: 荷兰制作的出色免费 2D、3D 和音频资源。本书中使用的精灵来自 Kenney:kenney.nl/assets

  • OpenGameArt: 一个由游戏开发者和艺术家组成的社区,提供免费和开源资源:opengameart.org/

  • Itch.io: 我们发布游戏所使用的平台也是一个提供各种免费资源的优秀资源库:itch.io/game-assets/free

使用这些资源,你将能够迅速制作出美丽的游戏。

尽管这些资源是免费的,但通常需要在游戏或游戏的商店页面上某处提及资源的创作者。所以,别忘了这样做;这样,你就能获得免费资源,同时他们也能得到曝光。对每个人都是双赢!

知道要学习什么是最重要的一步,你希望在这个过程中识别出来。从那里开始,问题是如何学习这些内容?让我们在下一节中探讨这个问题。

学习新主题

在学习更多关于编程和游戏设计方面有许多优秀的资源。在本节中,我们将介绍一些可能对你有帮助的不同类型的资源。

跟随特定的教程

当你知道你想学习什么时,肯定会有关于该主题的视频或文字教程。你所需要做的就是使用你喜欢的搜索引擎,比如 Google 或 YouTube,搜索你想要的主题。以下是我最喜欢的几个 Godot 引擎教程来源:

关于 Godot 引擎的在线教程并不缺乏。

阅读更多书籍

另一种加深知识的方法是阅读更多关于更专业和高级主题的书籍。以下是我最喜欢的几本书:

  • 《游戏设计艺术:视角之书》,作者 Jesse Schell。这本书对游戏设计相关的所有内容进行了非常全面的介绍。

  • 游戏编程模式》,作者 Robert Nystrom。想了解更多关于游戏中应用到的编程模式?那么这本书就是为你准备的。

  • 游戏设计中的乐趣理论》,作者 Raph Koster。一本关于什么使游戏有趣以及它们文化重要性的简短书籍。

  • Godot 4 游戏开发项目》,作者 Chris Bradfield。

  • GAMEDEV:制作你的第一个成功游戏 10 步》,作者 Wlad Marhulets。

任何这些都能帮助你成为游戏开发者和设计师的道路上。

阅读 Godot 引擎文档

除了第三方资源外,当然还有官方的 Godot 引擎文档。这是一个关于所有不同类和节点以及包含与引擎相关的所有不同子系统的手册的非常详尽的信息来源。

你可以在这里访问文档:docs.godotengine.org/

每当你搜索如何使用引擎的某个部分时,你应该首先查阅文档。

查看他人的游戏项目代码

通过查看他人的游戏项目和代码是一种极好的学习方法。因为 Godot 引擎是一个开源项目,许多使用该引擎的游戏也是开源的。这意味着整个项目都可以在线上供任何人查看和尝试。

一些优秀的项目包括以下内容:

注意,这些项目通常仍在开发中,或者可能是用较旧的 Godot 引擎版本制作的,但看看人们是如何解决某些问题总没有坏处。

学习新主题是提高你的游戏开发技能的绝佳方式;它将使你能够更快地完成更多任务。然而,另一种重要但常被忽视的提高方式是通过加入一个喜欢分享、提供反馈和支持彼此的志同道合的人的社区。让我们看看我们如何加入这样的社区。

加入社区

最后,我想鼓励你加入游戏开发社区。一般来说,这是一个非常友好和热情的人群,他们喜欢听到你的想法和你的游戏。不要犹豫,去接近他们并询问问题。大多数人都会非常乐于助人,并鼓励你在旅程中前进。

加入论坛、Discord、Reddit 或任何其他平台

有许多不同的社区与在多个平台上创建游戏相关联而兴起。无论你在哪个社交媒体上最活跃,那里很可能有一个充满活力的游戏开发者社区。以下是一些你可以加入的渠道:

分享你的进度,看到其他人的进度,并与他人讨论游戏开发,这可以是非常有动力和令人满足的建立你周围的小社区的方式。

为 Godot 引擎项目做出贡献

因为 Godot 引擎是开源的,而且你现在已经知道如何使用它,所以如果你想帮忙的话,可以这样做。以下是一些你可以参与的方式:

  • 记录问题:Godot 引擎,像任何软件一样,不幸的是并非没有错误,但很多人正在解决这些问题。如果你在引擎中发现了错误,你总是可以在引擎的 GitHub 页面上打开一个问题:github.com/godotengine/godot/issues.

  • 编写文档:无论何时你在阅读文档,如果你觉得你所在的页面有点不足,请不要犹豫,添加你自己的改进并帮助任何其他新接触 Godot 引擎的人。每个文档页面都有一个链接,可以直接带你到可以编辑页面的地方。

图 16.1 - 点击 GitHub 上的“编辑”来提议更改 Godot 文档页面

图 16.1 - 点击 GitHub 上的“编辑”来提议更改 Godot 文档页面

这些是你可以为 Godot 项目和社区做出贡献的最大方式之一。现在让我们回到下一节,继续创建游戏。

参加游戏马拉松

成为游戏开发者的一大部分当然是开发游戏。没有什么比与他人一起开发游戏更有趣了。这正是游戏马拉松的目的所在。游戏马拉松是一些活动,你可以在短时间内从头开始创建一个游戏,通常是一个周末,但也可以长达一个月或更长时间。

目标是创建一个小游戏,然后由马拉松的其他参与者来玩。这是获取大量反馈并在此期间玩许多其他人的游戏的好方法。更重要的是,你可以以团队的形式参加这些马拉松。这样,你可以在游戏开发世界中建立联系,并学会与多个人合作。然而,你也可以单独参加。

提供游戏马拉松的最大平台之一是itch.io,我们曾用它上传我们的游戏在第十二章。只需访问itch.io/jams,寻找你感兴趣的游戏马拉松。在任何给定的时间点,总有无数的游戏马拉松在进行。

图 16.2 - Itch.io 上的游戏马拉松页面

图 16.2 - Itch.io 上的游戏马拉松页面

你可以加入的一些优秀的游戏马拉松如下:

  • Godot Wild Jam:专门为 Godot 游戏开发者举办的每月一次的游戏马拉松:godotwildjam.com/

  • 全球游戏马拉松:全球范围内组织的实体游戏马拉松。查看网站以找到您附近的地点:globalgamejam.org/

  • GMTK 游戏马拉松:由游戏开发者工具包YouTube 频道组织的年度游戏马拉松。这个活动没有稳定的网站链接,但总是在 YouTube 频道上宣布:www.youtube.com/@GMTK

现在,你已经知道了如何融入游戏开发者社区,并开始与成千上万的志同道合的人分享你的精彩游戏。

再见

在本章中,我们通过向您展示您在创建优秀游戏旅程中的下一步是什么来结束本书。我们看到了我们可以工作的可能项目,如何学习新事物,以及如何加入社区。

整本书的目的是让新人们开始使用 Godot 引擎创建游戏。我们从对编程或游戏开发一无所知,发展到对两者都有非常扎实和中级理解。

在开始时,我们学习了如何使用 GDScript 作为我们的首选语言,了解了基本概念,如变量和循环,并逐步过渡到更中级的话题,如类和正确的编程指南。我们最终进入了高级话题,如 super 关键字和编程模式。

在这本书中,我们还开发了自己的游戏,一款模仿 Vampire Survivors 的小型生存游戏。我们了解了节点是什么,以及在各种不同情况下应该使用哪些节点。我们甚至让游戏支持多人模式,并在 Itch.io 上发布,供任何想玩的人游玩!

我希望你在学习如何编程和使用 Godot 引擎的过程中玩得开心,并且我衷心感谢你坚持到最后。现在,去创造一些令人惊叹的游戏吧!

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