Godot4-多人游戏创建精要指南-全-

Godot4 多人游戏创建精要指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

《使用 Godot 4.0 创建多人游戏的必备指南》是理解如何使用开源 Godot 引擎制作在线多人游戏的终极实践指南。在其第四版中,Godot 引擎引入了一个高级网络 API,使用户能够专注于创建有趣和有趣的机制,同时让引擎完成繁重的工作。

通过这本书,你将学习网络的基础知识,包括基本的 UDP、TCP 和 HTTP 协议。你将看到 Godot 引擎如何使用其 ENet 库实现将这些协议无缝集成到其游戏开发工作流程中。通过包括五个游戏在内的九个项目,其中一个是在线多人冒险游戏,你将学习到连接玩家在一起进行令人惊叹的共享体验所需的所有知识。

本书面向的对象

这本书是为中级 Godot 引擎用户所写,这些人已经知道 Godot 引擎的工作原理、其设计理念、编辑器、文档和其核心功能。这些用户已经使用 Godot 引擎制作了游戏。他们正在寻找能让他们的下一个项目脱颖而出的东西,而添加在线多人功能就是那件事。

本书涵盖的内容

第一章设置服务器,解释了网络是什么以及 Godot 引擎如何通过其 ENet 库实现来实施网络功能。你将学习如何进行第一次握手,以有效地连接服务器和客户端机器。

第二章发送和接收数据,讨论了网络的基础,即多台计算机之间交换数据。在本章中,你将了解到我们使用一种称为数据包的数据结构,并将数据序列化以在网络中重新创建游戏状态。为此,我们使用 UDP 协议。最后,我们将有一个登录屏幕,有效地从服务器检索数据。

第三章创建大厅以聚集玩家,解释了 Godot 引擎如何通过提供远程过程调用RPCs)来简化使用行业标准 UDP 协议进行序列化和交换数据的过程,使我们能够本质上对远程对象上的方法进行调用。到本章结束时,我们将登录屏幕扩展为大厅,向等式中添加另一个客户端,并将三台计算机连接在一起。

第四章创建在线聊天,解释了有了 RPCs 的力量,我们现在可以轻松地远程更改对象的状态。在本章中,我们学习如何使用 RPCs 允许玩家在聊天中交换消息。我们讨论了如何使用 RPCs 和通道来防止网络瓶颈。有了这些,我们为实施实际游戏功能做好准备。到本章结束时,我们将拥有一个完全功能性的聊天。

第五章, 制作在线问答游戏,解释了我们可以如何根据通用游戏状态同步玩家的游戏状态。我们将设置一个服务器,该服务器将对玩家交互做出反应,并将游戏状态从等待响应更改为处理比赛胜者、宣布胜者、开始新比赛以及达到可用问答问题的尽头,从而有效结束游戏。到本章结束时,我们将拥有一个多玩家在线问答游戏,多个玩家竞争回答最多正确的问题。

第六章, 构建在线国际象棋游戏,继续实现基于回合制的多人在线游戏,而经典国际象棋是这方面的最佳选择。在本章中,我们将学习如何在保持玩家机器上大量处理的同时,最大限度地发挥我们的 RPC(远程过程调用)的作用。我们还将讨论 MultiplayerSynchronizer 节点,它允许我们轻松远程同步节点属性。我们还将了解什么是 Multiplayer Authority,它防止玩家干扰其他玩家的对象。到本章结束时,我们将拥有一个完全功能性的在线国际象棋游戏。

第七章, 开发在线乒乓球游戏,开始了从回合制到动作的转变。动作游戏高度依赖玩家的反应时间,游戏世界应该快速更新其状态,以便玩家能够获得流畅的体验。在这里,我们将开发一个在线乒乓球游戏,并使用 MultiplayerSynchronizer 节点同步玩家的拍子和球。我们还将了解到某些功能应该使用不同的同步过程。我们将更深入地探讨 Multiplayer Authority 领域,以防止一个玩家的输入干扰另一个玩家的拍子移动。到本章结束时,我们将拥有一个可玩的多玩家在线乒乓球游戏。

第八章, 设计一个在线合作平台游戏,是我们的小步前进停止的地方,我们开始为自定义游戏实现有趣的功能。我们将原型化一个物理益智平台游戏,玩家需要抓取并移动箱子来克服障碍并达到关卡目标。应用我们在前几章中学到的所有知识,我们将通过在对象位置上同步动画来扩展 MultiplayerSynchronizer 的使用。到本章结束时,我们将拥有一个物理益智合作平台游戏的可行原型。

第九章创建在线冒险原型,如果我们说实话,这正是你在整本书中一直在寻找的东西。在这里,我们将运用所有技能来创建一个在线多人冒险游戏。玩家可以随时加入和离开,世界是持久的,保持玩家在任务中的进度。我们将讨论制作 MMORPG 游戏的基础知识,将玩家的任务进度存储和检索到数据库中,同步玩家的太空船和子弹,以及使他们的行为影响其他玩家的体验。到本章结束时,我们将拥有一个在线多人从上到下的太空射击冒险游戏的原型。

第十章调试和性能分析网络,在实现我们的从上到下的太空射击原型在线多人游戏功能之后继续。我们现在需要为成千上万的玩家同时玩我们的游戏铺平道路。为此,我们将使用 Godot 引擎内置的调试和性能分析工具来评估我们游戏中可能需要改进的潜在区域。我们将重点关注网络性能分析器和监控调试工具,以评估和提出针对我们在原型中发现的瓶颈的潜在解决方案。到本章结束时,我们将拥有开发者可以拥有的最强大和必要的两种技能:调试和优化游戏的能力。

第十一章优化数据请求,基于我们对现有工具的理解来评估我们需要发现潜在改进区域的信息;现在,是时候动手实践了。在本章中,我们将学习如何创建自定义监控器来收集关于特定游戏功能的数据,并决定最佳策略来优化它们。到本章结束时,我们将重构我们的从上到下的太空射击冒险游戏,减少我们产生的带宽和 RPC 数量,从而有效地减轻我们的网络消耗。我们还将实施几种减少网络负载的技术,使用网络性能分析器和自定义监控器评估每次改进,看看游戏变得有多好。

第十二章实现延迟补偿,讨论了由于为了减少网络使用而进行的改进,我们的游戏可能在玩家的机器上不准确复制的这个问题。随着 RPC(远程过程调用)的减少和更稀疏的同步,游戏可能在玩家之间变得异步。再加上延迟和丢包,实际上会恶化玩家的体验。没有人喜欢游戏中出现延迟。在本章中,我们将学习如何使用 Tweens 来实现插值、预测和外推,以补偿所有这些问题。到本章结束时,我们将拥有一个带有一些虚假延迟和解决这个破坏性问题的解决方案的从上到下的太空射击原型版本。

第十三章通过缓存数据减少带宽,处理了一个重要问题:在我们进行网络工程的过程中,我们了解到带宽是我们的核心资源,我们应该始终寻求优化其使用。在本章中,我们将学习如何使用 HTTP 下载一些数据并将其存储在玩家的机器上,以便在需要时可以重用。到本章结束时,我们将实现一个功能,允许玩家为他们的太空船使用自定义图片,并且这个新图片将被复制到所有其他玩家的游戏实例中。为了节省带宽,我们将使用用户数据文件夹实现缓存。

要充分利用本书

本书使用 Godot 引擎的 ENet 库实现来创建几个原型,探索这项技术的边界。为了充分利用本书,您需要了解 Godot 引擎的工作原理、如何使用 GDScript 编程、如何使用 Git 以及 UDP、TCP 和 HTTP 协议的基础知识。

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

在整本书中,我们使用书中 GitHub 仓库中可用的项目。本书专注于每个项目的网络方面,因此为了节省您的时间,我们提供了现成的项目供您使用,这样您就不必烦恼于实现与网络工程无关的其他功能。从这个意义上讲,建议您安装 Git,尽管这不是强制性的,因为您也可以通过提供的链接直接下载代码。

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0/。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Godot 引擎的 Network API 核心功能之一是ENetMultiplayerPeer类。”

代码块设置如下:

func _process(delta):
    server.poll()
    if server.is_connection_available():
        var peer = server.take_connection()
        var message = JSON.parse_string(peer.get_var())
        if "authenticate_credentials" in message:
            authenticate_player(peer, message)
        elif "get_authentication_token" in message:
            get_authentication_token(peer, message)

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从那里,我们首先需要一个服务器。所以,选择一个实例并按下ServerButton。”

小贴士或重要注意事项

看起来像这样。

联系我们

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

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

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

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

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

分享您的想法

读完《Godot 4.0 创建多人游戏必备指南》后,我们很乐意听听您的想法!请点击此处直接转到此书的 Amazon 评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

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

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

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

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

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

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

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

packt.link/free-ebook/9781803232614

  1. 提交您的购买证明

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

第一部分:握手和网络

在本书的这一部分,我们迈出了网络领域的第一步。我们首先使用 Godot 引擎的高级 EnetMultiplayerPeer 类进行握手。我们还学习了如何使用 UDP 协议交换数据,最后学习了如何使用 远程过程调用RPC)。

本部分包含以下章节:

  • 第一章, 设置服务器

  • 第二章, 发送和接收数据

  • 第三章**, 创建大厅以聚集玩家

  • 第四章**, 创建在线聊天

第一章:设置服务器

欢迎来到 《使用 Godot 4.0 创建多人游戏必备指南》。在这本实践手册中,你将学习用于使用 Godot Engine 4.0 网络 API 创建在线多人游戏的核心概念。

首先,我们将了解一些关于计算机通过网络进行通信的基本方面以及主要协议,包括哪些对于制作在线多人游戏更为相关。

之后,我们将了解 Godot Engine 4.0 如何使用并提供其网络 API 的低级和高级实现。我们将了解一些核心类,我们可以使用这些类在同一个网络上的多台计算机之间传递数据。然后我们将关注称为ENetMultiplayerPeer的高级 API。

在基础知识到位后,我们将利用我们刚刚学到的知识将本地游戏功能转换为在线游戏功能。为此,我们将开发五个游戏项目:

  • 在线问答游戏

  • 检查器

  • 乒乓球

  • 合作平台游戏

  • 俯视冒险游戏

然后,我们将学习一些技术,我们可以使用这些技术通过优化游戏发送、接收和处理网络数据的方式来提高玩家的体验。我们将了解我们不需要持续更新,我们可以用小块数据完成大部分游戏玩法,并让客户端的计算机自行填补空白。

在每一章中,你将扮演一个为虚构的独立游戏开发工作室工作的网络工程师。在每一章中,你将应用你最近学到的知识来解决工作室同伴提出的虚构问题。你将专注于他们展示的每个项目的网络方面,这样你就不会浪费宝贵的时间去理解不必要的方面。

在本章中,你将学习建立计算机网络的最重要的方面:将它们全部连接起来。你将了解这个过程是如何发生的,为什么要这样做,建立这种连接需要什么,以及我们如何使用 Godot 引擎提供的 API 来实现这一点。

我们将在本章中介绍以下主题:

  • 网络简介

  • 理解 Godot 引擎网络 API

  • 设置客户端

  • 设置服务器端

  • 制作你的第一个握手

到本章结束时,你将拥有一个应用程序的客户端和服务器版本,该应用程序建立了两台或多台计算机之间的连接。这是我们将在整本书中看到的一切的核心,有了这些知识,你将了解如何开始让计算机在网络中通信,这正是你在在线多人游戏中需要做的。

技术要求

Godot 引擎有自己的独立文本编辑器,这就是我们将用它来编写所有实践课程的代码。

如前所述,在这本书中,你将扮演一个虚构独立游戏工作室的网络工程师。因此,我们将提供所有非网络相关工作的预制作项目。你可以在本书的 GitHub 仓库中找到它们:github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

在将项目正确添加到你的 Godot 引擎项目管理器后,打开项目并转到res://01.setting-up-a-server文件夹。在这里,你可以找到你需要遵循本章(后续部分)的内容。

网络简介

构建一个连接的计算机网络是一项相当艰巨的任务。在本章中,我们将了解在线网络的核心概念。我们还将介绍 Godot 引擎如何为我们可能面临的每个问题提供解决方案,以制作在线多人游戏。

网络是一组相互连接的设备,它们相互通信。在这些通信中,这些设备交换信息并相互共享资源。你可以有一个本地网络,比如在家庭或办公室中,或者一个全球网络,比如互联网。这个想法是相同的。

为了使这些设备能够通信,它们需要执行我们所说的握手。握手是设备识别另一设备并建立它们的通信协议的方式。这样,它们就知道它们可以请求什么,期望得到什么,以及需要向对方发送什么。

握手开始于一个设备向另一个设备发送消息。我们称这个消息为握手请求。设备使用这个消息来启动握手过程。发送请求的设备等待接收方的消息。我们称这个第二个消息为握手响应

图 1.1 – 握手过程

图 1.1 – 握手过程

当请求的设备通过握手响应发送确认时,它们建立了它们的通信。之后,设备开始交换数据。这标志着握手过程的结束。我们通常称请求数据的设备为客户端。至于提供数据的设备,我们称它为服务器

注意,我们第一次交互时使用这些名称。在这第一次交互之后,这些角色发生变化是很常见的。从这个意义上说,沟通是动态的。服务器可能从客户端请求数据,而客户端可能向服务器提供数据。

在本章中,我们将使用 Godot 引擎网络 API 进行我们的第一次握手。我们还将创建和同步网络上的玩家数据。所以,请系好安全带,因为你将学习以下内容:

  • ENet 库是什么以及为什么我们在游戏中使用它

  • 如何使用ENetMultiplayerPeer类进行握手

为了做到这一点,你将创建一个 Godot 项目,该项目列出已连接的玩家并允许他们编辑和同步一行文本。这是一个简单而优雅的项目,涵盖了在 Godot 引擎中设置在线多人环境的基础。

理解 ENetMultiplayerPeer 类

Godot 引擎网络 API 的核心特性之一是 ENetMultiplayerPeer 类。通过使用这个类,我们可以在我们的游戏服务器和客户端之间执行握手。

ENetMultiplayerPeer 类是 ENet 库的高级实现。让我们了解这个库以及为什么我们在在线多人游戏中使用它。

ENet 库是什么?

ENet是一个轻量级、开源的网络库,在游戏开发行业中广泛使用。它被设计成一个高性能、可靠且易于使用的库,用于创建多人游戏和其他网络应用。ENet 库的一个优点是它是跨平台的,用 C 语言编写。因此,它具有小体积和低开销。

该库提供了一个简单且易于使用的 API,使得开发者能够轻松创建和管理网络连接,发送和接收数据包,并处理网络事件,如断开连接和数据包丢失。

在这个背景下,数据包是服务器和客户端在网络中传输的小数据单元。我们使用它们在网络上不同设备之间传输诸如游戏状态、玩家输入和其他类型的数据。

ENet 库提供了对多个通道的支持,使我们能够轻松地在单个连接中创建多个数据流,如语音和视频。这对于许多多人游戏来说是非常出色的。

在多人游戏中使用 ENet 的另一个原因是它易于使用的基于 UDP 协议的网络库。这是一个了解主要网络协议之一的好机会,让我们来做这件事。

UDP 协议是什么?

UDP 协议是一种无连接协议,非常适合实时、高带宽的应用,如在线游戏。这是因为它具有低延迟并能处理高吞吐量。为了保持一致,在网络术语的世界里,延迟指的是数据通过网络传输和接收的时间。

例如,在在线多人游戏中谈论延迟是非常常见的:玩家执行动作和游戏对其做出反应之间的时间。下一图展示了延迟是如何工作以及如何计算的:

图 1.2 – 延迟的视觉演示

图 1.2 – 延迟的视觉演示

它基本上是指数据穿越网络、由服务器正确处理并向客户端提供响应所需的时间。

带宽是指在一定时间内,我们可以通过给定的网络路由发送多少数据,在它被淹没之前。例如,当我们谈论DDoS 攻击时,这是一个基本概念,黑客通过大量的未解决请求淹没服务器,阻止其他客户端访问服务。在以下图中,你可以看到带宽概念的视觉表示:

图 1.3 – 带宽的视觉演示

图 1.3 – 带宽的视觉演示

带宽是指网络中可用通信通道的大小。你可以将其想象为一个传输数据的管道。更大的管道允许在任意给定时间内传输大量数据,而较小的管道可能甚至不允许传输任何大小数据。你可以在以下图中看到这一概念的解释:

图 1.4 – 带宽的视觉演示

图 1.4 – 带宽的视觉演示

与更常用的传输控制协议TCP)不同,用户数据报协议UDP)在传输数据之前并不在两个设备之间建立专用连接。相反,它只是将数据包发送到指定的目标地址,而不确保数据包已被接收或确认。

听起来…很糟糕,对吧?但实际上恰恰相反。

这种缺乏可靠性通常被视为 UDP 的缺点,但在在线多人游戏的环境中,这实际上可以是一个优势。在游戏中,响应速度和低延迟至关重要,建立和维护连接的开销可能是一个重要的瓶颈。

由于 UDP 不需要专用连接,因此允许更快、更有效地传输数据。此外,由于 UDP 不需要接收方确认接收数据包,因此它受网络拥塞或延迟的影响较小,这在维护高带宽、高延迟环境(如在线游戏)中的稳定和响应性连接时可能至关重要。

此外,UDP 的不可靠性在在线多人游戏的环境中实际上可能是有益的。在游戏中,即使是一小部分数据包丢失或延迟也可能对玩家的体验产生重大影响,因此游戏能够适应这些类型的网络条件非常重要。通过不提供数据包投递的保证,UDP 允许游戏以最适合特定游戏及其机制的方式处理数据包丢失和延迟。

考虑以下情况。

我们建立连接。在这个连接中,我们向网络中的所有玩家更新关于其他玩家角色在世界中的位置。这样,每个人共享相同的世界状态。

如果我们使用 TCP 协议,每个人都必须等待每个其他玩家发送他们的位置并确认他们已经收到了每个其他玩家位置的变化,同时还要尝试保持位置变化的正确时间顺序。

因此,在这个例子中,如果一个玩家向左移动五个单位并发送了包含所有移动数据的 15 个数据包,包括空闲状态,所有其他玩家都必须确认他们已经收到了这 15 个数据包。

使用 UDP,玩家可以忽略除最新更新之外的所有更新,这是实时体验中唯一相关的信息:现在游戏世界的状态是什么?它如何到达这个点并不重要;重要的是它就在这个时刻。

我们将看到这也会带来一些麻烦。但我们可以创建方法并理解技术来减轻这些问题。我们将在后续章节中讨论这一点。

这连接是如何发生的?

要建立 UDP 连接,我们需要两个核心要素:

  • 同伴的 IP 地址,主要是服务器

  • 他们将交换数据的端口

为了测试目的,在我们所有的项目中,我们将使用localhost IP 地址。这是到你的本地 IP 地址掩码的快捷方式。IP 地址就像一个房子或公寓的地址。它是给定数据包应该送达的确切位置,代表了计算机在网络中的地址。端口基本上是主机允许建立给定通信的特定通道;我们将使用9999作为我们的默认端口。这没有什么特别之处;它只是一个任意的选择。

考虑到这一点,让我们第一次看看ENetMultiplayerPeer类的实际应用。正如你可以想象的,这种设置需要双方面的方法。我们需要为我们的服务器设置一个游戏架构,并为客户端设置不同的架构。

让我们从服务器架构开始。

创建服务器

Godot 引擎中的ENetMultiplayerPeer类提供了一个方便的方式来创建和管理在线多人游戏的网络连接。这个类最重要的方法之一是create_server()方法,它用于创建一个新的服务器,该服务器可以接受来自客户端的连接。此方法使用简单,除了有五个参数外,它只需要一个参数就可以开始:

  • ENetMultiplayerPeer.create_server()方法的第一个参数是服务器将监听传入连接的端口。这是客户端将用于连接服务器的端口号。例如,如果你想服务器在端口9999上监听,你会调用ENetMultiplayerPeer.create_server(9999)。这是调用此方法的唯一必需参数。

  • 第二个参数是 max_clients,这是服务器允许同时连接的最大客户端数。此参数是可选的,如果没有指定,服务器将允许最多 4,095 个客户端连接。

  • 第三个参数是 max_channels,这是服务器允许每个客户端使用的最大通道数。通道用于分离不同类型的数据,例如语音和视频,并且对于在单个连接中创建多个数据流非常有用。此参数是可选的,如果没有指定,服务器将允许无限数量的通道。

  • 第四个参数是 in_bandwidth,这是服务器允许每个客户端的最大入带宽。此参数是可选的,如果没有指定,服务器将允许无限入带宽。

  • 第五个参数是 out_bandwidth,这是服务器允许每个客户端的最大出带宽。此参数是可选的,如果没有指定,服务器将允许无限出带宽。

让我们在 Godot Engine 中创建我们的服务器。打开之前提供的 GitHub 链接中的项目。打开项目后,执行以下步骤:

  1. 创建一个新的场景并使用 Node 实例作为根节点。

  2. 添加一个新的 Server.gd 文件。

  3. 保存场景并打开脚本。

  4. 定义一个名为 PORT 的常量并将其设置为我们的默认端口号,以便服务器可以监听它:

    const PORT = 9999
    
  5. 使用 new() 构造函数创建一个新的 ENetMultiplayerPeer。让我们将其存储在一个名为 peer 的变量中:

    var peer = ENetMultiplayerPeer.new()
    
  6. _ready() 函数中,调用 peer 变量的 create_server() 方法,并将 PORT 常量作为参数传入:

    func _ready():
        peer.create_server(PORT)
    
  7. 仍然在 _ready() 回调中,将 peer 变量分配给此节点内置的 multiplayer 成员变量:

        multiplayer.multiplayer_peer = peer
    
  8. multiplayer 变量的 peer_connected 信号连接到名为 _on_peer_connected 的函数。我们将在下面创建此回调方法:

    multiplayer.peer_connected.connect(_on_peer_connected)
    
  9. 创建一个名为 _on_peer_connected() 的新方法,它应该接收 peer_id 作为参数:

    func _on_peer_connected(peer_id):
    
  10. _on_peer_connected() 函数中,使用 print() 在控制台上打印传递的 peer_id 参数:

      print(peer_id)
    

    完整的脚本应该看起来像这样:

    extends Node
    const PORT = 9999
    var peer = ENetMultiplayerPeer.new()
    func _ready():
        var error = peer.create_server(PORT)
        multiplayer.multiplayer_peer = peer
        multiplayer.peer_connected.connect
            (_on_peer_connected)
    func _on_peer_connected(peer_id):
        print(peer_id)
    

重要的是要注意,此脚本使用了 Godot Engine 4.0 网络 API 中每个 Node 实例都有的内置 multiplayer 成员变量,它是一个 MultiplayerAPI 类的实例。

完成:我们的服务器已经准备好了。我告诉过你,这会很简单!

创建客户端

接下来,让我们创建我们的客户端。过程相当相似。主要区别是客户端需要服务器的 IP 地址才能在网络中找到它。

我们使用 ENetMultiplayerPeer.create_client() 方法将客户端连接到服务器。此方法非常简单易用,并且只需要两个参数即可工作:

  • create_client() 方法的第一个参数是服务器的地址。这可以是服务器的 IP 地址或主机名。例如,如果您想客户端连接到 IP 地址为 192.168.1.1 的服务器,您将调用 create_client("192.168.1.1")。但为了简化,我们将使用 "localhost",这是指向我们自己的 IP 地址掩码的快捷方式。

  • create_client() 方法的第二个参数是服务器监听传入连接的端口。这是客户端将用于连接到服务器的端口号。例如,如果服务器正在监听端口 9999,您将调用 create_client("192.168.1.1", 9999)

  • create_client() 方法的第三个参数是 channel_count,这是客户端将用于与服务器通信的通道数。通道用于分离不同类型的数据,例如语音和视频,并在单个连接内创建多个数据流非常有用。此参数是可选的,如果未指定,客户端将使用默认值 1 个通道。

  • create_client() 方法的第四个参数是 in_bandwidth,这是客户端在每个连接中允许的最大传入带宽。此参数是可选的,如果未指定,客户端将使用默认值 0,允许无限的传入带宽。

  • create_client() 方法的第五个参数是 out_bandwidth,这是客户端在每个连接中允许的最大传出带宽。此参数是可选的,如果未指定,客户端将使用默认值 0,允许无限的传出带宽。

  • create_client() 方法的第六个参数是 local_port,这是客户端将绑定到的本地端口。此参数是可选的,如果未指定,客户端将使用默认值 0

现在,让我们看看我们如何创建连接的 客户端 部分,以便它可以连接到我们的 服务器 并建立握手:

  1. 创建一个新的场景并添加一个 Node 实例作为根节点。

  2. 将一个新的脚本附加到它上面。

  3. 将脚本保存为 Client.gd

  4. 在脚本中定义一个名为 ADDRESS 的常量,并将其设置为服务器的 IP 地址。在这种情况下,我们将使用 "localhost"

    const ADDRESS = "localhost"
    
  5. 定义一个名为 PORT 的常量,并将其设置为默认端口号。确保这个数字与我们在 Server.gd 中使用的数字相匹配非常重要,否则这些对等体将无法找到彼此:

    const PORT = 9999
    
  6. 使用 new() 构造函数创建一个新的 ENetMultiplayerPeer 并将其存储在名为 peer 的变量中:

    var peer = ENetMultiplayerPeer.new()
    
  7. _ready() 回调中,调用 peer 变量上的 create_client() 方法,传入 ADDRESSPORT 常量作为参数:

    func _ready():
      peer.create_client(ADDRESS, PORT)
    
  8. peer 变量分配给节点的内置 multiplayer 成员变量:

    multiplayer.multiplayer_peer = peer
    

    完整的脚本应该看起来像这样:

    extends Node
    const ADDRESS = "localhost"
    const PORT = 9999
    var peer = ENetMultiplayerPeer.new()
    func _ready():
        peer.create_client(ADDRESS, PORT)
        multiplayer.multiplayer_peer = peer
    

好的,我们的服务器和客户端已经准备好了。现在,我们该如何测试它们呢?

测试我们的握手

Godot 引擎 4.0 拥有一个有用的调试功能:能够打开多个独立的游戏实例。这个功能允许我们同时测试不同的场景,使调试过程更加容易和快速。

要打开多个游戏实例,我们需要在 Debug | Run Multiple Instances 菜单中的最多四个选项中选择一个。

图 1.5 – 运行多个实例菜单

图 1.5 – 运行多个实例菜单

然后,当我们按下 Run ProjectRun Current Scene 按钮时,Godot 将启动我们之前设置的实例。让我们在这个项目中坚持使用两个实例。

这个功能对于测试在线多人游戏非常有用,因为它允许我们在同一运行中打开服务器和客户端。但是,正如你所看到的,它并不直接。当我们运行项目时,它实际上打开了同一场景的两个实例。

让我们创建一个最小的菜单,我们可以选择我们是客户端还是服务器:

  1. 创建一个新的场景,使用 Control 作为根节点并将其命名为 MainMenu

  2. Label 节点作为根节点的子节点添加。

  3. 将两个 Button 节点作为根节点的子节点添加。

  4. 将第一个 Button 命名为 ClientButton,第二个命名为 ServerButton

图 1.6 –  的场景树结构

图 1.6 – MainMenu 的场景树结构

  1. Button 节点的 text 属性分别设置为 I’m a clientI’m a server 并将它们并排放置在屏幕中间。

  2. Label 节点的 text 属性设置为 Are you a… 并将其放置在屏幕中间。

图 1.7 –  的场景 UI

图 1.7 – MainMenu 的场景 UI

  1. 添加一个新的 MainMenu 节点并打开它。

  2. 连接 _on_client_button_pressed

图 1.8 – ClientButton 的按下信号连接

图 1.8 – ClientButton 的按下信号连接

  1. 连接 _on_server_button_pressed 的按下信号。

  2. _on_client_button_pressed() 回调中,让我们在 get_tree() 实例上调用 change_scene_to_file() 方法,传入 "res://Client.tscn" 作为参数:

    extends Control
    func _on_client_pressed():
        get_tree().change_scene_to_file
            ("res://Client.tscn")
    
  3. _on_server_button_pressed() 回调中,与之前相同,传入 "res://Server.tscn" 代替。

    完整的脚本应该看起来像这样:

    extends Control
    func _on_client_pressed():
        get_tree().change_scene_to_file
            ("res://Client.tscn")
    func _on_server_pressed():
        get_tree().change_scene_to_file("res://Server.tscn")
    

现在,让我们确保在测试之前保存场景。之后,我们只需要点击 Run Current Scene 按钮并观察场景变得生动。所有艰苦的工作都已经完成,现在我们只需要欣赏结果。

一旦我们有两个调试实例正在运行,我们需要先选择一个作为服务器。为此,我们可以按下 Server.tscn 场景并开始监听传入的连接。

然后,在另一个实例中,我们需要按下Client.tscn场景并尝试连接到服务器。如果一切如预期进行,我们应该在服务器实例的控制台中看到打印出的peer_id

这意味着客户端和服务器已经成功建立了连接,现在可以开始交换消息了。恭喜你,你刚刚创建了你第一个握手!

摘要

在本章中,我们探讨了网络连接的基础,即通过称为握手的程序建立连接。

握手确保两台计算机在网络中相互识别并建立这种通信的协议。这一点非常重要,因为这是我们所有进一步努力的基石。如果没有这个,我们的玩家和服务器将会断开连接。一个将会向虚空发送数据,而另一个将会无限期地等待某物到来。

说到发送数据,既然我们的计算机已经连接并开放以接收和发送数据,现在是时候看看如何做到这一点了。在本章中,你看到了如何使用 ENet 库正确建立连接,以及 Godot 引擎如何提供高级别的握手方法,以至于我们几乎看不到是否真的发生了握手。

在下一章中,我们将使用 UDP 协议来在客户端和服务器之间建立连接。但这一次,我们将进一步挖掘,并实际上从客户端向服务器发送数据,以及反过来。

使用 UDP 协议来理解当我们最终开始习惯 Godot 引擎的ENetMultiplayer API 时,底层可能发生的事情是非常重要的。

现在,让我们看看下一章中低级数据传输的混乱世界,这样我们就可以理解,有了新的高级网络 API,我们的生活将变得多么容易!

第二章:发送和接收数据

在上一章中,我们看到了如何使用 Godot 引擎的高级 ENetMultiplayerPeer API 在两台计算机之间建立连接。但之后我们做什么呢?为什么我们要在计算机之间建立连接?网络的基础是连接计算机之间的通信,允许它们发送和接收数据。这些数据通过将内容分解成称为 数据包 的小块来传输。

每个数据包就像一张包含必要信息的明信片,例如发送者和接收者的 IP 地址、通信端口以及消息的内容。然后我们将这些数据包通过网络发送,它们可以被路由到预期的接收者。使用通信协议,如 UDP 协议,我们在发送端将数据分解成数据包,并在关系的接收端重新组装它们。

本章中,我们将讨论数据包发送和接收的基本原理以及使 UDP 协议独特之处。为此,我们需要稍微深入一些,并使用 Godot 引擎的 UDPServerPacketPeerUDP 类。这些是低级 API 类,因此在这里我们将讨论一些内容。

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

  • 理解数据包

  • JavaScript 对象表示法JSON)格式简介

  • 使用 PacketPeerUDP 发送数据包

  • 使用 UDPServer 监听数据包

  • 验证玩家身份

  • 加载玩家的头像

技术要求

在本章中,我们将继续在 Godot 引擎中跟进我们的项目,但这次,我们将使用 res://02.sending-and-receiving-data 文件夹中提供的文件。因此,如果您还没有这样做,请使用此链接下载项目的仓库:github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

然后,将项目添加到您的 Godot 引擎项目管理器中,打开项目并转到 res://02.sending-and-receiving-data 文件夹。

理解数据包

数据包 是使用 UDP 协议在网络中进行通信的基本构建块。它们是包含所有必要信息以到达其预期接收者的数据小块。这包括发送者和接收者的 IP 地址、通信端口以及消息的内容。

发送者通过网络将数据包发送到接收者。接收端重新组装数据包,使接收者能够理解发送的消息。这个过程被称为 数据包交换。您可以在以下位置看到这个过程的视觉表示:

图 2.1 – 数据包交换过程

图 2.1 – 数据包交换过程

与 TCP 协议等其他协议不同,UDP 协议不保证数据包会按发送顺序到达。这意味着该协议不太可靠,但更高效、更快。

UDP 与其他协议的不同之处还在于其缺乏连接状态。每个数据包都包含它到达接收端所需的所有数据。我们单独处理它们,网络根据每个数据包自己的信息进行路由。这与 TCP 协议形成对比,因为后者需要通过传统的握手程序建立一个预先安排的、固定的数据通道。

这意味着我们可以使用 UDP 协议发送这些数据包而不需要握手。只要我们的服务器在指定的端口上监听消息,它就能接收发送方的消息。

由于所有这些原因,UDP 协议在发送网络游戏数据时更加高效,因为它速度快,不需要在接收端等待每个数据包的确认。这对于在线多人游戏来说是一个巨大的优势,尤其是在玩家的反应时间对游戏很重要的情况下。

使用 UDP 协议进行快速消息系统和甚至语音通话也很常见。使用 UDP 进行语音通话可能带来的一个问题是有时音频无法按正确顺序或任何顺序到达对方。这会引起一些问题,但由于通信旨在实时进行,用户可以要求对话另一端的人重复,UDP 协议已成为此类服务的首选解决方案。重要的是要理解——何时以及何时不是正确的选择。

现在我们已经浏览了我们可以通过网络交换数据的协议,我们需要了解这些数据看起来是什么样子。我们能否通过网络发送对象的实例?它们将在接收端如何组装?

在这个意义上,网络通信要低级一些;我们需要在数据结构中发送只有发送方和接收方端点都能理解的相关信息。为此,我们通常避免传递二进制数据,例如对象。

相反,我们将重要信息序列化,并通过网络传输必要的块,以便接收端可以使用我们传输的数据创建一个全新的对象副本。这更加可靠,并且允许更小的带宽使用。我们常用的一个数据结构是以 JSON 文件格式组织的字典。

JSON 格式简介

在网络编程中,直接通过网络传输对象并不总是可靠的,因为数据可能在传输过程中被损坏或丢失。此外,如果代码是恶意的,包含可执行代码的对象的传输可能存在安全风险。这就是为什么使用数据序列化将对象转换为易于通过网络传输的格式是一种常见的做法。

最常用的数据序列化格式之一是 JSON。JSON 是一种轻量级的基于文本的格式,可以表示复杂的数据结构,如数组和对象,使其成为网络通信的理想选择。

当使用 Godot 引擎网络 API 和 UDP 时,发送和接收 JSON 文件是一种常见做法。使用 JSON,我们可以快速高效地进行序列化和反序列化。JSON 文件是可读的,这使得开发者更容易调试和排除问题。JSON 文件也是灵活的,这意味着我们可以只选择需要发送的相关数据,使网络通信更加高效。

与二进制格式不同,JSON 文件易于阅读和修改。这使得在数据传输过程中出现任何问题时更容易进行调试和故障排除。

现在我们已经了解了 JSON 格式的优点和背后的整体理念,我们如何正确地使用它?JSON 文件如何帮助我们通过网络传输数据并保持玩家在相同的游戏上下文中?

如本节所述,序列化是我们挑选出关于数据结构(如对象)的必要信息,并将其转换为可以传递甚至存储的格式的过程。序列化是软件工程领域(包括网络)中需要学习的重要技能之一。

正是通过序列化,我们可以将应用程序的状态转换为其他应用程序实例可以通过时间进一步复制此状态——例如,实现保存和加载系统,或者通过空间,正如我们将在在线多人游戏中做的那样。因此,让我们了解序列化是如何工作的以及如何进行序列化。

序列化

Sprite2D 节点,转换成一个简单、线性的表示,我们可以将其存储在文件中。例如,*.tscn 文件是序列化文件,代表 Godot 引擎编辑器中的一个场景。

序列化涉及将对象转换为可以在另一台机器或另一个上下文中轻松重建的格式。这可能涉及以标准格式(如 JSON)编码对象的属性、数据和相关信息。序列化在网络通信中至关重要,因为它允许数据高效、可靠地传输和接收,同时还能实现不同编程语言和系统之间的互操作性。

例如,如果我们想在客户端根据服务器提供的数据重新创建一个 Sprite2D 节点,我们可以序列化重要的属性,如其位置、旋转、缩放和纹理。它看起来会是这样:

{
  "position": {
    "x": 2244,
    "y": 1667
  },
  "rotation": 45,
  "scale": {
    "x": 2,
    "y": 2
  },
  "texture_path": "res://assets/objects/Bullet.png"
}

因此,在客户端,我们实例化一个新的 Sprite2D 节点,并使用这些数据确保它代表服务器希望客户端看到的内容。我们将在接下来的工作中大量使用序列化。在 Godot 中,我们有 JSON 辅助类用于创建和解析 JSON 数据。

JSON.stringify() 方法用于将对象或数据类型(如整数或字典)序列化为 JSON 格式的字符串。此方法接受一个对象作为输入,并返回包含输入对象 JSON 表示的字符串。

该字符串可以随后在网络中传输、存储在文件中或用于任何需要对象字符串表示的上下文中。使用 JSON.parse_string() 方法,生成的字符串可以轻松地反序列化为对象。

另一方面,JSON.parse_string() 方法用于将 JSON 格式的字符串反序列化为可识别的 Godot 数据类型或对象。此方法接受一个字符串作为输入,并返回反序列化的数据。生成的对象可以用于任何需要原始对象的地方。

在反序列化 JSON 字符串时,该方法负责将 JSON 值映射到适当的 Godot 引擎数据类型。这包括将字符串映射到字符串、数字映射到数字以及布尔值映射到布尔值,还包括解析更复杂的数据类型,如字典和对象。

通过 JSON.stringify()JSON.parse_string() 方法,Godot 引擎提供了一个简单且可靠的方式来将数据转换为可以在网络中传输或存储在文件中的格式。

我们看到了如何将我们的相关数据转换为可理解的标准格式,以便我们可以在接收端存储、传输和重新创建。让我们了解如何在网络中传递这些数据。

当我们处理在线多人游戏时,这是基本知识,因为我们将通过这个过程在玩家之间重新创建对象,甚至整个游戏状态,使他们共享相同的游戏世界。

使用 PacketPeerUDP 发送数据包

现在,让我们转向实际知识。在本章中,你的任务是为一个游戏实现一个登录系统。我们的项目已经有一个酷炫的用户界面,能够收集玩家数据,例如他们的登录名和密码。你的任务是确保只有授权的玩家可以通过实现安全认证功能来访问游戏内容。

一旦玩家成功登录,你需要根据我们在数据库中保存的内容显示他们的角色头像。作为一名网络工程师,你明白在线系统中的安全性至关重要。你知道一个强大的身份验证系统对于确保只有合法用户被授予访问游戏内容是必不可少的。

因此,你需要开发一个登录系统,该系统将检查玩家的凭证与安全数据库的匹配情况,并验证他们是否有权访问游戏的功能。

根据你的技能和经验,你需要创建一个系统,该系统能够在确保玩家数据安全的同时提供卓越的用户体验。所以,接受这个挑战,让我们创建一个登录系统,这将证明你作为网络工程师的技能!

在我们的项目仓库中,打开res://02.sending-and-receiving-data//MainMenu.tscn场景,让我们开始吧。

创建 AuthenticationCredentials Autoload

在 Godot 引擎中,Autoloads是当游戏开始时 Godot 自动加载的单例。我们可以在编辑器中创建和编辑它们,并从游戏中的任何脚本中访问它们。我们使用 Autoloads 来存储游戏范围内的数据或提供全局功能,这使得它们成为携带玩家凭证穿越游戏的一种便捷方式。

使用 Autoloads 携带玩家凭证的主要优势之一是它们在场景变化期间始终可用。这意味着任何游戏中的脚本都可以访问 Autoload 并在需要时检索玩家的凭证。这消除了从一个脚本传递凭证到另一个脚本的必要性,使得代码更干净且更容易维护。

此外,由于 Autoloads 在整个游戏生命周期中都是持久的,只要玩家不关闭游戏,我们就可以访问他们的凭证。

这可以使实现具有身份验证功能的登录系统的过程更加高效和流畅。

那么,让我们创建我们的AuthenticationCredentials Autoload,如下所示:

  1. 使用Node节点作为根节点创建一个新的场景。

  2. 重命名根节点AuthenticationCredentials

  3. 向其附加一个新的脚本,将其保存为AuthenticationCredentials.gd,并打开它。

  4. 创建一个变量来存储玩家的用户名;我们可以将这个变量命名为user,并且它应该默认为空字符串:

    extends Node
    var user = ""
    
  5. 然后,创建一个变量来存储我们在成功验证登录时保存的会话令牌:

    var session_token = ""
    
  6. 保存场景,然后转到项目 | 项目设置并打开Autoload选项卡。

  7. 路径字段中,点击小文件夹图标:

图 2.2 – 项目设置菜单中的 Autoload 选项卡

图 2.2 – 项目设置菜单中的 Autoload 选项卡

  1. 从弹出菜单中选择AuthenticationCredentials.tscn

图 2.3 – 从文件菜单中选择 AuthenticationCredentials 场景

图 2.3 – 从文件菜单中选择 AuthenticationCredentials 场景

  1. 节点名称 字段保留为 AuthenticationCredentials 并点击 添加 按钮。

现在我们已经完成了。现在,您可以通过调用 AuthenticationCredentials 单例在任何地方访问在 AuthenticationCredentials.gd 场景脚本中定义的变量和函数。

这对于跟踪游戏中多个场景和节点之间的全局状态非常有用。需要注意的是,这个 Autoload 应该只存在于多人游戏的客户端端,而不是服务器端。所以,请确保从您的服务器应用程序中删除它。

现在,让我们看看我们如何收集并发送玩家的凭证到服务器。为此,我们将直接在登录屏幕本身上工作!打开 LoginScreen.tscn,然后继续进行有趣的部分。

发送玩家的凭证

命名为 LoginScreenControl 节点,其用户界面用于捕获玩家的凭证,以便我们可以进行身份验证并让他们访问我们的世界:

图 2.4 – 登录屏幕场景的节点层次结构

图 2.4 – 登录屏幕场景的节点层次结构

界面包括两个 LineEdit 节点,一个允许玩家输入他们的登录凭证。如果发生任何错误,我们可以使用 ErrorLabel 节点来显示必要的消息。

由于我们在这里收集玩家的凭证,我们可以使用 LoginButton 节点来触发登录过程。有了这个场景,一旦玩家成功登录,他们就可以安全地访问他们的头像屏幕。

但现在,我们需要在加载他们的头像之前验证他们的登录。所以,让我们动手吧。按照以下步骤进行操作:

  1. 打开 LoginScreen.gd 脚本并进入 send_credentials() 函数。

  2. send_credentials() 函数内部,创建一个名为 message 的字典,其中包含我们将要在服务器上验证的用户凭证。

  3. 要存储这些凭证,在消息字典中创建一个名为 'authenticate_credentials' 的键;其值也应该是一个字典。我们将用它来存储玩家的凭证。

  4. 使用 user_line_editpassword_line_edit 文本属性来捕获玩家输入的用户名和密码:

        var message = {'authenticate_credentials':
            {'user': user_line_edit.text, 'password':
                password_line_edit.text}}
    
  5. 使用 PacketPeerUDP.new() 构造函数实例化一个新的名为 packetPacketPeerUDP 对象:

        var packet = PacketPeerUDP.new()
    
  6. 使用 connect_to_host() 方法将 packet 对象连接到服务器的地址和端口。在这里,我们使用默认的 ADDRESSPORT 常量,它们分别代表客户端连接到的服务器的 IP 地址和端口号。它们分别是 127.0.0.19999

    packet.connect_to_host(ADDRESS, PORT)
    
  7. 使用 JSON.stringify() 方法将消息字典对象序列化为 JSON 格式的字符串,并使用 packet.put_var() 方法将其发送到服务器:

        packet.put_var(JSON.stringify(message))
    
  8. 创建一个 while 循环以等待来自服务器的响应。packet.wait() 方法等待数据包到达绑定的地址。如果它收到数据包,则返回 OK 错误常量;否则,它返回基于 Godot 错误常量的错误代码。因此,我们可以使用它来等待我们的数据包到达服务器端:

        while packet.wait() == OK:
    
  9. 当我们收到响应时,我们需要使用 JSON.parse_string() 方法将响应数据从 JSON 格式反序列化为字典对象。让我们将其存储在一个名为 response 的变量中:

            var response = JSON.parse_string
                (packet.get_var())
    
  10. 使用 in 操作符检查 response 字典中是否存在认证令牌。如果存在 "token" 字符串,将其值存储在 AuthenticationCredentials.session_token 中:

            if "token" in response:
                AuthenticationCredentials.session_token =
                    response['token']
    
  11. 之后,我们还可以将我们从服务器收到的消息中存在的 user 存储为玩家的用户名:

                AuthenticationCredentials.user = message
                    ['authenticate_credentials']['user']
    
  12. 更新用户界面以指示成功认证,并切换到 AvatarScreen.tscn 场景。如果令牌不存在,向玩家显示错误消息:

                error_label.text = "logged!!"
    
  13. 然后,在所有这些之后,我们可以使用 get_tree().change_scene_to_file("res://AvatarScreen.tscn") 方法将场景更改为实际的头像屏幕,并中断 while 循环:

                get_tree().change_scene_to_file
                    ("res://AvatarScreen.tscn")
                break
    
  14. 如果我们从服务器收到响应,但其中不包含 "token" 键,我们将使用 error_label.text 显示认证失败消息,并中断 while 循环:

            else:
                error_label.text = "login failed,
                    check your credentials"
                break
    

    到这一点,send_credentials() 方法应该看起来像这样:

    func send_credentials():
        var message = {'authenticate_credentials':
            {'user': user_line_edit.text, 'password':
                password_line_edit.text}}
        var packet = PacketPeerUDP.new()
        packet.connect_to_host(ADDRESS, PORT)
        packet.put_var(JSON.stringify(message))
        while packet.wait() == OK:
            var data = JSON.parse_string(packet.get_var())
            if "token" in data:
                error_label.text = "logged!!"
                AuthenticationCredentials.user = message
                    ['authenticate_credentials']['user']
                AuthenticationCredentials.session_token =
                    data['token']
                get_tree().change_scene_to_file
                    ("res://AvatarScreen.tscn")
                break
            else:
                error_label.text = "login failed,
                    check your credentials"
                break
    

现在我们已经了解了客户端的工作原理以及它将如何处理玩家数据,让我们了解连接的另一端将如何接收这些数据并处理它。为此,打开 Server.tscn 场景。

使用 UDPServer 监听数据包

欢迎来到我们的 Godot 引擎服务器场景!这个场景是我们游戏服务器逻辑的实现之处。

服务器是我们游戏的骨架,负责验证玩家并向他们提供有关其头像的数据,例如他们的名字和纹理文件。这个节点被称为 Server,它包含一个预写的脚本,其中包含一些基本变量。其中两个至关重要的变量是 database_file_pathlogged_users

database_file_path 变量是 FakeDatabase JSON 文件的路径,它代表一个包含玩家数据的假数据库。logged_users 变量是一个字典,用于存储当前登录的玩家。

这些变量对我们服务器的功能至关重要,我们将使用它们来验证玩家并向他们提供他们所需的数据。

让我们实现 Server 节点最重要的功能,即监听数据包。按照以下步骤进行:

  1. 打开 Server.gd 文件。

  2. 声明一个 server 变量并将其设置为 UDPServer.new()。这创建了一个新的 UDPServer 类实例,它将允许我们监听传入的连接:

    var server = UDPServer.new()
    
  3. _ready() 函数中,调用 server 变量的 listen() 方法,并将我们的默认 PORT 常量作为参数传递。这将启动服务器并使其监听传入的连接:

    func _ready():
        server.listen(PORT)
    
  4. _process(delta) 函数中,调用 server 变量的 poll() 方法来检查是否有任何传入的消息。此方法不会阻塞游戏循环,因此我们可以在 _process(delta) 函数中安全地调用它:

    func _process(delta):
        server.poll()
    
  5. server 变量上调用 is_connection_available() 方法来检查是否有客户端发送了消息。如果它返回 true,则调用 take_connection() 方法以获取一个 PacketPeerUDP 实例,我们可以用它来读取传入的消息:

    if server.is_connection_available():
        var peer = server.take_connection()
    
  6. 使用我们从 PacketPeerUDP 实例获得的 get_var() 方法来获取传入的消息。由于我们知道消息是 JSON 格式的字符串,我们可以使用 JSON.parse_string() 方法将其转换为我们可以工作的字典对象:

    var message = JSON.parse_string(peer.get_var())
    
  7. 检查传入的消息是否包含 "authenticate_credentials" 键。如果包含,则调用 authenticate_player() 函数,并将 peermessage 作为参数传递:

    if "authenticate_credentials" in message:
        authenticate_player(peer, message)
    

    我们将在稍后创建 authenticate_player() 方法,但就目前而言,我们的脚本应该看起来像这样:

    extends Node
    const PORT = 9999
    @export var database_file_path =
        "res://FakeDatabase.json"
    var database = {}
    var logged_users = {}
    var server = UDPServer.new()
    func _ready():
        server.listen(PORT)
    func _process(delta):
        server.poll()
        if server.is_connection_available():
            var peer = server.take_connection()
            var message = JSON.parse_string
                (peer.get_var())
            if "authenticate_credentials" in message:
                authenticate_player(peer, message)
    

我们刚刚看到了如何打开客户端和服务器之间的通信通道并开始监听消息。有了这个,我们可以过滤这些消息,以便服务器知道客户端正在请求什么——在我们的案例中,是验证玩家的凭证。

这是一个网络 API 的低级实现。有了这个,我们可以创建标准消息格式和内容,在服务器端触发事件,并期望从服务器获得标准响应。让我们看看我们的服务器如何回应这个客户端请求。

验证玩家

验证玩家凭证是多玩家游戏的一个关键方面。在我们的项目中,我们正在使用 Godot 引擎构建一个游戏的登录系统。该登录系统允许玩家使用他们的用户名和密码登录,并在登录成功后显示他们的角色头像。

我们将使用一个假数据库,以 JSON 文件的形式存储,来表示玩家的凭证。虽然这种方法比使用完整的数据库管理系统更简单,但它有自己的安全风险。因此,在准备就绪的项目中,请注意此方法的风险。

在我们的项目中验证玩家凭证时,我们还将使用 Godot 的 FileAccess 类从 JSON 文件加载假数据库并解析数据。这将允许我们比较玩家的登录凭证与数据库中的数据,并在凭证匹配时验证玩家。

加载假数据库

现在,让我们加载我们的数据库,以便我们可以检查从玩家客户端获取的数据是否与服务器上的任何数据匹配。简而言之,数据库是有组织的数据集合。在我们的案例中,我们将使用 JSON 文件格式作为我们的数据库。

使用 JSON 文件作为数据库的优势在于它们易于操作,并且您不需要具备数据库结构和安全性的先验知识。

例如,我们的模拟数据库由以下内容组成:

{
  "user1": {
    "password":"test",
    "avatar":"res://Avatars/adventurer_idle.png",
    "name":"Sakaki"
  },
  "user2": {
    "password":"test",
    "avatar":"res://Avatars/player_idle.png",
    "name":"Keyaki"
  }
}

您甚至可以在 Godot 文本编辑器中打开它;只需双击我们基础项目中提供的res://FakeDatabase.json文件。

前面的 JSON 文件代表一个简单的数据库,包含两个用户条目,"user1""user2",每个用户都有相应的一组数据。每个用户包含的数据包括密码、头像和姓名。

"password"字段包含每个用户的纯文本密码。这是一种非常简单的存储密码的方法,因为它不安全,因为可能被泄露。然而,它适合教育目的。

"avatar"字段包含一个指向表示用户头像的文件的引用。在这种情况下,它引用了我们游戏中的两个不同的图像文件,每个用户一个。

最后,"name"字段简单地存储了一个表示玩家头像名称的字符串。

注意,数据库文件绝对不应该对客户端可用。因此,在您的最终项目中,请确保将数据库文件从 Godot 的项目中移除,并放入一个安全的数据库设备中。

虽然 JSON 文件对于某些项目来说是一个很好的选择,但它们可能不适合其他项目。以下是一些需要考虑的优点和缺点:

  • 优点

    • 它们易于读写,这使得它们成为小型项目或开发速度优先时的理想选择。

    • JSON 文件可以被大多数编程语言原生解析,包括我们之前看到的 GDScript,这意味着您不需要安装任何额外的软件或库来与之工作。

    • 正如我们刚才看到的,JSON 文件是可读的,可以使用简单的文本编辑器打开和编辑,这使得它们非常适合调试。

  • 缺点

    • 它们不适合大型项目,特别是有大量并发用户的项目,因为可能存在数据一致性和性能问题。

    • JSON 文件在查询数据和执行复杂操作方面不如其他数据库格式灵活。

为了加载和读取我们的 JSON 模拟数据库文件中的数据,我们将使用 Godot 引擎的FileAccess类。

FileAccess类是一个内置的 Godot 类,它提供了一个接口来加载、读取、写入和保存文件到用户的磁盘。它是一个强大的工具,对于任何需要从用户设备访问文件的游戏或应用程序来说都是必不可少的。

让我们具体探讨如何使用这个类将我们的 JSON 模拟数据库文件加载并解析到游戏中,如下所示:

  1. 前往Server.gd脚本中的load_database()函数。

  2. 在函数中,通过调用open方法并传入 JSON 文件的路径作为第一个参数,以及FileAccess.READ作为第二个参数来创建FileAccess类的新实例。READ常量告诉FileAccess类文件应该以读取模式打开:

    func load_database(path_to_database_file):
        var file = FileAccess.open(path_to_database_file,
            FileAccess.READ)
    
  3. 文件打开后,调用get_as_text()方法以读取文件的内容作为文本字符串:

        var file_content = file.get_as_text()
    
  4. 接下来,使用JSON.parse_string()方法解析文件的正文作为 JSON 字符串,并将结果字典存储在fake_database变量中:

        fake_database = JSON.parse_string(file_content)
    

    在我们继续回复玩家的认证请求之前,让我们看看这个函数在这些步骤结束时的样子:

    func load_database(path_to_database_file):
        var file = FileAccess.open(database_file_path,
            FileAccess.READ)
        var file_content = file.get_as_text()
        database = JSON.parse_string(file_content)
    

在我们的数据库就绪后,我们可以查看我们的有效玩家,并检查客户端发送的消息中接收到的凭证是否与我们存储的凭证匹配。理想情况下,我们会使用更安全的格式来避免任何数据泄露或黑客攻击,但这对我们的小型应用程序来说应该足够了。

现在,让我们看看如何根据玩家是否成功认证或认证失败来向客户提供一个有效的响应。在前一种情况下,我们将向玩家提供一个认证令牌,以便他们可以在整个游戏会话中使用它,从而无需进一步的认证程序来保持他们登录状态。

回复认证请求

当客户端将凭证发送到服务器进行认证时,服务器将接收它们并开始认证过程。服务器将使用凭证在我们的包含用户数据的假数据库中搜索匹配的记录。如果凭证匹配,服务器将生成会话令牌并将其发送回客户端。

会话令牌是一串唯一的字符,用于在服务器端标识客户端,客户端必须在所有后续请求中出示它以证明其身份。

为了验证凭证,我们调用load_database函数,我们可以在_ready()函数中这样做,将假数据库加载到我们的服务器中。

然后,我们将使用玩家通过logged_users字典提供的用户名,以及用户名,来跟踪已认证的用户。

如果客户端尝试使用无效或过期的会话令牌,服务器将拒绝请求,客户端将需要重新认证。这样,我们可以确保只有已认证的客户端在玩游戏时才能访问服务器的资源。

现在,让我们继续到authenticate_player()函数,并创建我们的认证逻辑。按照以下步骤进行:

  1. message字典中访问authenticate_credentials键,并将其存储在credentials变量中,如下所示:

    func authenticate_player(peer, message):
        var credentials = message
            ['authenticate_credentials']
    
  2. 通过运行以下代码来检查credentials字典中是否存在userpassword键:

        if "user" in credentials and "password" in
            credentials:
    
  3. 如果存在键,从credentials字典中提取userpassword键的值并将它们存储在单独的变量中:

    var user = credentials["user"]
    var password = credentials["password"]
    
  4. 检查我们刚刚存储的user键是否存在于我们的fake_database字典键中:

    if user in fake_database.keys():
    
  5. 如果user键存在,检查password键是否与存储在fake_database字典中的匹配:

    if fake_database[user]["password"] == password:
    
  6. 如果password键匹配,生成一个随机整数令牌并将其存储在以user为键的logged_users字典中,这样我们就可以在必要时始终检查它们:

    var token = randi()
    logged_users[user] = token
    
  7. 创建一个名为response的字典,包含一个键值对。键是token,值是token变量:

    var response = {"token":token}
    
  8. 使用peer.put_var()方法以 JSON 格式将response字典发送回客户端:

    peer.put_var(JSON.stringify(response))
    
  9. 如果密码不匹配,向客户端发送一个空字符串以指示认证失败:

    else:
        peer.put_var("")
    

    有了这些,我们应该有一个正确处理并回复玩家认证请求的方法。让我们看看它最终是如何完成的:

    func authenticate_player(peer, message):
        var credentials = message['authenticate_
            credentials']
        if "user" in credentials and "password" in
            credentials:
            var user = credentials["user"]
            var password = credentials["password"]
            if user in database.keys():
                if database[user]["password"] == password:
                    var token = randi()
                    var response = {"token":token}
                    logged_users[user] = token
                    peer.put_var(JSON.stringify(response))
                else:
                    peer.put_var("")
    

现在,让我们继续到整个过程中的一个重要部分。玩家将收到一个带有令牌的请求,正如我们在发送玩家凭据部分所看到的,他们将在AuthenticationCredentials自动加载中存储这个令牌。因此,之后,玩家的游戏将改变场景到AvatarScreen并尝试请求他们的头像。

让我们看看玩家将如何在整个过程中保持他们的会话有效。以下部分在玩家实际开始玩游戏后仍然至关重要。所以,请保持关注,了解我们如何始终确保玩家持有有效的令牌。

维持玩家的会话

任何在线游戏最重要的一个方面是保持玩家的会话在整个游戏过程中活跃。在我们的项目中,我们将确保玩家的令牌在整个游戏会话中都是可用的,即使在切换到不同的场景时也是如此。这样,我们可以在玩家玩游戏时保持他们的身份。

为了实现这一点,我们将使用AuthenticationCredentials单例在玩家的机器上存储令牌。这样,玩家的令牌将可供所有游戏脚本使用,使我们能够在进行任何其他场景之前检查玩家是否仍然处于认证状态。

通过在玩家的机器上保留令牌,我们可以避免不断向服务器发送登录请求以重新认证玩家,从而确保游戏体验更快更流畅。为了确保玩家的凭据仍然有效,我们将使用get_authentication_token()方法允许玩家的客户端向服务器请求他们的认证令牌。

当玩家即将过渡到新场景或自上次请求以来经过一定时间时,我们调用此方法。这样,我们可以确保玩家仍然处于认证状态,并且可以无任何问题地继续他们的游戏。

因此,仍然在 Server.gd 脚本中,转到 get_authentication_token() 方法,让我们开始为玩家提供他们需要玩我们的游戏所需的东西,继续以下步骤:

  1. get_authentication_token() 方法内部,让我们从 message 参数中提取用户信息。为此,我们可以创建一个名为 credentials 的新变量,并将其赋值为 message 参数:

    func get_authentication_token(peer, message):
        var credentials = message
    
  2. 然后,让我们检查 credentials 字典中是否有名为 "user" 的键:

        if "user" in credentials:
    
  3. 检查客户端提供的 token 键是否与存储的用户 token 键匹配:

        if credentials['token'] == logged_users
            [credentials['user']]:
    
  4. 创建一个名为 token 的变量来存储在 logged_users 变量中找到的 token 键。然后,让我们通过调用 peer.put_var() 方法并传递 JSON 格式的令牌字符串来返回用户的认证 token 键,以便客户端从服务器接收响应:

        var token = logged_users[credentials['user']]
        peer.put_var(JSON.stringify(token))
    

    我们的功能应该如下所示:

    func get_authentication_token(peer, message):
        var credentials = message
        if "user" in credentials:
            if credentials['token'] == logged_users
                [credentials['user']]:
                Var token = logged_users[credentials
                    ['user']]
                peer.put_var(JSON.stringify(token))
    

现在,无论何时我们需要执行任何需要服务器确认玩家仍在有效游戏会话中的程序,我们都可以调用此函数。但为了实际执行,我们需要在我们的服务器中添加两行代码,以便它能够理解客户端何时发出此类请求。

_process() 函数中,我们检查客户端是否正在请求 authenticate_credentials() 方法。让我们检查客户端是否正在请求 get_session_token() 方法,如果是的话,我们就调用它。_process() 函数应该如下所示:

func _process(delta):
    server.poll()
    if server.is_connection_available():
        var peer = server.take_connection()
        var message = JSON.parse_string(peer.get_var())
        if "authenticate_credentials" in message:
            authenticate_player(peer, message)
        elif "get_authentication_token" in message:
            get_authentication_token(peer, message)

现在,让我们继续我们这个小型项目的最后一部分,我们将提供和加载玩家的头像数据。

加载玩家的头像

欢迎来到 AvatarScreen!这是玩家将能够自定义他们的头像外观并在我们(模拟)游戏的最终版本中选择一个独特名称的地方。为了显示他们当前可用的头像,我们需要从数据库中加载玩家的头像数据并在屏幕上显示它。

为了做到这一点,我们有一个名为 ControlControl 节点,称为 AvatarCard

图 2.5 – AvatarScreen 场景的节点层次结构

图 2.5 – AvatarScreen 场景的节点层次结构

AvatarCard 节点包含一个用于使用纹理文件显示头像图像的 TextureRect 节点,以及一个用于显示头像名称的 Label 节点。

要加载玩家的头像,我们首先需要从我们的模拟数据库中检索图像文件的路径,我们之前已经用头像信息填充了这个数据库。因此,在我们深入 Server.gd 脚本中的动作之前,这次让我们专注于 get_avatar() 函数。继续以下步骤:

  1. get_avatar() 函数内部,创建一个包含消息内容的本地 dictionary 变量:

    func get_avatar(peer, message):
        var dictionary = message
    
  2. 检查 dictionary 变量中是否存在 "user" 键:

        if "user" in dictionary:
    
  3. 如果在这个字典中找到 "user" 键,让我们创建一个本地 user 变量,它等于 dictionary 变量中 "user" 键的值:

            var user = dictionary['user']
    
  4. 检查dictionary变量中的'token'键是否与由user键指定的用户存储在logged_users字典中的令牌匹配:

            if dictionary['token'] == logged_users[user]:
    
  5. 如果是这样,创建一个本地avatar变量,其值等于fake_database字典中由'user'键指定的用户的'avatar'键的值:

                var avatar = fake_database[dictionary
                    ['user']]['avatar']
    
  6. 创建一个本地nick_name变量,其值等于fake_database字典中由user键指定的用户的name键的值:

                var nick_name = fake_database[dictionary
                    ['user']]['name']
    
  7. 创建一个包含avatarname键的response字典,其中avatarnick_name的值分别对应于avatarnick_name

                var response = {"avatar": avatar, "name":
                    nick_name}
    
  8. 使用peer.put_var()方法将response字典作为 JSON 字符串发送给客户端:

                peer.put_var(JSON.stringify(response))
    

    这样,我们就完成了服务器的封装,因此我们准备好移动到get_avatar()函数来继续我们的工作:

    func get_avatar(peer, message):
        var dictionary = message
        if "user" in dictionary:
            var user = dictionary['user']
            if dictionary['token'] == logged_users[user]:
                var avatar = database[dictionary
                    ['user']]['avatar']
                var nick_name = database[dictionary
                    ['user']]['name']
                var response = {"avatar": avatar, "name":
                    nick_name}
                peer.put_var(JSON.stringify(response))
    

    现在,让我们打开AvatarScreen.gd脚本,以便我们最终可以显示玩家的头像!前往request_authentication()函数,因为如前所述,每次我们需要对玩家的数据进行操作时,我们都需要验证他们的凭据:

  9. request_authentication()函数内部,创建一个名为request的变量,它包含一个具有'get_authentication_token''user''token'键的字典。'get_authentication_token'的值应设置为true,以便服务器理解请求,而'user''token'的值应从AuthenticationCredentials单例中检索:

    func request_authentication(packet):
        var request = {'get_authentication_token': true,
            "user": AuthenticationCredentials.user, "token
               ": AuthenticationCredentials.session_token}
    
  10. 使用packet将请求发送到服务器,通过使用JSON.stringify()将请求编码为 JSON 字符串,然后使用put_var()方法发送它:

        packet.put_var(JSON.stringify(request))
    
  11. 使用while循环等待服务器的响应。在循环内部,创建一个名为data的变量来存储服务器返回的 JSON 响应,使用JSON.parse_string()进行解码:

        while packet.wait() == OK:
            var data = JSON.parse_string(packet.get_var())
    
  12. 检查data变量是否等于存储在AuthenticationCredentials单例中的session_token变量。如果是,调用request_avatar函数并退出循环:

            if data == AuthenticationCredentials.
                session_token:
                request_avatar(packet)
                break
    

    最后,我们的request_authentication()函数应如下所示:

    func request_authentication(packet):
        var request = {'get_authentication_token': true,
            "user": AuthenticationCredentials.user,
                 "token": AuthenticationCredentials.
                      session_token}
        packet.put_var(JSON.stringify(request))
        while packet.wait() == OK:
            var data = JSON.parse_string(packet.get_var())
            if data == AuthenticationCredentials.
                session_token:
                request_avatar(packet)
                break
    

    现在是检索玩家头像数据并显示他们的头像,以便他们可以参与我们的游戏世界的时候了!为此,让我们转到request_avatar()函数,创建头像请求和显示逻辑:

  13. request_avatar()函数内部,创建一个名为request的字典,包含'get_avatar''token'"user"键及其相应的值。我们从AuthenticationCredentials Autoload 获取用户和会话令牌:

    func request_avatar(packet):
        var request = {'get_avatar': true, 'token':
            AuthenticationCredentials.session_token,
                "user": AuthenticationCredentials.user}
    
  14. 使用packet.put_var()方法将request字典作为 JSON 格式的字符串发送到服务器:

        packet.put_var(JSON.stringify(request))
    
  15. 创建一个while循环以等待服务器响应。在循环内部,使用JSON.parse_string方法将响应解析为字典,并将其存储在名为data的变量中:

        while packet.wait() == OK:
            var data = JSON.parse_string(packet.get_var())
    
  16. 检查字典数据是否包含"avatar"键。如果包含,则从"avatar"键值中的路径加载头像图像的纹理,并将其设置为texture_rect的纹理。同时,将label的值设置为data字典中"name"键的值。最后,使用break退出while循环:

            if "avatar" in data:
                var texture = load(data['avatar'])
                texture_rect.texture = texture
                label.text = data['name']
                break
    

    我们几乎完成了登录屏幕!在我们添加最后的润色之前,让我们看看request_avatar()方法是如何结束的:

    func request_avatar(packet):
        var request = {'get_avatar': true, 'token':
            AuthenticationCredentials.session_token,
                "user": AuthenticationCredentials.user}
        packet.put_var(JSON.stringify(request))
        while packet.wait() == OK:
            var data = JSON.parse_string(packet.get_var())
            if "avatar" in data:
                var texture = load(data['avatar'])
                texture_rect.texture = texture
                label.text = data['name']
                break
    
  17. 现在,最后的润色是向Server.gd脚本添加另一个检查,以处理我们收到头像请求的情况。因此,_process()方法应该变成如下所示:

    func _process(delta):
        server.poll()
        if server.is_connection_available():
            var peer = server.take_connection()
            var message = JSON.parse_string
                (peer.get_var())
            if "authenticate_credentials" in message:
                authenticate_player(peer, message)
            elif "get_authentication_token" in message:
                get_authentication_token(peer, message)
            elif "get_avatar" in message:
                get_avatar(peer, message)
    

    如果我们通过点击播放按钮来测试我们的游戏,或者测试主菜单场景,我们可以验证我们的游戏是否正在运行!

  18. 我们需要做的第一件事是在一个调试实例中选择服务器按钮:

图 2.6 – 在主菜单场景中按下服务器按钮

图 2.6 – 在主菜单场景中按下服务器按钮

  1. 然后,在另一个实例中,选择客户端,它应该立即打开登录屏幕场景:

图 2.7 – 将玩家的用户名插入到客户端的登录屏幕 UserLineEdit 中

图 2.7 – 将玩家的用户名插入到客户端的登录屏幕 UserLineEdit 中

  1. 选择我们假数据库中可用的一个用户,并插入他们的凭证:

图 2.8 – 将玩家的用户名插入到客户端的登录屏幕 PasswordLineEdit 中

图 2.8 – 将玩家的用户名插入到客户端的登录屏幕 PasswordLineEdit 中

  1. 一旦你按下带有正确凭证的登录按钮,它应该加载带有相应头像的头像屏幕场景:

图 2.9 – 显示玩家头像的 AvatarScreen 场景,在成功认证后

图 2.9 – 显示玩家头像的 AvatarScreen 场景,在成功认证后

恭喜!你已经制作了第一个带有认证功能的登录屏幕,在整个网络中序列化和反序列化玩家的数据。为自己感到骄傲——这是一项了不起的成就!

摘要

在本章中,我们看到了如何使用 Godot 引擎网络 API 中的 UDP 协议实现来在服务器和客户端之间建立连接。有了这个,网络对等体可以打开通信通道并交换数据。

由于这种实现采用了一种相当低级的方法,我们看到了如何为我们的同伴创建一个简单的 API,以便他们可以制作、理解和回复彼此的请求。根据请求的不同,可能需要遵循一个称为序列化的过程,这是我们如何从游戏状态中提取相关信息并将其转换为我们可以存储和传递的格式。在我们的案例中,我们看到了 JSON 格式是最常见的序列化格式之一。

使用 JSON 格式,我们看到了如何将我们的 Godot 引擎字符串解析为 JSON,以及如何将 JSON 文件转换为字典,这样我们就可以使用 GDScript 更高效地处理它。

在本章末尾,我们看到了如何验证玩家的凭据,将它们与一个假数据库进行匹配。在成功验证后,我们收集了玩家的数据,根据他们在我们数据库中的数据显示他们各自的头像。

在下一章中,我们将通过允许多个客户端登录到同一服务器并最终共享体验来增加一个新的复杂度级别。为此,我们将创建一个显示所有已登录玩家名称和头像的 Lobby 节点!

第三章:创建一个大厅以聚集玩家

在上一章中,我们讨论了如何使用 UDP 数据包在游戏中的多个玩家之间交换数据。虽然这种方法非常高效,但需要大量手动工作来确保数据能够正确发送和接收。在本章中,我们将探讨 Godot 引擎的高级网络 API,它通过提供一组内置工具和函数来简化网络过程,从而简化了网络操作。

具体来说,我们将重点关注ENetMultiplayerPeer API,这是 Godot 引擎为其 ENet 库实现提供的包装类,以及远程过程调用RPC),这是一种通信协议,允许我们像在本地调用一样对远程计算机上的函数和方法进行调用。我们将使用这些工具来创建大厅,验证玩家身份,从伪造的 JSON 数据库中检索玩家头像数据,并在玩家进入大厅时同步所有玩家。我们将探讨使用 RPC 而不是交换 UDP 数据包的好处,以及这种方法如何简化在多个玩家之间同步游戏状态的过程。

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

  • 使用 RPC 远程调用函数

  • 理解多人游戏权限

  • 比较 UDP 和 ENet 方法

  • 使用 RPC 重新制作登录界面

  • 添加玩家的头像

  • 检索玩家头像

  • 测试大厅

到本章结束时,你将牢固地理解如何使用 Godot 引擎的高级网络 API 和 RPC 来创建一个健壮的多玩家大厅。

技术要求

在本章中,我们将使用 Godot 引擎构建另一个项目。记住,在整个本书中,我们使用的是 Godot 引擎版本 4.0,这也是一个要求。

这次,我们将使用res://03.making-lobby-to-gather-players文件夹中提供的文件。所以,如果你还没有项目仓库,可以通过这个链接下载它:

github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

然后,将项目添加到 Godot 引擎的项目管理器中,打开项目并进入res://03.making-lobby-to-gather-players文件夹。

使用 RPC 远程调用函数

在网络环境中,RPC代表远程过程调用,这是一种协议,允许一个程序在另一台机器或网络上运行的程序上调用函数或过程。在 Godot 引擎的上下文中,RPC 允许对象在网络之间交换数据,这是创建多人游戏的关键特性。

要在 Godot 引擎中使用 RPCs,我们需要使用 ENetMultiplayerPeer API,它提供了一个高级网络接口,用于处理网络连接、发送和接收数据,以及管理 RPCs。通过使用 ENetMultiplayerPeer,我们可以轻松发送和接收 RPCs,并更直接地处理网络通信。

当使用 RPCs 交换数据时,对象可以通过函数交换数据,这使得过程比使用 UDP 数据包交换数据更直接。使用 UDP 数据包时,我们需要发送请求程序的包并等待响应,然后才能获取数据。这个过程可能很复杂且难以管理,正如我们在上一章中看到的,尤其是在有大量对象交换数据的大型游戏中。

RPCs 的一项限制是它们不允许通过网络传输对象,如节点或资源。这对于需要在不同机器之间交换复杂对象的游戏来说可能是一个挑战。然而,有几种方法可以解决这个问题,例如发送序列化数据或使用自定义序列化方法。我们在第二章,“发送和接收数据”中学习了如何做到这一点,所以这对我们来说不会是问题。

RPCs 是创建多人游戏的有力工具,在 Godot 引擎中使用 ENetMultiplayerPeer API 可以轻松使用它们。尽管 RPCs 有局限性,例如无法通过网络传输对象,但它们仍然是创建强大且无缝多人体验的关键部分。

介绍 @rpc 注解

Godot 引擎 4.0 引入了一个名为 @rpc 函数注解的新功能。@rpc 注解用于标记在多人游戏中可以通过网络远程调用的函数。

我们可以向 @rpc 注解添加几个选项,这些选项控制了函数在网络上的调用和执行方式。

让我们更详细地看看每个选项:

  • 我们有调用选项,这意味着当我们对这个函数进行远程过程调用(RPC)时会发生什么:

    • call_remote: 这个选项表示该函数应该只在其他节点的节点实例上远程调用,而不是在节点的本地实例上调用。

    • call_local: 这个选项表示该函数也应该在当前节点的本地实例上调用。这在我们需要同步网络中的所有节点,包括调用者时非常有用。

  • 然后,我们有调用者选项,意味着谁可以远程调用这个函数:

    • authority: 这个选项表示该函数只能由多人游戏管理权限调用。我们很快就会看到更多关于它的内容。

    • any_peer: 这个选项表示该函数可以被网络中的任何节点调用。这对于那些可以被多个节点在多人游戏中执行,而不仅仅是多人游戏管理权限的函数来说非常有用。

  • 当我们向此函数发出 RPC 时,我们还有关于远程数据交换可靠性的选项:

    • reliable: 此选项表示函数应在网络上可靠地执行,这意味着函数调用将保证到达其目的地。这对于需要无数据丢失风险执行的函数是有用的。

    • unreliable: 此选项表示函数应以低可靠性执行,这意味着在网络中可能有一些数据可能会丢失或延迟。这对于可以容忍一些数据丢失的函数是有用的。

    • unreliable_ordered: 此选项与unreliable类似,但确保函数调用在网络中按顺序执行。这对于需要按特定顺序执行但可以容忍一些数据丢失的函数是有用的。

我们还可以指定 RPC 应该使用哪个连接通道来传输其数据。这有助于防止瓶颈或为特定功能保留一些通道。例如,我们可以选择使用reliable数据的通道,例如玩家之间消息的传输。然后,我们可以有另一个使用unreliable_ordered数据的通道,例如更新玩家关于他们头像当前位置的信息。

在这种意义上,我们只需要最新的位置信息到达;其他任何带有先前位置的调用都是无关紧要的。因此,当一个通道等待消息到达时,另一个通道会持续接收关于头像位置的新更新,并且它们都不会阻塞对方。

我们传递这些选项的顺序对于 Godot 引擎本身来说并不重要。唯一的例外是通道,它应该是传递的最后一个选项。以下是一个例子:

@rpc("any_peer", "unreliable_ordered")
func update_pos():
    pass
@rpc("unreliable_order", "any_peer")
func update_pos():
    pass

建立 RPC 选项的这两种方式是相同的,并且都会生效。现在,以下注释存在一个问题:

# This wouldn't work
@rpc(2, "any_peer", "unreliable_ordered")
func update_pos():
    pass
# This would work
@rpc("any_peer", "unreliable_ordered", 2)
func update_pos():
    pass

只有第二个 RPC 注释会生效,因为我们把通道作为注释的最后一个参数传递。在第一个 RPC 注释示例中,我们把通道作为第一个参数传递,所以它不会生效。

现在我们已经了解了如何将一个函数标记为rpc函数以及我们可以使用哪些选项来微调它并实现我们在游戏中的需求,让我们看看我们需要什么才能调用这样的函数并将它们在网络中传播。

RPC 需要什么?

要在 Godot 引擎中实现 RPC,首先,我们必须设置一个ENetMultiplayerPeer连接,它管理网络连接并处理节点之间的数据传输。我们在第一章中已经做到了这一点,但我们也会在这里介绍这个过程。

一旦设置了 ENetMultiplayerPeer 连接,我们必须确保接收 RPC 的节点的 NodePath 是精确的。这意味着目标节点的 NodePath 必须在网络中的所有玩家上完全相同。如果 NodePath 不精确,RPC 可能不会被发送到正确的节点,或者根本不会发送。

我们可以为我们的根节点设置一个默认名称以避免问题。我们选择根节点来方便之后的逻辑,因为它是在层次结构中的第一个节点。

例如,在我们的即将到来的 Main 中:

图 3.1 – ServerLobby、LoginScreen 和 ClientLobby 场景及其根节点命名为 Main

图 3.1 – ServerLobby、LoginScreen 和 ClientLobby 场景及其根节点命名为 Main

还需要注意的是,网络中的每个节点都应该共享所有标记为 @rpc 的方法,即使它们没有被任何人调用或使用。这是因为 Godot 引擎要求所有节点都能访问相同的方法集以正常工作。这可能对开发者来说是一个小的不便,因为它可能会使一些类膨胀,包含不必要的函数,但对于 Godot 的网络系统有效地工作来说是必要的。

例如,这些是我们即将到来的 大厅 项目中每个场景中找到的方法。请注意,它们都在其非 RPC 方法之上共享突出显示的方法:

图 3.2 – Server.gd、LoginScreen.gd 和 Client.gd 脚本共享带有 @rpc 注解的函数

图 3.2 – Server.gd、LoginScreen.gd 和 Client.gd 脚本共享带有 @rpc 注解的函数

在 Godot 引擎中制作 RPC 需要设置 ENetMultiplayerPeer 连接,确保目标节点的 NodePath 是精确的,并确保网络中的所有节点共享所有标记为 RPC 的方法。虽然这可能需要一些额外的设置和轻微的不便,但它使开发者能够轻松高效地创建多人游戏。

有了这些,我们理解了 Godot 引擎中 RPC 的核心。我们看到了我们需要设置我们的游戏以支持 RPC,@rpc 注解是如何工作的,以及我们如何调整它以匹配我们的设计。在这些调整选项之一中,我们看到了只允许多人权限调用特定的 RPC 函数是可能的。让我们看看多人权限是什么,以及我们可以用它做什么。

理解多人权限

在 Godot 引擎的高级网络 API 中,多人权限是一个概念,指的是在多人游戏中有权决定节点状态的节点。当两个或更多玩家在多人游戏中连接时,拥有一个集中式的玩家来决定哪些更改是有效的并且应该同步到所有连接的客户端是非常重要的。

在游戏中,多人权限被分配给特定的对等节点,通常是服务器或主机,这个节点有权决定哪些来自特定节点的更改应该被接受并在所有连接的客户端之间同步。这一点很重要,因为在多人游戏中,多个玩家可能会同时尝试更改游戏状态,而多人权限负责正确管理、验证和同步这些更改。

在多人游戏中,每个连接的客户端都会分配一个唯一的对等节点 ID,这是一个在游戏网络中识别客户端的数字。对等节点 ID 由 Node.multiplayer.multiplayer_peer 对象管理,它是 ENetMultiplayerPeer 对象的引用,该对象处理游戏的网络连接。multiplayer_peer 对象可以用来在连接的客户端之间发送和接收数据,以及管理游戏网络连接的状态。

我们可以使用 Node.get_multiplayer_authority() 方法检索节点的当前多人权限,也可以使用 Node.set_multiplayer_authority() 方法设置不同的权限,将节点 ID 作为参数传递。更改节点的多人权限将允许新的节点在网络上对节点进行更改和同步,这可能会相当危险。

例如,如果一个玩家负责包含其生命值的节点,玩家可能会以某种方式黑客攻击它,并能够自我管理他们的生命值,最终变得不朽。

比较 UDP 和 ENet 方法

UDPServerPacketPeerUDP 类是低级别的网络工具,允许通过 UDP 数据包交换数据。这种方法需要我们做更多的工作,因为我们必须自己管理数据包的发送和接收。例如,要使用 UDPServerPacketPeerUDP 创建登录系统,我们需要创建一个包含用户登录信息的数据包,将其发送到服务器,然后等待响应。

第二章发送和接收数据项目中,我们看到了如何使用 UDPServerPacketPeerUDP 来传递数据。我们看到了使用这些类,我们可以在系统的每个端点(客户端和服务器)序列化和反序列化数据。使用这种方法,我们需要轮询数据包并等待请求和响应的到来。这确实有效,但你看到了它可能会变得有点复杂。

使用 UDPServerPacketPeerUDP 类的一个优点是它们提供了对网络过程的更多控制,这对于需要精细调整网络的高级游戏非常有用。然而,这种方法也更容易出现错误,因为我们必须自己处理数据包的发送和接收,这可能导致数据包丢失或数据包顺序错误等问题。

另一方面,使用ENetMultiplayerPeer和 RPCs 提供了一种更高级别的网络解决方案,简化了创建登录系统的过程。使用这种方法,开发者可以使用@rpc函数注释标记一个方法为 RPC,这使得它可以从网络中的任何节点调用。

例如,要使用ENetMultiplayerPeer和 RPCs 创建登录系统,我们可以将处理登录过程的方法标记为 RPC,然后从客户端节点调用它。我们将在稍后看到这一点,你将了解 Godot 引擎的高级网络 API 是多么强大和简单。

使用ENetMultiplayerPeer和 RPCs 简化了网络过程,并使创建多人游戏变得更加容易。ENetMultiplayerPeer的内置功能,如自动数据包排序和错误纠正,使得创建稳定的网络连接更加容易。此外,@rpc注释使得从网络中的任何节点调用方法变得简单,简化了开发过程。

虽然UDPServerPacketPeerUDP类提供了对网络过程的更多控制,但使用ENetMultiplayerPeer和 RPCs 提供了创建多人游戏的更简单、更流畅的方法。最终的选择取决于你正在制作的游戏的具体需求,但在大多数情况下,使用 Godot 引擎提供的更高级别的工具将导致更快速、更高效的开发过程。

现在我们已经了解了 Godot 引擎的高级网络 API 如何通过ENetMultiplayerPeer类解决许多问题,以及它与 UDP 方法相比的优势,比如它能够轻松地允许我们需要的 RPC 功能,使我们的游戏更容易,让我们重新制作在第二章“发送和接收数据”中制作的登录界面,使用这些新工具。这将使我们能够在理解底层方法的同时,了解使用高级方法的优势。

使用 RPCs 重新制作登录界面

欢迎回到我们的工作室,亲爱的网络工程师!在第二章“发送和接收数据”中,我们学习了如何使用 Godot 引擎的UDPServerPacketPeerUDP类创建一个基本的登录系统。虽然这种方法非常适合我们的小型项目,但随着我们向前发展,我们需要提升游戏级别并创建一个大厅!

不要担心,我们为你找到了完美的解决方案——Godot 引擎的ENetMultiplayerPeer和 RPCs!这两个强大的工具将帮助我们构建一个强大且高效的系统,可以轻松扩展以支持多个连接的客户端——根据我们的研究,最多可以支持 4,095 个同时连接的玩家!

使用 Godot 引擎的ENetMultiplayerPeer,我们可以轻松管理多个连接并在所有连接的客户端之间同步游戏数据。这意味着我们的登录系统将能够处理更多的连接,我们的游戏将比以往任何时候都运行得更顺畅!

这样一来,我们也将能够进行 RPCs!RPCs 是 Godot 引擎网络中的关键部分。它们允许我们在网络中的其他节点上调用函数,就像它们是本地函数一样。有了 RPCs,我们可以轻松地在所有连接的客户端之间共享数据和执行操作,使我们的登录系统更加健壮和高效。

那么,准备好提升我们的游戏,网络工程师!在接下来的章节中,我们将深入探讨使用ENetMultiplayerPeer和 RPCs 实现新登录系统,并将玩家头像同步到大厅。

我们还将介绍一些使用ENetMultiplayerPeer和 RPCs 的最佳实践和技巧,以确保我们的多人游戏运行得既顺畅又高效。有了这些强大的工具,我们将能够创建一个令人惊叹的多人游戏,让玩家们欲罢不能。

让我们从使用ENetMultiplayerPeerAPI 在玩家和服务器之间建立连接开始。

建立 ENetMultiplayerPeer 连接

让我们从第一章回顾一下如何使用高级ENetMultiplayerPeer类建立连接。我们将从服务器开始。

这次,我们还将添加来自第二章“发送和接收数据”的元素,例如伪造的数据库和登录用户。这将使我们能够验证玩家并跟踪谁已连接以及他们的会话令牌。好的,无需多言,让我们深入探讨吧!

我们将首先在端口9999上设置 ENet 多玩家服务器,加载我们的伪造 JSON 数据库文件,并将peer实例分配给节点的multiplayer对象的multiplayer_peer属性,以便我们可以进行 RPCs。记住,我们只能在建立的 ENet 连接内执行 RPCs:

  1. 打开res://03.making-lobby-to-gather-players/LobbyServer.tscn场景,然后打开节点的脚本。

  2. 声明一个常量变量PORT,并分配我们的默认值9999。这个变量将用于稍后指定服务器监听传入连接的端口号:

    const PORT = 9999
    
  3. 使用@export装饰器创建一个新的变量database_file_path,它可以从检查器面板中进行编辑。这个变量将存储包含我们伪造用户数据库的 JSON 文件的路径。我们正在使用与上一章相同的文件:

    @export var database_file_path = "res://02.sending-and-receiving-data/FakeDatabase.json"
    
  4. 创建一个新的ENetMultiplayerPeer实例,并将其分配给peer变量。这将是我们用于在客户端和服务器之间发送和接收数据以及进行 RPCs 的高级网络接口:

    var peer = ENetMultiplayerPeer.new()
    
  5. 创建一个名为 database 的空字典和一个名为 logged_users 的空字典。这些变量将用于存储我们的假用户数据,并分别跟踪哪些用户当前已登录:

    var database = {}
    var logged_users = {}
    
  6. _ready() 回调中,调用 peer.create_server(PORT) 创建一个新的多玩家服务器,该服务器在由 PORT 变量指定的端口号上监听传入的连接:

    func _ready():
        peer.create_server(PORT)
    
  7. 仍然在 _ready() 回调中,将 peer 分配给 multiplayer.multiplayer_peer。这个变量使我们的 peer 对象成为游戏中所有节点的默认网络接口:

    func _ready():
        peer.create_server(PORT)
        multiplayer.multiplayer_peer = peer
    
  8. 最后,仍然在 _ready() 回调中,调用 load_database() 方法。我们将在稍后创建它。我们这样做是为了让数据库从服务器准备好开始就在内存中:

    func _ready():
        peer.create_server(PORT)
        multiplayer.multiplayer_peer = peer
        load_database()
    
  9. 现在,定义一个新的函数 load_database(),它接受一个可选参数 path_to_database_file。此函数将用于将用户数据从 JSON 文件加载到我们的 database 字典中:

    func load_database(path_to_database_file =
        database_file_path):
    
  10. load_database() 内部,使用 FileAccess.open() 打开由 path_to_database_file 指定的文件,并将其分配给 file 变量:

    func load_database(path_to_database_file =
        database_file_path):
        var file = FileAccess.open(path_to_database_file,
            FileAccess.READ)
    
  11. 使用 file.get_as_text() 获取文件的文本内容,并将其分配给 file_content 变量:

    func load_database(path_to_database_file =
        database_file_path):
        var file = FileAccess.open(path_to_database_file,
            FileAccess.READ)
        var file_content = file.get_as_text()
    
  12. 使用 JSON.parse_string()file_content 的内容解析为 JSON,并将结果字典分配给 database

    func load_database(path_to_database_file =
        database_file_path):
        var file = FileAccess.open(path_to_database_file,
            FileAccess.READ)
        var file_content = file.get_as_text()
        database = JSON.parse_string(file_content)
    

    到目前为止,这是我们的 LobbyServer.gd 应该看起来像这样:

    extends Control
    const PORT = 9999
    @export var database_file_path = "res://
        02.sending-and-receiving-data/FakeDatabase.json"
    var peer = ENetMultiplayerPeer.new()
    var database = {}
    var logged_users = {}
    func _ready():
        peer.create_server(PORT)
        multiplayer.multiplayer_peer = peer
        load_database()
    func load_database(path_to_database_file =
        database_file_path):
        var file = FileAccess.open(path_to_database_file,
            FileAccess.READ)
        var file_content = file.get_as_text()
        database = JSON.parse_string(file_content)
    

这样,我们就到了动手实践有趣部分和本章核心的时候了。接下来,我们将最终创建将在我们即将到来的类中使用的 @rpc 方法。

创建 RPC 函数模板

这样,我们可以开始定义我们的 @rpc 方法,这样当我们转到 LobbyLogin 时,我们就已经知道我们将调用什么以及它是如何工作的。所以,仍然在 LobbyServer 中,让我们创建一些 RPC 方法。

这些方法也将用于 LobbyLoginLobbyClient。记住,所有执行 RPC 的类应该共享相同的 RPC 方法,即使它们没有使用它们。

因此,让我们创建这个接口:

  1. 这行代码上的 @rpc 注解是一个 RPC 注解,它告诉 Godot 这个函数只能由多玩家权限(即服务器本身)远程调用。远程调用意味着当 LobbyServer 对此函数进行 RPC 调用时,它不会在本地执行。我们将使用 add_avatar() 方法向游戏大厅添加新头像,并在 LobbyClient 上实现它:

    @rpc
    func add_avatar(avatar_name, texture_path):
        pass
    
  2. clear_avatars() 函数将删除大厅中的所有头像。我们使用此函数从游戏中清除所有头像,以便与新玩家同步。这也是一个我们将在 LobbyClient 上实现的方法:

    @rpc
    func clear_avatars():
        pass
    
  3. 这个 @rpc("any_peer", "call_remote") 注解告诉 Godot 任何节点都可以远程调用这个函数。我们将使用 retrieve_avatar() 方法来检索特定玩家的头像纹理路径。我们将在 LobbyServer 中很快实现这个方法,而 LobbyClient 将会远程调用它:

    @rpc("any_peer", "call_remote")
    func retrieve_avatar(user, session_token):
        pass
    
  4. authenticate_player() 方法将使用用户名和密码验证玩家。我们使用这个函数来验证玩家的凭证,并在 logged_users 字典中将它们与一个会话令牌配对。这也是 LobbyServer 中的一个方法,但现在 LobbyLogin 将会远程调用它:

    @rpc("any_peer", "call_remote")
    func authenticate_player(user, password):
        pass
    
  5. 然后,我们使用 authentication_failed() 方法来通知玩家他们的身份验证失败。当服务器无法验证玩家发送的凭证时,我们将从 LobbyServerLobbyClient 上调用这个方法。

    注意,虽然带有 @rpc 注解的每个函数都应该在所有与之交互的其他类中都有,但这些类不需要有相同的 @rpc 选项。当我们跳转到 LobbyLoginLobbyClient 时,你会更好地理解这一点:

    @rpc
    func authentication_failed(error_message):
        pass
    
  6. 我们还有 authentication_succeed()。我们从 LobbsyServer 在玩家的 LobbyClient 上调用这个函数,告诉他们他们的身份验证成功,并为他们提供会话令牌:

    @rpc
    func authentication_succeed(user, session_token):
        pass
    

    这样,我们就有了在大厅系统中将要使用的所有 RPC 函数。LobbyServer 的 RPC 部分应该看起来像这样:

    @rpc
    func add_avatar(avatar_name, texture_path):
        pass
    @rpc
    func clear_avatars():
        pass
    @rpc("any_peer", "call_remote")
    func retrieve_avatar(user, session_token):
        pass
    @rpc("any_peer", "call_remote")
    func authenticate_player(user, password):
        pass
    @rpc
    func authentication_failed(error_message):
        pass
    @rpc
    func authentication_succeed(user, session_token):
        pass
    

我们的模板已经准备好了。它包含了组成我们大厅的类之间需要共享的 @rpc 方法,以便在我们的网络中进行通信。记住,这是一个必要的步骤;即使某些类没有实现该方法,它们也应该至少共享这个接口。例如,接下来,我们将在大厅服务器中实现身份验证逻辑,但其他类只需要该方法的签名即可工作。让我们看看这个过程会如何进行。

玩家身份验证

在这个部分,我们将专注于在大厅服务器中验证玩家。我们将使用之前在服务器脚本中定义的 authenticate_player() RPC 方法来验证玩家的身份并授予大厅的访问权限。

authenticate_player() 方法将接受一个用户名和一个密码作为参数,并返回一个错误消息或会话令牌。如果凭证无效,该方法将向 authentication_failed() 方法发出远程调用,并带有一个错误消息来解释失败的原因。

如果凭证有效,该方法将向 authentication_succeed() 方法发出远程调用,传递一个会话令牌并将其返回给玩家的 LobbyClient。会话令牌是一个唯一的整数,用于标识玩家,并在后续的 RPC 中用于验证玩家。

让我们看看我们如何使用 Godot 引擎中可用的工具来实现这个逻辑:

  1. LobbyServerauthenticate_player() 方法内部,使用 multiplayer.get_remote_sender_id() 方法获取发送认证请求的玩家的 peer_id。这是我们识别谁发送了请求,以便我们可以正确地响应请求的方式:

    func authenticate_player(user, password):
        var peer_id = multiplayer.get_remote_sender_id()
    
  2. 检查用户是否存在于 database 字典中。如果不存在,使用 rpc_id() 方法在 peer_id 上调用 authentication_failed RPC 方法,消息为 "User doesn't exist"。为此,我们可以使用 rpc_id() 方法,它直接向给定 ID 的对等方发起 RPC:

        if not user in database:
            rpc_id(peer_id, "authentication_failed",
                "User doesn't exist")
    
  3. 如果用户存在于数据库中,检查密码是否与用户关联的密码匹配。如果匹配,使用 randi() 内置方法生成一个随机令牌:

            elif database[user]['password'] == password:
                var token = randi()
    
  4. 然后,将认证过的用户添加到 logged_users 字典中,并在 peer_id 上调用 authentication_succeed RPC 方法,传递令牌作为参数:

            logged_users[user] = token
            rpc_id(peer_id, "authentication_succeed",
                token)
    

    这个方法应该看起来是这样的:

    func authenticate_player(user, password):
        var peer_id = multiplayer.get_remote_sender_id()
        if not user in database:
            rpc_id(peer_id, "authentication_failed",
                "User doesn't exist")
        elif database[user]['password'] == password:
            var token = randi()
            logged_users[user] = token
            rpc_id(peer_id, "authentication_succeed", token)
    

注意使用 RPC 的便利性。我们不需要轮询或等待数据包到达目的地,也不必担心序列化函数参数。我们甚至不需要创建一个 请求 API 来检测请求者试图实现什么,就像我们之前所做的那样。它非常直接,几乎就像创建一个本地应用程序,你直接访问对象。

现在,让我们看看如何在 LobbyLogin 上调用这个函数。我将假设你已经理解了如何使用 ENetMultiplayerPeer.create_client() 方法连接到服务器。如果你对此有任何疑问,请参考第一章;程序是相同的。

LobbyLogin 与上一章的登录方式相似,所以我们可以直接跳到 send_credentials() 方法,在这里它与 LobbyServer 进行通信。你会注意到它也有我们在 LobbyServer 中看到的 RPC 方法。在这种情况下,它们都使用了默认选项,因为服务器是唯一应该调用这些方法的对象:

  1. send_credentials() 方法中,从 user_line_edit 节点检索 text 字符串属性,并将其存储在 user 变量中:

    func send_credentials():
        var user = user_line_edit.text
    
  2. 然后,用 password_line_edit 做同样的操作,并将其存储在 password 变量中:

        var password = password_line_edit.text
    
  3. 最后,向多玩家权限发起 RPC 调用,调用 authenticate_player() 方法,并传递 userpassword 参数。这将仅在 LobbyServer 上执行此调用:

        rpc_id(get_multiplayer_authority(),
            "authenticate_player", user, password)
    

    最终,LobbyLogin.send_credentials() 方法将看起来是这样的:

    func send_credentials():
        var user = user_line_edit.text
        var password = password_line_edit.text
        rpc_id(get_multiplayer_authority(),
            "authenticate_player", user, password)
    

让我们看看 authentication_failed()authentication_succeed() 方法,以便我们了解它们是如何工作的,以及我们如何跨场景保持玩家的认证凭据。

authentication_succeed() 接收一个名为 session_token 的参数,这是服务器在验证玩家的凭据时传递的,正如我们之前所看到的。

然后,我们使用 user_line_edit.textsession_token 参数更新 AuthenticationCredentials.userAuthenticationCredentials.session_token 的值。就像在上一章中一样,AuthenticationCredentials 是一个单例自动加载,用于存储玩家的用户名和会话令牌,以便我们可以在后续场景中使用它。

谈到后续场景,在更新 AuthenticationCredentials 单例之后,我们使用 get_tree().change_scene_to_file(lobby_screen_scene) 将场景更改为 lobby_screen_scene。这意味着玩家已成功登录,我们可以将他们带到游戏大厅:

@rpc
func authentication_succeed(session_token):
    AuthenticationCredentials.user = user_line_edit.text
    AuthenticationCredentials.session_token = session_token
    get_tree().change_scene_to_file(lobby_screen_scene)

对于 authentication_failed(),我们将 error_label.text 设置为从 LobbyServer 收到的错误消息。这将向玩家显示错误:

@rpc
func authentication_failed(error_message):
    error_label.text = error_message

现在我们已经了解了双方如何进行通信以及他们如何处理他们传递和从对方那里获得的数据,是时候继续前进,看看游戏如何处理这些数据,并将玩家的头像显示给彼此,每次有新玩家加入会话时都在网络上同步新玩家。

在接下来的部分中,我们将看到玩家端的大厅屏幕看起来是什么样子,以及我们如何在网络上加载、显示和同步玩家的头像。

添加玩家的头像

在任何在线游戏中,玩家的头像都是代表他们在虚拟世界中的关键元素。在上一节中,我们成功验证了玩家,并将他们的会话令牌和用户名保存在我们的 AuthenticationCredentials 自动加载中。现在,是时候使用这些信息在大厅中显示玩家的头像了。

为了实现这一点,我们将从我们的模拟数据库中检索玩家的头像信息,并创建一个新的 AvatarCard,这是一个包含 TextureRect 节点以显示头像图片和标签以显示其名称的自定义场景。这样,玩家将能够轻松地识别彼此,并感觉与游戏世界更加紧密相连。

为了做到这一点,让我们打开 LobbyClient.gd 脚本。在这里,我们将做三件主要事情:

  1. 通过对 retrieve_avatar() 方法进行 RPC 调用来从服务器检索头像信息。

  2. 实现 LobbyServer 在检索头像数据后调用的 add_avatar() 方法。

  3. 实现 LobbyServer 在向大厅添加新头像之前调用的 clear_avatars() 方法。

我们将从后两个开始,然后我们可以再次转向 LobbyServer.gd 文件以实现 retrieve_avatar() 方法:

  1. add_avatar() 方法中,创建一个新的 avatar_card_scene 实例:

    @rpc
    func add_avatar(avatar_name, texture_path):
        var avatar_card = avatar_card_scene.instantiate()
    
  2. 将新创建的 avatar_card 实例添加到 avatar_card_container。这是一个位于 ScrollContainer 节点内的 HBoxContainer 节点:

        avatar_card_container.add_child(avatar_card)
    
  3. 在继续执行代码之前,等待下一帧进行处理。我们这样做是因为在更新数据之前,AvatarCard 需要准备好:

        await(get_tree().process_frame)
    
  4. avatar_card 实例上调用 update_data() 方法,使用传递给 add_avatar() 方法的参数来更新其数据。这样,大厅将使用 avatar_name 来显示玩家的头像名称,并将加载存储在 texture_path 中的图像来显示他们的头像图像:

        avatar_card.update_data(avatar_name, texture_path)
    

    整个 add_avatar() 方法应该看起来像这样:

    @rpc
    func add_avatar(avatar_name, texture_path):
        var avatar_card = avatar_card_scene.instantiate()
        avatar_card_container.add_child(avatar_card)
        await(get_tree().process_frame)
        avatar_card.update_data(avatar_name, texture_path)
    

使用 @rpc 注解,我们创建了一个方法,游戏服务器可以在客户端上调用,以将新的玩家头像添加到所有玩家的大厅屏幕上,但这会导致一个小问题。实际上,此方法可能会添加在较新玩家加入之前已经在大厅中的头像。

因此,我们首先需要清除所有之前的头像,然后再次添加所有当前登录玩家的头像。这确保了大厅中只有正确的头像。

在接下来的部分,我们将创建一个方法,该方法将遍历所有当前头像并将它们移除,以便我们使用一个空的 HBoxContainer 来添加新的头像。

清理头像卡

如前所述,每当服务器向大厅添加新的头像时,它首先清理大厅并从头开始重新创建所有头像。我们将详细说明这一点,当我们实现 retrieve_avatar() 方法时。

clear_avatars() 方法释放了 avatar_card_container 节点上的所有现有头像。它遍历 avatar_card_container 的所有子节点,并对每个子节点调用 queue_free()。在此函数执行后,之前在大厅中显示的所有头像都将从容器中移除。

clear_avatars() 方法中,使用 for 循环遍历 avatar_card_container 中的每个子节点,并对每个子节点调用 queue_free() 方法,将其从 SceneTree 中移除并释放其资源:

@rpc
func clear_avatars():
    for child in avatar_card_container.get_children():
        child.queue_free()

就这样;很简单,对吧?

在我们回到 LobbyServer.gd 之前,让我们向多玩家权限发出一个 RPC,以便它检索当前玩家的头像。我们在 _ready() 方法中这样做:

func _ready():
    rpc_id(get_multiplayer_authority(), "retrieve_avatar",
        AuthenticationCredentials.user,
            AuthenticationCredentials.session_token)

我们使用 rpc_id() 方法在多玩家权限上调用 retrieve_avatar() RPC 方法,在这种情况下是 LobbyServer。我们将玩家的 usernamesession_token 作为参数传递给 retrieve_avatar() 方法,这些参数存储在 AuthenticationCredentials 单例自动加载中。现在,是时候回到 LobbyServer.gd 了。

获取玩家头像

在本节中,我们将实现 LobbyServer.gd 脚本上的 retrieve_avatar() 方法,这将允许玩家从服务器请求他们的头像数据。头像数据存储在模拟数据库中。服务器将通过一些 RPC 响应来更新所有玩家,以显示他们的头像在共享大厅中。

使用这种方法,我们将完成大厅项目的功能。玩家将能够进行身份验证并在大厅中显示他们的头像。这将为在下一章中构建更复杂的多人游戏提供一个坚实的基础,因为已经涵盖了网络的基本知识。

让我们开始吧!

  1. retrieve_avatar()方法中,通过验证用户是否存在于logged_users字典中来检查用户是否已登录。如果用户未登录,则退出函数:

    func retrieve_avatar(user, session_token):
        if not user in logged_users:
            return
    
  2. 然后,检查远程对等节点提供的会话令牌是否与存储在logged_users字典中用户的会话令牌匹配:

        if session_token == logged_users[user]:
    
  3. 如果令牌匹配,则在所有已连接的对等节点上调用clear_avatars()函数以清除其大厅屏幕上的任何现有头像:

            rpc("clear_avatars")
    
  4. 遍历存储在logged_users字典中的所有已登录用户:

            for logged_user in logged_users:
    
  5. database字典中检索当前logged_user的头像名称和纹理路径:

                Var avatar_name = database[logged_user]
                    ['name']
                var avatar_texture_path = database
                    [logged_user]['avatar']
    
  6. 在所有已连接的对等节点上调用add_avatar()方法,并传入avatar_nameavatar_texture_path作为参数以在大厅中显示头像:

               rpc("add_avatar", avatar_name,
                   avatar_texture_path)
    

    这就是经过所有这些步骤后的retrieve_avatar()方法应该看起来像:

    @rpc("any_peer", "call_remote")
    func retrieve_avatar(user, session_token):
        if not user in logged_users:
            return
        if session_token == logged_users[user]:
            rpc("clear_avatars")
            for logged_user in logged_users:
                var avatar_name = database
                    [logged_user]['name']
                Var avatar_texture_path = database
                    [logged_user]['avatar']
                rpc("add_avatar", avatar_name,
                    avatar_texture_path)
    

注意其@rpc注解选项。注意,任何对等节点都可以远程调用它。这就是我们在 Godot 引擎中为我们的在线多人游戏创建 RPC API 的方式。

一些方法应该只由多人游戏管理员远程调用,一些应该在本地调用,还有一些可以被网络上的任何对等节点调用。决定和管理对等节点如何相互交互的责任在我们。

在所有这些准备就绪后,是时候使用多个实例来测试我们的游戏了,以模拟服务器和连接到我们网络的多个玩家。让我们在下一节中这样做!

测试大厅

为了测试这一点,我们将运行三个游戏实例:

  1. 前往调试 | 运行多个实例并选择运行 3 个实例

图 3.3 – 在“运行多个实例”菜单中选择运行三个实例

图 3.3 – 在“运行多个实例”菜单中选择运行三个实例

  1. 然后,打开res://03.making-lobby-to-gather-players/MainMenu.tscn场景并按下播放按钮。

  2. 选择一个实例作为游戏的服务器。为此,只需点击服务器按钮。

图 3.4 – 在主菜单屏幕上按下“服务器”按钮

图 3.4 – 在主菜单屏幕上按下“服务器”按钮

  1. 现在,选择另一个实例并点击LobbyLogin屏幕,在那里您可以输入第一个假玩家的凭据。

  2. 在用户名字段中输入user1,在密码字段中输入test。这是我们为第一个用户添加到FakeDatabase.json中的凭据。然后,按下带有单个头像的LobbyClient屏幕。

图 3.5 – 带有玩家凭据的大厅登录屏幕

图 3.5 – 显示玩家凭证的 LobbyLogin 屏幕图

有了这些,服务器将验证玩家的凭证,并允许玩家进入下一个屏幕,显示基于数据库文件中匹配的数据的玩家角色的头像和名称。在下面的屏幕截图中,我们可以看到成功登录后的下一个屏幕。

图 3.6 – 登录后 LobbyClient 显示玩家的头像

图 3.6 – 登录后 LobbyClient 显示玩家的头像

  1. 然后,选择最后一个实例,点击 LobbyLogin 屏幕图,使用第二位玩家的凭证。在第一个字段中,输入 user2,然后在第二个字段中,输入 test。这将带您进入 LobbyClient 屏幕图,现在应该有两个头像。您可以检查其他客户端实例,它们将具有相同的头像。

图 3.7 – 第二位玩家登录后显示两位玩家头像的游戏

图 3.7 – 第二位玩家登录后显示两位玩家头像的游戏

我们可以看到一切都在按我们的预期工作!玩家可以输入他们的凭证,服务器验证它们,并在验证后提供会话令牌以保持他们登录。登录后,他们可以看到他们的头像。不仅如此,我们的游戏还能在新的玩家加入会话时同步玩家的头像。

我们使用强大的 @rpc 注解完成了所有这些工作,当使用 ENetMultiplayerPeer API 连接对等节点时,可以使用此注解。

摘要

在本章中,我们学习了 RPC 及其在多玩家游戏架构中的重要性。我们看到了如何使用 RPC 在 Godot 引擎中的节点之间交换数据。我们还看到了多玩家权限节点是什么,以及如何设置一个管理网络对等体之间所有游戏状态的节点。除此之外,我们还看到,通过使用多玩家 API 和 ENetMultiplayerPeer,我们可以轻松处理节点之间的通信。

在本章中,我们创建了一个大厅,这是一个具有大厅的多玩家游戏,玩家可以在其中一起加入。我们看到了如何创建客户端-服务器架构,使用 RPC 验证用户,并在服务器和客户端之间交换数据。我们还学习了如何使用多玩家 API 和 ENetMultiplayerPeer 在客户端和服务器之间建立连接。

我们学到的基本概念之一是 ENetMultiplayerPeer 如何简化与低级 UDP 方法相比的多玩家游戏创建过程。它抽象掉了低级网络编程的复杂性,例如发送和接收数据包、管理连接和处理错误。这使得我们更容易专注于实现游戏的游戏机制,而不是担心网络通信的低级细节。

总体而言,本章为在 Godot 引擎中开发多人游戏提供了一个坚实的基础。通过遵循本章中概述的步骤,开发者可以创建一个基于大厅的简单多人游戏,该游戏利用 RPC、身份验证和多人 API。

在接下来的章节中,我们将测试 ENetMultiplayerPeer 的能力,以交换、更新和同步玩家。为此,我们将创建一个聊天室,玩家可以在其中相互交流,并最终创造一个共享体验和社区感。

第四章:创建在线聊天

欢迎来到我们关于使用 Godot Engine 4.0 制作在线多人游戏的书籍的下一章!

在上一章中,我们了解了如何为玩家创建大厅以及它在进入游戏前聚集玩家的重要性。现在,我们将更进一步,探讨使用 Godot 的网络 API 开发在线聊天的过程。

正如我们所见,Godot 的网络 API 为我们提供了一套强大的工具,用于构建实时多人游戏。在本章中,我们将使用 ENetMultiplayerPeer 类在玩家之间建立可靠的连接,并使用一些 远程过程调用RPC)方法来处理聊天系统的逻辑。

如您可能已经知道,在任何在线多人游戏中,聊天系统都是必不可少的,因为它允许玩家在游戏过程中相互沟通。一个设计良好的聊天系统可以极大地提升玩家体验,使团队成员之间的协调更加顺畅,并鼓励玩家之间的社交互动。

在本章中,我们还将探讨在网络中使用多个通信渠道的重要性。我们在上一章中介绍了这个概念,但现在我们将看到如何在实际的 RPC 方法中使用这个功能,以便我们的网络能够顺畅地在玩家之间传递数据。在以下逐步指导的末尾,我们将得到以下结果:

图 4.1 – 玩家交换信息的聊天界面

图 4.1 – 玩家交换信息的聊天界面

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

  • 理解数据交换和通道

  • 发送聊天消息

  • 远程更新玩家的数据

技术要求

通过本章,我们将使用 Godot 引擎项目仓库的第四个文件夹,该文件夹可通过以下链接获取:

github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

在将项目导入你的 Godot 引擎项目管理器后,打开它并导航到 res://04.creating-an-online-chat 文件夹。然后,打开 ChatControl.tscnChatControl.gd 文件。它们将是本章的重点。

在本章接下来的内容中,我们将学习可靠和不可靠数据交换的基本概念以及通道的工作原理,以便为我们的聊天系统打下基础。

理解数据交换和通道

聊天系统本质上是一个按时间顺序排列的消息堆栈,我们根据玩家之间发送的消息来排序。由于这个系统需要一个连贯的时间顺序,我们需要了解如何防止数据在网络传输过程中变得混乱和无序。我们还需要防止这种排序影响其他系统和组件。为此,我们将学习如何可靠地发送数据包,以及如何使用多个通道进行数据交换。

可靠和不可靠的包

Godot 引擎的网络 API 允许在节点之间进行可靠和不可靠的数据交换。@rpc 注解提供了一种使用不同的传输协议(如 UDP 和 TCP)在客户端和服务器之间安全传输数据的方法。可靠的数据交换确保数据按顺序交付,并且在传输过程中不会丢失,这使得它非常适合像聊天消息这样的关键数据。不可靠的数据交换更快、更高效,但无法保证数据的顺序或交付,这使得它非常适合像玩家位置或实时更新这样的非关键数据。在我们的聊天系统中,我们使用可靠的 @rpc 选项来确保聊天消息能够及时无误地送达。可靠的数据交换确保玩家能够跟随对话并做出适当的回应。通道提供了对数据交换的又一层控制,通过优先处理或分离通过不同通道发送的数据,从而实现更好的网络优化。

在接下来的部分中,我们将学习通道是如何工作的,以及我们可以用它们做什么。

理解通道

当在 Godot 引擎中开发多人游戏时,理解网络 API 中通信通道的工作方式对于优化网络性能和最小化延迟至关重要。在 Godot 引擎网络 API 的上下文中,通信通道用于在节点之间交换的不同类型的数据进行分离。例如,你可能希望使用一个通道来更新游戏状态,另一个通道来发送聊天消息,还有另一个通道来发送玩家移动数据。

Godot 引擎网络 API 中的 @rpc 注解提供了一个选项,用于指定 RPC 方法在发送和接收数据时应使用的通道。默认情况下,所有 RPC 方法都使用通道 0。然而,我们可以通过在 @rpc 注解中将一个整数作为最后一个选项传递来指定不同的通道。例如,如果你想为游戏状态更新使用一个通道,你可以将游戏状态更新分配给通道 1,将聊天消息分配给通道 2

在你的 Godot 引擎多人游戏中使用多个通道可以帮助提高网络性能并最小化延迟。通过将不同类型的数据分离到不同的通道中,你可以优先处理更重要的数据,并防止单个通道上的拥堵。

值得注意的是,使用多个通道也可以在数据包丢失或网络拥塞的情况下帮助防止数据丢失和损坏。通过将数据分离到不同的通道,你可以确保一个通道上的数据丢失或损坏不会影响其他通道。这有助于防止诸如同伴之间的不同步或损坏的游戏数据等问题。我们将在下一节中讨论这一点。

记住,我们将使用可靠的数据交换来处理我们的消息,因此我们不能因为数据交换通道仍在等待聊天消息到达,就阻止玩家更新其他关键信息,例如他们和他人头像的位置。为此使用另一个通道是明智的。让我们看看它是如何工作的。

打开 res://04.creating-an-online-chat/ChatControl.tscn 场景,你会注意到它已经按照我们制作聊天系统所需的全部节点进行了结构化。因此,我们将重点放在脚本上。以下图显示了 ChatControl 场景节点的层次结构:

图 4.2 – ChatControl 场景节点层次结构

图 4.2 – ChatControl 场景节点层次结构

从那里,打开 res://04.creating-an-online-chat/ChatControl.gd 文件,并使负责向聊天添加消息的方法:

  1. add_message() 方法上方创建一个 @rpc 注解。这个 RPC 方法应该对任何同伴都可用;它也应该在本地调用自己。在此基础上,它将是一个可靠的方法。最后,在这里,我们将使用一个单独的通道来交换数据,所以让我们使用通道 2

    @rpc("any_peer", "call_local", "reliable", 2)
    func add_message(_avatar_name, message):
        pass
    
  2. add_message() 方法内部,创建一个名为 message_text 的新变量,它将使用 _avatar_name 字符串和消息参数来创建一个使用两个冒号分隔的占位符的文本,如下所示:

        var message_text = "%s: %s" % [_avatar_name,
            message]
    
  3. 然后,连接 label.text 字符串,换行,然后添加 message_text 字符串。这将把最新的消息添加到玩家的可见聊天中:

        label.text = label.text + "\n" + message_text
    
  4. 最后,为了确保玩家的聊天总是显示最新的消息,我们将 container.scroll_vertical 更新为与 label.size.y float 匹配。这样,它将滚动到聊天标签的底部,显示最新的消息:

        container.scroll_vertical = label.size.y
    

    到目前为止,add_message() 方法应该看起来像这样:

    @rpc("any_peer", "call_local", "reliable", 2)
    func add_message(_avatar_name, message):
        var message_text = "%s: %s" % [_avatar_name,
            message]
        label.text = label.text + "\n" + message_text
        container.scroll_vertical = label.size.y
    

通过这样,我们可以使用专用通道从一名玩家传输消息到另一名玩家,并在每个玩家的聊天界面中显示它们。使用独立的数据传输通道就像在函数的 @rpc 注解的最后选项中添加一个整数一样简单。

在下一节中,让我们学习如何收集玩家的消息并实际处理它,将其发送到我们同伴网络中的其他玩家。

发送聊天消息

Godot 引擎的 RPC 允许在多人游戏中在客户端和服务器之间进行高效的数据传输。我们可以为这个特定目的创建一个 RPC 方法,将消息数据作为参数。传输可以是可靠的或不可靠的,具体取决于应用程序的需求。一旦消息被发送,它就会被适当的接收者(包括客户端和服务器)接收,并适当地处理,例如显示给用户或记录。我们在理解数据交换和通道部分做到了这一点,当时我们创建了add_message()方法。

使用 Godot 的 RPC 发送消息是一个直接的过程,涉及定义消息格式。在我们的例子中,我们使用玩家的头像名称和消息内容,如之前所见,调用 RPC 方法来传输消息,并在接收端适当地处理消息。

我们将实现一个方法来读取玩家使用LineEdit节点输入的消息,并将其发送到网络上的所有对等节点。为此,我们将使用连接到_on_line_edit_text_submitted()回调方法的LineEdit.text_submitted信号,如下面的图像所示:

图 4.3 – LineEdit 文本提交信号连接

图 4.3 – LineEdit 文本提交信号连接

信号连接后,打开脚本,让我们来处理_on_line_edit_text_submitted()方法:

  1. 我们需要做的第一件事是防止处理空消息。为此,让我们在new_text参数是空字符串时从函数中返回:

    func _on_line_edit_text_submitted(new_text):
        if new_text == "":
            return
    
  2. 然后,我们可以调用add_message()方法进行 RPC。这将调用所有已连接节点的此方法。我们将传递ChatControl.avatar_name字符串和new_text字符串作为参数,以便对等节点有适当的数据来创建他们的聊天消息:

        rpc("add_message", avatar_name, new_text)
    
  3. 最后,我们清除line_edit.text字符串,以视觉上传达游戏已收到玩家的消息并正在处理它:

        line_edit.clear()
    

    完整的_on_line_edit_text_submitted()方法应如下所示:

    func _on_line_edit_text_submitted(new_text):
        if new_text == "":
            return
        rpc("add_message", avatar_name, new_text)
        line_edit.clear()
    

使用此方法,游戏中的任何玩家都将能够输入一条消息,并要求所有对等节点(包括他们自己)根据我们之前看到的add_message()逻辑添加一条新消息到他们的聊天中。

现在,我们需要了解我们将如何更新每个玩家关于其他玩家消息的情况。在下一节中,我们将学习如何在场景的根节点之外的节点上使用 RPC。有了这个,我们将能够编写更简洁的脚本,因为我们不会在不会实现的方法中膨胀我们的类。

远程更新对等节点数据

Godot 引擎的 Network API 中真正酷的地方是我们可以利用 RPC 在各个地方传递数据。例如,我们已经看到我们在消息中使用玩家的头像名称。但你有没有想过在这些步骤中我们是如何检索这些数据的?

你可能看到了一个名为set_avatar_name()的 RPC 方法,对吧?由于它的@rpc注解没有任何选项,你可以假设它使用默认选项。了解这一点很重要,因为正如我们之前所看到的,这意味着它应该只由多人游戏权限(在这种情况下,服务器)远程调用。

让我们打开ChatServer.gd文件,了解幕后发生了什么。本质上,大部分内容与retrieve_avatar() RPC 方法几乎相同。在第 39 行,我们有以下指令:

var peer_id = multiplayer.get_remote_sender_id()

我们看到这是一种方法,可以将最新 RPC 的发送者保存在内存中,以便在必要时可以回溯,在这种情况下,这是必要的。

第 45 行,我们向刚刚请求其头像数据的玩家调用rpc_id()方法来调用set_avatar_name()方法:

chat.rpc_id(peer_id, "set_avatar_name", database[user]
    ['name'])

注意,还有其他一些内容。我们是从ChatControl节点调用这个rpc_id()方法,它是Main根节点的子节点。

这是服务器的ChatControl节点。由于服务器和客户端的场景树具有相同的NodePath到它们的ChatControl节点,我们可以在服务器的ChatControl节点上执行这个rpc_id()调用,而不是从Main节点调用,它将远程调用它,请求其头像数据的玩家:

图 4.4–ChatServer 和 ChatClient 的场景节点层次结构

图 4.4–ChatServer 和 ChatClient 的场景节点层次结构

这是一个防止单个类因包含许多 RPC 方法而膨胀的好方法,因为记住,如果调用者有一个 RPC 方法,所有节点都应该在其等效节点上具有相同的NodePath上的此方法。

这也是一种非常有效的方法,可以远程更新节点的新数据。RPCs(远程过程调用)是我们在使用 Godot 引擎制作在线多人游戏时,可以拥有的非常令人印象深刻且有用的工具。

摘要

在本章中,我们看到了如何使用RPC方法在网络的多个节点之间传递数据和执行操作。我们还理解了可靠和不可靠数据交换之间的核心区别,并看到了一些使用每种情况的示例。由于我们可以在网络节点之间交换数据的方式存在这种核心区别,我们也理解了一种方式可能会阻塞另一种方式,因此我们可以使用通道来防止这种类型的数据阻碍与该交换无关的其他类型的数据。

通过创建一个玩家可以聊天的在线大厅,我们看到了如何使用@rpc注解及其一些可用选项,包括允许其他玩家进行远程调用而不是只有多人游戏权限。

在下一章中,我们将使用我们刚刚获得的知识来构建一个实际的实时多人体验。我们将创建一个多人在线问答游戏,玩家将竞争看谁能够最快地选择正确的答案。那里见!

第二部分:创建在线多人游戏机制

在了解我们可用于创建在线多人游戏的工具后,我们通过创建实际的游戏原型来将这些工具置于具体情境中。在本部分中,我们学习如何将单人游戏转变为在线多人游戏,从问答游戏开始,以 MMORPG 游戏的原型结束。

本部分包含以下章节:

  • 第五章**, 制作在线问答游戏

  • 第六章, 构建在线国际象棋游戏

  • 第七章, 开发在线乒乓球游戏

  • 第八章, 设计在线合作平台游戏

  • 第九章, 创建在线冒险原型

第五章:制作在线问答游戏

在本章中,我们将深入探讨使用 Godot 引擎 4.0 提供的强大网络 API 创建在线问答游戏的迷人领域。我们将探讨如何利用 Godot 引擎的网络 API 创建一个引人入胜且互动性强的问答游戏,可以与朋友或在线陌生人一起玩。我们将涵盖在线多人游戏开发的基本概念,包括客户端-服务器架构、游戏同步和玩家交互。

在这里,我们不会深入探讨这类游戏的游戏设计方面:计分、管理激励措施、平衡等等;相反,我们将专注于方程的工程方面:如何同步答案,防止玩家在另一位玩家已经回答时回答,更新双方的问题数据等等。

我们将首先设置问答游戏的服务器端,包括创建一个可以处理多个客户端传入连接和答案的专用服务器。然后,我们将转向设计游戏的核心功能,包括处理玩家输入、管理答案和问答问题,同时处理客户端和服务器之间的通信。

在本章中,我们将学习如何使用 Godot 引擎的 RPCs 来管理连接,处理数据同步,并实现实时多人游戏机制。我们还将涵盖管理游戏状态等主题。

本章将涵盖以下主题:

  • 介绍在线问答游戏

  • 为问答游戏设置大厅

  • 实现在线回合

  • 将本地机制转变为远程游戏

到本章结束时,你将牢固地理解如何使用 Godot 引擎 4.0 的网络 API 创建在线问答游戏。你将学习到制作在线异步游戏的关键概念和技术,包括服务器端设置、客户端实现和网络通信。以下截图展示了我们的在线问答游戏的最终结果:

图 5.1 – 显示问题和选项的在线问答游戏玩法屏幕

图 5.1 – 显示问题和选项的在线问答游戏玩法屏幕

在下一节中,我们将讨论问答游戏的组件,以便我们可以确定作为网络工程师,我们必须在何处实现将游戏的本地多人版本转变为在线多人版本所需的必要功能。

介绍在线问答游戏

欢迎网络工程师!我们的工作室需要你将我们的问答游戏转变为在线多人游戏体验!我们已经经历了各种挑战来创建一个吸引人的问答游戏。现在,是时候通过添加在线多人功能将其提升到下一个层次了。

想象一下来自世界各地的玩家在实时中进行竞争,测试他们的知识和技能。在本章中,你将深入了解游戏开发中的网络世界,并学习如何使用 GDScript 实现多人游戏功能。那么,让我们开始,让我们的测验游戏成为难忘的多人游戏体验!

我们在线多人测验游戏的一个关键特性是从 JSON 数据库动态加载问题。数据库中的每个问题都包含所有必要的数据,例如问题本身和四个供玩家选择的备选答案。只有一个备选答案是正确的,并且这个信息也存储在数据库中,以确保游戏的公平性和一致性。

为了提供一个简单直观的用户界面,我们的游戏提供了四个按钮,每个按钮代表屏幕上显示的问题的一个答案。一个带有标签的面板显示游戏信息,包括玩家必须回答以在回合中获得分数的当前问题。游戏界面设计用于为玩家在浏览问题和答案时提供无缝体验。

图 5.2 – 显示本回合问题和可用答案的测验界面

图 5.2 – 显示本回合问题和可用答案的测验界面

随着玩家正确回答问题,他们可以进入下一轮。当玩家赢得一轮时,游戏会更新问题和答案选项,确保玩家始终面临新的问题挑战。游戏将继续进行,直到没有更多问题可以回答,从而为玩家提供引人入胜且具有竞争力的多人游戏体验。

图 5.3 – 显示本回合获胜者的测验界面

图 5.3 – 显示本回合获胜者的测验界面

在下一节中,我们将深入探讨我们的测验游戏的大厅。我们将探讨如何创建一个无缝的多人大厅系统,允许玩家加入游戏并在一个有趣且引人入胜的多人环境中竞争。

设置测验游戏的大厅

在接下来的章节中,我们将深入探讨为我们的测验游戏设置大厅的过程。QuizLobby 场景是玩家互动和为游戏做准备的中心。

图 5.4 – 显示用户名和密码字段以及比赛中玩家的登录界面

图 5.4 – 显示用户名和密码字段以及比赛中玩家的登录界面

确认玩家的过程与我们之前章节中做的方法类似,利用玩家提交的数据与 FakeDatabase 进行匹配。这确保只有拥有有效凭证的注册玩家才能访问大厅。

一旦玩家成功登录,他们的名字将出现在其他玩家面前,为当前大厅中的玩家提供可见性。你可以选择将之前的聊天也添加到这个场景中,以便玩家在比赛开始前进行互动。这将营造社区感,并允许玩家在等待游戏开始时相互连接和互动。

QuizLobby 场景与我们的前一个大厅相似。因此,在本节中,我们将重点介绍它在大厅(第三章,“制作大厅以聚集玩家”)基础上增加的核心功能。

在下一节中,我们将创建并理解这个新大厅迭代中的附加功能。为此,打开 res://05.quiz-online/QuizLobby.gd 脚本,并转到 add_logged_player() 方法。

显示新玩家

在这次更新的大厅中,我们将拥有的新功能之一是查看加入当前比赛的所有玩家的能力。为了实现这一点,让我们采取以下步骤:

  1. add_logged_player() 方法内部,将 logged_players_label.text 属性设置为 player_name;此函数接收一个参数。生成的文本应在当前内容下方追加 player_name,为此,我们将字符串与一个跳行占位符字符串连接,并将占位符格式化为 player_name

    @rpc
    func add_logged_player(player_name):
        logged_players_label.text = logged_players_
            label.text + "\n%s" % player_name
    
  2. 然后,转到 start_game() 方法,并在 @rpc 注解中添加 "authority""call_local" 选项:

    @rpc("authority", "call_local")
    func start_game():
        pass
    
  3. 然后,在函数内部,让我们告诉 SceneTree 将当前场景更改为 quiz_screen_scene,这是一个指向 QuizScreenClient.tscn 的变量:

    @rpc("authority", "call_local")
    func start_game():
        get_tree().change_scene_to_file(quiz_screen_scene)
    
  4. 最后,在 _on_StartButton_pressed() 回调中,我们将直接调用多玩家权限的 start_game() 方法,而无需在本地调用:

    func _on_StartButton_pressed():
        rpc_id(get_multiplayer_authority(), "start_game")
    

在游戏开发中,确保公平的游戏玩法并为玩家提供愉快的体验是创建成功游戏的关键方面。这涉及到实现各种功能和特性,使游戏引人入胜且动态。其中一项功能是在玩家加入游戏时将玩家添加到比赛中。这可以通过创建一个面板来实现,该面板显示当前参与游戏的玩家名单。

在多人游戏中,服务器和客户端之间的通信至关重要。确保只有授权实体可以执行特定操作是至关重要的。这确保了游戏机制和流程对所有玩家都是一致和可靠的。

最后,一旦所有玩家都进入游戏,比赛即将开始,下一步是将他们和服务器移动到下一个游戏屏幕。这个屏幕将显示有关游戏的所有必要信息,例如目标、规则和游戏机制。

这确保了所有玩家都在同一页面上,并知道他们可以期待游戏中的什么。总的来说,实现这些功能确保游戏运行顺畅,玩家拥有积极的游戏体验。

这样,每次有玩家加入比赛时,他们的名字都会被添加到start_game()方法中,只有多人游戏权限者可以远程调用它,对吧?我们很快就会看到一些新东西。

在服务器端,我们将对此方法有不同的实现。在下一节中,我们将看到比赛是如何实际开始的,以及为什么我们将所有玩家和服务器移动到下一个游戏屏幕。

开始比赛

这种实现方式是为了防止一个玩家在没有其他玩家也开始游戏的情况下,对其他玩家或自己调用start_game()方法。想法是,按下开始按钮的玩家将请求多人游戏权限者开始游戏。

反过来,多人游戏权限者,在本例中是服务器,将告诉每个玩家也开始比赛。它是通过在每个玩家上调用start_game()方法来做到这一点的。让我们看看这是如何完成的:

  1. 打开res://05.online-quiz/QuizServer.gd脚本并找到start_game()方法。

  2. @rpc注解行中,添加"any_peer""call_remote"选项。这将允许网络上的任何节点对该方法进行远程调用:

    @rpc("any_peer", "call_remote")
    func start_game():
    
  3. 然后,告诉 SceneTree 使用get_tree().change_scene_to_file()方法切换到quiz_screen_scene_path。这将通知服务器也更新其上下文到QuizScreenServer场景中的那个。这对于实际游戏的运行是必要的:

    @rpc("any_peer", "call_remote")
    func start_game():
    get_tree().change_scene_to_file(quiz_screen_scene_path)
    
  4. 最后,也是最重要的,向其他节点的start_game方法发起 RPC 调用,这样网络上的每个人都会移动到各自的QuizScreenClient场景:

    @rpc("any_peer", "call_remote")
    func start_game():
        get_tree().change_scene_to_file
            (quiz_screen_scene_path)
        rpc("start_game")
    

大厅系统是任何在线多人游戏的关键组成部分,因为它作为玩家连接和准备比赛的入口。在我们的问答游戏中,我们已经成功使用 Godot 内置的远程过程调用RPC)功能实现了大厅系统。此功能使我们能够在客户端和服务器之间建立可靠的双向通信通道,确保所有玩家保持同步。

在设置了大厅系统后,玩家可以加入比赛,他们的名字将被添加到start_game()方法中,只有多人游戏权限者可以调用此方法,以防止未经授权的调用并确保游戏的完整性。此方法的客户端实现将不同,我们将在下一节中探讨这一点。

如果你想在大厅系统中添加更多功能,你可以创建一个类似于在魔兽争霸 III:混乱之治中找到的倒计时计时器。这个功能增加了比赛的兴奋和期待感,并有助于玩家为即将到来的比赛做好心理准备。然而,对于我们的问答游戏,我们已经准备好进入下一步。

有了这些,我们的问答游戏的大厅部分已经准备好,可以聚集一些玩家并为他们设置,以便开始比赛。我们看到了如何使用 Godot 提供的@rpc注解选项来创建双向通信,我们可以使用它来同步玩家并将他们全部移动到实际游戏中。

大厅系统是任何在线多人游戏的关键部分,我们已经成功地使用 Godot 内置的 RPC 功能在我们的问答游戏中实现了它。该系统允许玩家加入比赛并与其数据同步到服务器,确保游戏公平且一致。虽然我们可以在大厅系统中添加更多功能,例如倒计时计时器,但我们现在准备进入开发下一个阶段。

在下一节中,我们将创建一个机制来禁用其他玩家已经选择了正确答案时玩家的动作。有了这个机制,你甚至可以创建一个回合制机制,这正是我们将在第六章中做的,构建在线 国际象棋游戏

实现在线回合

在设计问答游戏时,确保玩家只能对一个给定问题提供一个答案非常重要。当创建多人游戏时,这可能特别具有挑战性,因为多个玩家可能会同时尝试回答问题。

为了防止这种情况,有必要实现一个系统,一旦其他玩家提供了有效答案,就禁用玩家回答问题的能力。

实现这个系统的一个常见方法是在玩家提供回应后禁用代表潜在答案的按钮。这可以通过识别哪个按钮被按下并将其与游戏数据库中存储的正确答案进行比较的代码来完成。一旦确定了答案,代码就可以禁用按钮并防止其他玩家回答问题。

为了进一步提高玩家的体验,在提供答案后通常也会包含一个短暂的暂停。在这段时间里,玩家可以回顾问题和答案,游戏可以显示关于答案是否正确的反馈。这有助于在游戏中建立紧张和兴奋的气氛,同时也给玩家一个反思自己表现并提高技能的机会。

我们需要防止玩家在另一位玩家已经回答过同一问题后再次回答。为此,我们可以在提供有效响应后禁用代表答案的按钮。为了更好的体验,我们可以在切换到下一个问题之前添加一个短暂的暂停。

在本节中,我们将了解如何防止玩家的动作,最终创建一个伪回合制机制。

让我们了解如何实现这种伪回合制机制:

  1. 打开 res://05.online-quiz/QuizScreenServer.gd 脚本,并实现其主方法。

  2. 首先,让我们将 "any_peer" 添加到 answered() 方法的 @rpc 注解中。这将允许任何玩家在正确回答问题时触发我们即将描述的行为。

  3. answered() 方法内部,我们将告诉 quiz_panel 更新本回合的获胜者,通过调用 "update_winner" 方法并传递存储在数据库中的玩家姓名来执行 RPC。这将更新每个对等方的 QuizPanel,告知本回合的获胜者:

    @rpc("any_peer")
    func answered(user):
        quiz_panel.rpc("update_winner", database[user]
            ["name"])
    
  4. 然后,我们启动一个本地计时器,等待足够的时间让玩家消化本回合的获胜者。我们还对 wait_label 进行 RPC 调用,以确保每个人的 WaitLabel 显示正确的等待时间:

    @rpc("any_peer")
    func answered(user):
        quiz_panel.rpc("update_winner", database[user]
            ["name"])
        timer.start(turn_delay_in_seconds)
        wait_label.rpc("wait", turn_delay_in_seconds)
    
  5. 现在,让我们在 missed() 方法上做同样的事情。但我们将对 "player_missed" 进行 RPC 调用:

    @rpc("any_peer")
    func missed(user):
        quiz_panel.rpc("player_missed", database[user]
            ["name"])
        timer.start(turn_delay_in_seconds)
        wait_label.rpc("wait", turn_delay_in_seconds)
    

有了这个,QuizScreenServer 处理玩家在赢得或输掉回合时的游戏状态。使用 RPC,我们可以更新所有对等方关于游戏中的发生情况,并使他们为下一回合做好准备。但我们还没有看到这是如何实际工作的。接下来,让我们看看在调用 update_winner()player_missed() 方法时 QuizPanel 发生了什么。

更新玩家关于本回合的信息

在多人问答游戏中,保持所有玩家与游戏状态同步至关重要,尤其是在有人已经正确回答了问题的情况下。QuizScreenServer 主节点负责更新游戏状态,并通知所有连接的玩家关于当前回合中刚刚发生的事情。为此,QuizScreenServer 主节点会对所有对等方的 QuizPanels 进行 RPC 调用。每个玩家侧的 QuizPanel 节点将本地更新游戏状态,并防止在下一回合开始前进行任何进一步交互。

这些方法的实现确保所有玩家处于同一页面,玩家之间的游戏状态没有差异。采用这种方法,我们可以为游戏中的所有玩家提供公平和一致的游戏体验。

打开 res://05.online-quiz/QuizPanel.gd 文件,并实现 update_winner()player_missed() 方法,以及它们的辅助方法,例如 lock_answers()unlock_answers()

  1. 找到update_winner()方法,并为其@rpc注解添加"call_local"选项。我们这样做是因为当我们在这个服务器上执行这个 RPC 时,它也应该更新自己的QuizPanel节点:

    @rpc("call_local")
    func update_winner(winner_name):
    
  2. 然后,在update_winner()方法内部,更新question_label.text属性以显示包含winner_name的消息:

    @rpc("call_local")
    func update_winner(winner_name):
        question_label.text = "%s won the round!!" %
            winner_name
    
  3. 最后,我们将调用lock_answers()方法。这将使玩家等待下一轮,正如我们很快就会看到的:

    @rpc("call_local")
    func update_winner(winner_name):
        question_label.text = "%s won the round!!" %
            winner_name
        lock_answers()
    
  4. 我们可以在player_missed()方法中做完全相同的事情。但在这里,我们将显示不同的消息,传达玩家错过了答案:

    @rpc("call_local")
    func player_missed(loser_name):
        question_label.text = "%s missed the question!!" %
            loser_name
        lock_answers()
    

    因此,我们的用户界面正在更新玩家关于他们同伴行动的信息。如果一个玩家回答正确,他们会知道;如果一个玩家回答错误,他们也会知道。现在是让他们为下一轮做好准备的时候了。让我们看看lock_answers()unlock_answers()方法。

  5. lock_answers()方法中,我们将遍历所有AnswerButtons,它们是Answers节点的子节点,并将它们禁用。这样,玩家就无法再与这些按钮交互,从而防止他们回答问题:

    func lock_answers():
        for answer in answer_container.get_children():
            answer.disabled = true
    
  6. unlock_answers()方法中,我们做的是相反的操作,关闭每个AnswerButton节点上的禁用属性:

    func unlock_answers():
        for answer in answer_container.get_children():
            answer.disabled = false
    

这将阻止并允许玩家与当前问题的可用答案进行交互。我们可以使用相同的方法创建一个实际的轮流制系统,其中玩家轮流尝试一次回答一个问题。这里有一个挑战给你,我们的网络工程师。作为一个练习,使用你刚刚获得的知识实现一个轮流制系统。你拥有所有必要的资源。

轮流制系统是一种构建游戏玩法的方式,其中每个玩家轮流进行移动,然后传递控制权给下一个玩家。这与实时游戏玩法形成对比,在实时游戏玩法中,所有玩家同时行动。轮流制系统通常用于策略游戏,在这些游戏中,玩家需要仔细规划他们的移动。

要在你的问答游戏中实现轮流制系统,你需要修改现有的代码以添加一个新的逻辑层。一种方法是为玩家创建一个队列,每个玩家按顺序轮流。当轮到玩家时,他们可以回答问题,而其他玩家则被锁定。一旦他们回答完毕,他们的回合就结束了,队列中的下一个玩家接着进行他们的回合。

要创建这个系统,你可以将lock_answer()unlock_answer()转换为 RPC 方法,并使用rpc_id()方法直接根据玩家是否是当前活动玩家来锁定或解锁玩家的答案选项。

在下一节中,我们将了解如何将问答游戏的基本机制应用于在线环境。这将是本章的核心内容,我们将看到我们如何评估玩家是否正确回答了问题,以及我们如何加载新问题,确保所有玩家都在查看相同的问题。

将本地机制转化为远程游戏

现在我们能够管理玩家的交互并向玩家传达游戏状态,是时候实现问答的核心功能了。接下来,我们将实现问题和答案。为此,我们将使用一个问题数据库,在其中我们将它们与可能的答案和正确答案索引一起存储。

在这里,我们将看到我们如何加载和反序列化这些问题到QuizPanel中。除此之外,我们还将了解我们如何利用 RPC 来保持所有人的同步。当然,我们还将实现当玩家选择正确和错误答案时的逻辑。

当玩家选择一个答案时,我们需要将其与正确答案索引进行比较,如果正确,我们应该通知QuizScreenServer关于正确答案。我们还将需要使用 RPC 来保持所有人关于当前问题和答案状态的同步。

此外,我们还需要实现当玩家选择错误答案时背后的逻辑。我们可以使用之前使用的相同锁定机制来防止玩家在有人已经提供了有效答案时回答。一旦我们处理了错误答案,我们需要通知QuizScreenServer关于错误答案,并进入下一轮。

通过实现所有这些功能,我们可以创建一个健壮且引人入胜的多人同时可玩的问答游戏。通过使用数据库来加载问题,我们可以使游戏动态多变。并且通过使用 RPC 和锁定机制,我们可以确保游戏运行顺畅,并且每个人都处于同一页面上。

理解问题数据库

首先,让我们先看看我们的问题数据库。打开文件res://05.online-quiz/QuizQuestions.json。它看起来是这样的:

{
    "question_01":
        {
            "text": "Which of the following is not a
                Node?",
            "alternatives": ["Sprite2D", "Line2D",
                "Area3D", "PackedScene"],
            "correct_answer_index" : 3
        },
    "question_02":
        {
            "text": "Which of the following is an image
                file?",
            "alternatives": ["Bot.png", "Landscape.txt",
                "BeautifulTown.json", "UglyDuck.md"],
            "correct_answer_index" : 0
        },
    "question_03":
        {
            "text": "Which of the following is a sound
                file?",
            "alternatives": ["Scream.txt", "Blabla.ogg",
                "Laser.gd", "Music.svg"],
            "correct_answer_index": 1
        }
}

注意,我们代表每个问题作为一个既是也是字典。每个问题有三个键:"text""alternatives""correct_answer_index""text"键是实际的问题陈述,"alternatives"是一个可能的答案数组,我们将将其转换为AnswerButtons,而"correct_answer_index""alternatives"数组中正确答案的索引。

了解这一点后,你可以自行创建一些问题。请注意,默认情况下,我们有四个AnswerButtons,所以在"alternatives"键中尝试提供四个值。否则,你需要实现一个AnswerButtons工厂,根据我们从问题中加载的答案数量动态创建它们。

加载和更新问题

现在,让我们了解在QuizPanel内部是如何实现这个过程的。打开位于res://05.online-quiz/QuizPanel.gd的脚本,并找到update_question()方法。你首先会注意到它有一个@rpc注解。

这是因为我们设计得这样,是服务器调用它并告诉它加载哪个问题。我们将在稍后看到这一点,但现在,让我们实现这个方法的逻辑:

  1. 创建一个名为question的变量,并将其设置为对available_questions调用pop_at()方法的结果,传入new_question_index参数。这样,我们将从可用问题列表中删除当前问题并将其存储起来以便继续使用:

    func update_question(new_question_index):
        var question = available_questions.pop_at
            (new_question_index)
    
  2. 检查问题是否不等于null。由于pop_at()方法在提供的索引中找不到值时返回null,我们检查这一点以了解是否还有我们没有使用的问题,换句话说,如果available_questions为空:

        if not question == null:
    
  3. 如果我们得到的问题不是null,将question_label.text属性设置为存储在questions数组中的问题字典的'text'属性。这就是我们显示问题陈述的方式:

        question_label.text = questions[question]['text']
    
  4. 创建一个名为correct_answer的变量,并将其值设置为存储在questions数组中的问题字典的'correct_answer_index'属性。这样做,我们可以保留正确的答案以便在玩家回答时进行比较:

        correct_answer = questions[question]
            ['correct_answer_index']
    
  5. 使用range()函数和for循环遍历数字03(包含),对于每次迭代,创建一个名为alternative的变量,并将其设置为i,即存储在问题字典中的'alternatives'数组中的当前元素。将answer_container节点的当前子节点的文本设置为alternative。这样,我们就在各自的AnswerButton上显示了备选答案的文本:

        for i in range(0, 4):
            var alternative = questions[question]
                ['alternatives'][i]
            answer_container.get_child(i).text =
                alternative
    
  6. 在加载问题和其答案后,让我们调用unlock_answers()函数。这基本上开始了当前回合,允许玩家再次与QuizPanel交互:

            unlock_answers()
    
  7. 如果questionnull,意味着我们没有剩余的问题可以玩测验,我们需要使用for循环遍历answer_container节点的每个子节点。对于每次迭代,我们将question_label节点的文本设置为'No more questions'

        else:
            for answer in answer_container.get_children():
                question_label.text = "No more questions"
    
  8. 由于我们已经到达了我们的测验比赛的终点,我们可以调用lock_answers()函数来防止任何进一步的交互:

                lock_answers()
    

在这些步骤之后,update_question()方法应该看起来像这样:

@rpc
func update_question(new_question_index):
    var question = available_questions.pop_at
        (new_question_index)
    if not question == null:
        question_label.text = questions[question]['text']
        correct_answer = questions[question]
            ['correct_answer_index']
        for i in range(0, 4):
            var alternative = questions[question]
                ['alternatives'][i]
            answer_container.get_child(i).text =
                alternative
        unlock_answers()
    else:
        for answer in answer_container.get_children():
            question_label.text = "No more questions"
        lock_answers()

通过这种方式,我们已经设置了主要的测验机制。我们可以从我们的数据库中选择一个问题并显示给我们的玩家。你可以检查_ready()回调来了解我们如何将问题加载到内存中并将它们分配给available_questions变量。

如前所述,我们将在这里关注重点。说到重点,我们仍然缺少一个机制,那就是我们如何验证答案,对吧?找到 evaluate_answer() 方法,让我们来实现它:

  1. evaluate_answer() 方法的内部,创建一个名为 is_answer_correct 的变量,并将其设置为 answer_indexcorrect_answer 变量的比较。这将检查给定的答案索引是否与正确答案的索引匹配:

        var is_answer_correct = answer_index == correct_answer
    
  2. 发射一个名为 answered 的信号,其参数为 is_answer_correct 变量。这个信号将被测验的其他部分用来告知玩家的答案是否正确:

        answered.emit(is_answer_correct)
    

    最后,我们的 evaluate_answer() 方法相当简单,只做我们需要知道玩家是否正确回答当前问题的事情:

    func evaluate_answer(answer_index):
        var is_answer_correct = answer_index == correct_answer
        answered.emit(is_answer_correct)
    

你可能已经注意到 evaluate_answer() 方法不是一个 RPC 函数,对吧?它本质上发射一个信号,告诉我们玩家的答案是否正确。那么服务器是如何管理这个的呢?

在接下来的部分,我们将了解这些信息是如何在我们测验的客户端和服务器实现之间传递的。

将玩家的答案发送到服务器

现在,让我们了解我们机制的最后一块以及它在多人网络中的行为。在前一节中,我们最终得到了对玩家答案的评估,这导致了 answered 信号的发射。

answered 信号需要以特定的方式处理,以确保所有玩家保持同步,并且所有对等方的游戏状态保持一致。当玩家提交答案时,会发射 answered 信号,并通过 RPC 调用服务器更新所有对等方关于这一点。

answered 信号及其相关方法对于维护多人网络中所有玩家游戏状态的一致性至关重要。没有它们,玩家可能会看到不同的游戏状态,并拥有不同的体验,这会使游戏变得不那么有趣,甚至可能不公平。

在本节中,我们将了解这个信号是如何在网络中传播并更新所有对等方关于玩家答案的。

打开 res://05.online-quiz/QuizScreenClient.gd 脚本,你将注意到,在最开始,我们有一个对 QuizPanel.answered 信号的回调。

让我们来实现这个回调:

  1. 在方法体内,使用 if 语句检查 is_answer_correct 是否为真:

    func _on_quiz_panel_answered(is_answer_correct):
        if is_answer_correct:
    
  2. 如果 is_answer_correcttrue,则使用 rpc_id() 方法调用服务器上的 answered 方法。有了这个,服务器将更新所有对等方关于本回合胜者的信息:

    func _on_quiz_panel_answered(is_answer_correct):
        if is_answer_correct:
           rpc_id(
               get_multiplayer_authority(),
               "answered",
               AuthenticationCredentials.user
           )
    
  3. 如果 is_answer_correctfalse,则使用 rpc_id() 方法调用服务器上的错过方法。最后,如果玩家选择了错误的答案,服务器将更新所有对等方关于这一点:

        else:
           rpc_id(
               get_multiplayer_authority(),
               "missed",
               AuthenticationCredentials.user
           )
    

_on_quiz_panel_answered() 的整个实现应该看起来像这样:

func _on_quiz_panel_answered(is_answer_correct):
    if is_answer_correct:
       rpc_id(
           get_multiplayer_authority(),
           "answered",
           AuthenticationCredentials.user
       )
    else:
       rpc_id(
           get_multiplayer_authority(),
           "missed",
           AuthenticationCredentials.user
       )

因此,客户端实现将通知服务器有关玩家交互的信息。反过来,服务器将更新游戏状态,并告诉所有同伴也更新他们的游戏状态以匹配服务器的状态。之后,我们就位了缺失的网络组件,我们的在线问答游戏已经准备就绪。请随意测试并尝试更多问题!

摘要

在本章中,我们看到了如何使用 Godot Engine 4.0 网络 API 创建在线问答游戏。我们涵盖了在线多人游戏开发的基本概念,包括客户端-服务器架构、游戏同步和玩家交互。通过问答游戏,我们看到了如何从 JSON 数据库动态加载问题,以及如何在问答比赛中显示当前玩家。我们创建了一个机制,防止玩家在另一位玩家已经回答问题后回答,从而创建了一个伪回合制机制。最后,我们看到了如何管理玩家交互,将游戏状态传达给玩家,以及如何实现正确和错误答案背后的逻辑,一轮接一轮地加载新问题,直到没有更多问题可以显示。

在下一章的项目中,我们将更深入地实现我们的在线问答游戏的回合制机制。正如我们在本章中看到的,我们可以使用与伪回合制机制类似的方法,但进行一些修改以使其成为一个真正的回合制系统。

此外,我们将探讨如何传递有关玩家回合的信息,例如谁正在轮到他们,对手回合期间发生了什么,以及更多内容。我们还将学习如何设置胜负条件并更新同伴们关于这些条件的信息,这对于在我们的游戏中营造成就感与挑战感至关重要。

到下一章结束时,你将更深入地了解游戏开发过程,包括如何创建引人入胜的在线多人游戏机制,并使用 Godot Engine 4.0 网络 API 实现它们。那里见!

第六章:构建在线国际象棋游戏

在本章中,我们将深入探讨创建在线多人国际象棋游戏的迷人领域。我们将应用本书中获得的全部知识和技能,来开发一个引人入胜且互动性强的游戏体验。

棋盘游戏“国际象棋”,一种深受各个年龄段玩家喜爱的经典棋类游戏,为探索在线多人游戏开发的复杂性提供了完美的画布。我们将学习如何利用 Godot 引擎的强大功能和其多变的特性来创建无缝的多人游戏体验,让玩家在游戏中制定策略、竞争并共同享受游戏。

为了促进游戏状态在多个玩家之间的同步,我们将介绍一个名为 MultiplayerSynchronizer 的强大工具节点。这个节点将在更新所有连接玩家棋盘上棋子位置方面发挥关键作用。通过使用这个节点,我们可以确保每个玩家的游戏视图保持一致并更新,从而提升整体的多人游戏体验。

在本章中,我们将涵盖一些基本概念,如客户端-服务器架构、游戏同步和玩家交互,这些对于任何多人游戏开发都是基础。通过理解这些概念并将它们应用于我们的国际象棋游戏,我们将创建一个强大且引人入胜的多人游戏体验,吸引全球各地的玩家。以下图表展示了最终项目,其中玩家正在在线相互对战!

图 6.1 – 黑队回合期间玩家 1 的游戏实例视图和玩家 2 的游戏实例视图

图 6.1 – 黑队回合期间玩家 1 的游戏实例视图和玩家 2 的游戏实例视图

除了技术方面,我们还将专注于创建直观的用户体验,使玩家能够无缝地导航游戏并与棋子进行交互。一个精心设计的界面对于提升玩家体验和确保流畅且愉快的游戏体验至关重要。通过使用一些视觉提示,我们将确保玩家能够直观地了解当前的游戏状态。

随着我们不断前进,我们将探讨管理玩家交互的策略,例如处理玩家回合和验证走法。这些功能对于保持游戏的公平性并确保游戏遵循国际象棋的既定规则至关重要。通过整合这些元素,我们将创造一个真实且沉浸式的国际象棋体验,让玩家可以连续数小时地参与其中。

到本章结束时,你将深入了解在线多人游戏开发的复杂性。你将拥有创建自己多人游戏所需的知识和技能,包括同步游戏状态、处理玩家交互以及提供沉浸式的多人游戏体验。

因此,准备好开始这段激动人心的旅程吧,我们将深入探索在线多人跳棋游戏开发的世界。为此,我们将学习MultiplayerSynchronizer节点和 RPC 函数,因为它们将是我们在同步玩家棋盘时的关键盟友。让我们开始这一章,共同发掘多人游戏开发的巨大潜力。

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

  • 介绍 Checkers 项目

  • 序列化玩家回合

  • 反序列化对手的回合

  • 管理胜负条件

技术要求

在这一章中,我们将处理 Godot Engine 项目仓库的第四个文件夹,您可以通过以下链接访问:github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

在继续我们的项目导入之前,你需要完成的一个要求是下载 Godot Engine 版本 4.0,因为这是我们将在整本书中使用的版本。

打开 Godot Engine 4.0 后,使用项目管理器打开项目。然后,导航到06.building-online-checkers文件夹。在这里,您可以找到我们构建本章项目所使用的所有文件。您可以测试游戏,打开并播放res://06.building-online-checkers/CheckersGame.tscn场景。

这个场景展示了我们游戏的大部分功能。在这一章中,我们还将实现我们在整本书中一直在使用的游戏大厅系统。除此之外,我们还将把一些本地功能转变为在线多人功能。

所以,请保持关注。

介绍 Checkers 项目

欢迎来到我们尊敬的虚构独立开发工作室的网络工程师!随着我们进入这一章,让我们花一点时间熟悉现有的 Checkers 项目。目前,该项目是为本地多人设计的,允许玩家进行离线的激动人心的比赛。这种理解将为我们探索将 Checkers 游戏转变为吸引人的在线多人体验的道路提供一个坚实的基础。

在这个努力中,我们的目标是无缝地将现有的本地多人功能过渡到在线环境中,沿途不遇到重大障碍。通过利用我们现有的知识和技能,我们可以有效地调整游戏以支持在线多人,从而扩大其影响力,并为玩家提供与全球对手竞争的机会。

在本节中,我们将揭示 Checkers 项目的内部运作,获得对其结构和机制的有价值见解。有了这种理解,我们将更有信心和效率地导航移植过程。

作为指定的网络工程师,你在这一事业中扮演着关键角色。你的专业知识和解决问题的能力将受到考验。通过仔细考虑和战略性的实施,我们可以最大限度地减少任何潜在挑战,并确保顺利过渡到在线多人领域。

一起,我们将检查项目的架构,剖析其组件,并确定实现在线多人功能所需的必要修改。通过应用我们对网络概念和编程技术的了解,我们将构建一个坚实的基础,在线多人功能将在此之上蓬勃发展。

记住,你是这个过程中的一个重要部分;作为一名网络工程师,你的工作是实现所有在线多人代码,因此你在其中扮演着基本角色。你的技能将有助于我们愿景的实现——一款令人兴奋的在线多人棋盘游戏,吸引着数字世界各地的玩家。因此,让我们开始这段旅程。在接下来的章节中,我们将了解我们本地棋盘游戏的方方面面。

理解棋盘游戏棋子场景

棋子在我们的棋盘游戏中实现了棋子游戏的功能。它允许玩家通过选择或取消选择棋子与之交互,并跟踪其选择状态。在下面的屏幕截图中,我们可以看到场景树结构的棋子。

图 6.2 – 棋子场景节点层次结构

图 6.2 – 棋子场景节点层次结构

为了增强玩家在回合中的体验和决策,我们利用 EnabledColorRectSelectedColorRect 节点提供的视觉提示。当棋子在玩家的回合内能够进行有效移动时,EnabledColorRect 变得可见。相反,当玩家选择这个特定的棋子而不是其他棋子时,SelectedColorRect 变得可见,从而在它们之间实现清晰的区分。

Sprite2D 节点负责显示棋子的当前纹理。根据玩家的队伍以及棋子是否被提升为国王棋子,纹理可以代表白色棋子、黑色棋子或它们各自的国王版本。

SelectionArea2D 节点在检测玩家输入中起着至关重要的作用。通过使用 input_event 信号,我们建立了玩家点击与棋子之间的通信。这使得我们能够切换棋子的选择状态,确定它当前是否被选中或取消选中。

让我们看看它的代码,以了解每个节点如何在这个整体逻辑中发挥作用。

总体而言,这段代码构成了在线多人棋盘游戏的一个关键组成部分,提供了与游戏内单个棋子交互和管理其行为的必要功能:

extends Node2D
signal selected
signal deselected
enum Teams{BLACK, WHITE}
@export var team: Teams = Teams.BLACK
@export var is_king = false: set = _set_is_king
@export var king_texture = preload("res://
    06.building-online-checkers/WhiteKing.svg")
@onready var area = $SelectionArea2D
@onready var selected_color_rect = $SelectedColorRect
@onready var enabled_color_rect = $EnabledColorRect
@onready var sprite = $Sprite2D
var is_selected = false

我们首先定义一些变量来存储有关 Pieces 队伍的信息——它是否是国王,以及其纹理。它还有一些场景中的子节点的引用和一个变量来跟踪 Pieces 是否当前被选中。定义了信号以指示 Pieces 被选中或取消选中。接下来,我们将声明我们在 _is_king 变量中提到的设置函数:

func _set_is_king(new_value):
  is_king = new_value
  if not is_inside_tree():
    await(ready)
  if is_king:
    sprite.texture = king_texture

当这个变量被设置为新的值时,代码会检查节点是否是场景树的一部分。如果不是,它会等待节点准备好,这可以防止通过检查器更改变量值时出现任何错误。如果变量被设置为 true,它会更新游戏 Pieces 的视觉外观以表示国王,使用特定的纹理。这允许在 Pieces 成为国王时进行动态的视觉变化。之后,我们有来自 Area2D 节点的 input_event 信号的信号回调:

func _on_area_2d_input_event(viewport, event, shape_idx):
  if event is InputEventMouseButton:
    if event.button_index == 1 and event.pressed:
      select()

它监听左鼠标按钮点击,并在检测到点击时调用 select() 方法,正如我们接下来将要看到的,这个方法执行与选中 Pieces 相关的程序。这段代码在玩家在 Pieces 的 Area2D 上悬停并点击左鼠标按钮时启用游戏内的交互。这里,我们有 select() 方法的代码:

func select():
  get_tree().call_group("selected", "deselect")
  add_to_group("selected")
  selected_color_rect.show()
  is_selected = true
  selected.emit()

在这个函数中,我们定义了当 Pieces 被选中时会发生什么。它确保任何之前选中的对象被取消选中,标记当前对象为选中状态,显示一个可视指示器,更新一个变量以反映选中状态,并发出 selected 信号来通知游戏的其他部分关于选中事件。这段代码对于管理和传达游戏中 Pieces 的选中状态至关重要。然后,我们还有相反的函数,deselect()

func deselect():
  remove_from_group("selected")
  selected_color_rect.hide()
  is_selected = false
  deselected.emit()

在这里,我们定义了当对象被取消选中时会发生什么。代码将对象从 "selected" 组中移除,隐藏选中状态的可视指示器,更新 is_selected 变量以反映取消选中状态,并发出 deselected 信号来通知游戏的其他部分关于取消选中事件。现在,是时候启用选择状态的变化了;没有这个,Pieces 不应该被选中。这有助于防止玩家从对手的队伍中选中 Pieces:

func enable():
  area.input_pickable = true
  enabled_color_rect.visible = true

在这部分,我们使 Area2D 对输入事件做出响应,并在屏幕上显示一个可视指示器。这对于允许玩家在启用状态下与游戏中的 Pieces 交互和操作非常重要。现在,让我们看看它的对应方法,即 disable() 方法:

func disable():
  area.input_pickable = false
  enabled_color_rect.visible = false

这基本上是前一种方法的相反。我们使 Area2D 对输入事件无响应,从而使其不可交互。除此之外,我们还隐藏了表示 Pieces 可用性的屏幕上的可视指示器,直观地表示 Pieces 的禁用状态。这对于控制玩家何时以及如何与游戏中的 Pieces 交互非常有用。

在接下来的部分中,我们将看到 FreeCell 场景是如何工作的。这是一个我们用来突出显示所选棋子可以移动到的可用空单元格的场景。

理解 FreeCell 场景

在我们的游戏项目中,有一个称为 FreeCell 的概念,它代表一个棋子可以移动到的有效单元格。把它想象成一个棋子被允许进入的指定区域。每次玩家选择一个具有有效移动的棋子时,我们通过将它们显示为绿色来视觉上指示可用的单元格。这些单元格实际上是 FreeCell 场景的实例,我们动态地在游戏板上创建和显示这些实例。

为了提供一个清晰的例子,想象一下选择了一个国王棋子的场景。在下面的图中,你可以看到所有这个国王棋子可以移动到的单元格都被高亮显示为绿色。这些被高亮的单元格都是 FreeCell 场景的实例,允许玩家快速识别所选棋子的可能移动选项。

Figure 6.3 – Free cells available to the selected king piece

图 6.3 – 可供所选国王棋子使用的 FreeCell

通过使用 FreeCell 概念,我们通过视觉传达有效的移动可能性来增强玩家的体验。这使他们能够做出明智的决定并有效地策略性地规划下一步。这是一个强大的工具,它为游戏机制增添了清晰度和深度。

FreeCell 场景是我们游戏的一个基本组成部分,由三个不同的节点组成。主要节点是一个 Area2D,它是 FreeCell 的基础。它包括两个子节点 – 一个 CollisionShape2D 和一个 ColorRect

CollisionShape2D 节点负责定义 FreeCell 的形状和边界。它确保单元格可以正确地与其他游戏对象(如棋子或游戏世界中的其他元素)交互。正如我们即将看到的,CollisionShape2D 还定义了 Area2D 检测鼠标输入的边界,这对于 FreeCell 的行为是基本的。

ColorRect 节点相反地控制着 FreeCell 的视觉表示。它决定了单元格的颜色和外观,为游戏板上的其他元素提供了一个视觉指示器,以便将其区分开来。

![Figure 6.4 – The FreeCell scene node hierarchy

![img/Figure_06.04_B18527.jpg]

图 6.4 – FreeCell 场景节点层次结构

为了更好地理解 FreeCell 的功能和行为,让我们探索其伴随的脚本。通过检查脚本代码,我们将深入了解 FreeCell 如何运作以及它与其他游戏元素的交互,最终有助于整体游戏逻辑和机制。

extends Area2D
signal selected(cell_position)
func _input_event(viewport, event, shape_idx):
     if event is InputEventMouseButton:
          if event.button_index == 1 and event.pressed:
               select()
func select():
     selected.emit(self.position)

此代码允许由Area2D表示的单元格在其区域内响应用户右键鼠标事件。当单元格被选中时,它发出一个包含单元格位置的信号。这个信号可以用来通知其他对象或脚本关于选择的信息,并为它们提供单元格的位置以进行进一步处理。我们将在CheckerBoard脚本中使用这个功能,将 FreeCell 的位置映射到棋盘的单元格上。

在下一节中,我们将介绍游戏棋盘,这是我们游戏的一个基本组成部分。然后,我们将把注意力转向本章的核心内容,即实现我们将将其转换为 RPC 的方法。这些 RPC 将使游戏能够在在线环境中无缝运行,允许玩家之间互动并同步他们的网络动作。

通过利用 RPC 的力量,我们将为玩家创造一个动态和吸引人的多人游戏体验。让我们深入了解细节,并探索这些方法如何使我们的游戏在在线环境中栩栩如生。

介绍 CheckerBoard 场景

在本节中,让我们深入探讨游戏棋盘的主要作用,因为这将为我们的后续章节奠定基础。通过理解棋盘的关键职责,我们可以自然地识别出我们在后续章节中将关注的特定领域。这种理解将为我们在继续探索棋盘的开发过程时提供一个清晰和结构化的方法。

我们游戏棋盘的主要作用是管理游戏中的黑白棋子之间的关系。为了实现这一点,棋盘利用内置的TileMap函数将棋子映射到笛卡尔坐标系上。此外,它还使用哈希表将棋盘上每个单元格的内容关联起来。

这意味着我们可以通过提供相应的Vector2i坐标来访问特定单元格的内容。例如,通过使用meta_board[Vector2i(0,3)]表达式,我们可以检索位于棋盘第一列第四行的单元格内容。这次访问的结果将是null,表示该单元格是空的,或者它将返回当前映射到该特定单元格的棋子。这种机制允许高效地检索和操作棋盘上的内容,从而实现流畅的游戏和与棋子的交互。

CheckerBoard 在我们的游戏中扮演着至关重要的角色,它监督游戏的各种方面。首先,它管理棋盘上每个单元格的可用移动,考虑到当前在场的队伍以及是否有棋子被提升为国王。这确保了玩家只能根据游戏规则进行有效移动。

CheckerBoard 还负责控制游戏中的回合。它根据活跃的队伍启用和禁用棋子,允许在回合中仅允许正在进行的队伍进行移动。这种机制确保了公平的游戏和维持游戏的流畅性。

此外,CheckerBoard 会跟踪每个队伍在每个回合结束时拥有的棋子数量。这个计数至关重要,因为它决定了游戏的胜负条件。如果一个队伍在棋盘上没有剩余的棋子,CheckerBoard 将触发适当的 win 条件,宣布对方队伍为胜者。

通过管理单元格移动、调节回合和监控棋子数量,CheckerBoard 维护游戏的规则和进展。它的作用对于提供明确的胜负条件至关重要。让我们看看 CheckerBoard 场景结构:

图 6.5 – CheckerBoard 场景节点层次结构

图 6.5 – CheckerBoard 场景节点层次结构

需要注意的是,CheckerBoard 是作为 TileMap 实现的,这是我们在游戏中使用的一个有用的类。我们使用 TileMap 类提供的特定方法,例如 map_to_local()local_to_map()get_used_cells(),来建立我们的单元格映射功能。map_to_local() 方法将帮助我们将游戏映射到 TileMap 中的单元格位置,而 local_to_map() 将帮助我们将棋子的位置转换为地图中的单元格。这将帮助我们以行和列的形式抽象游戏,而不是使用浮点数。至于 get_used_cells() 方法,它将帮助我们访问仅设置了瓦片的单元格,并避免在 TileMap 中处理空白单元格。这在我们创建单元格内容的矩阵时将非常有用。

CheckerBoard 类中,我们将关注理解 meta_boardcreate_meta_board()map_pieces() 方法在 CheckerBoard 类中的重要性:

func create_meta_board():
     for cell in get_used_cells(0):
          meta_board[cell] = null
func map_pieces(team):
     for piece in team.get_children():
          var piece_position = local_to_map(piece.position)
          meta_board[piece_position] = piece
          piece.selected.connect(_on_piece_selected.bind
              (piece))

create_meta_board() 方法负责设置 meta_board 字典。这个字典充当一个数据结构,将单元格坐标映射到它们对应的内 容。通过利用前面提到的 TileMap 方法,create_meta_board() 方法用适当的单元格坐标填充 meta_board,并将它们初始化为空值,表示空白单元格。

相反,map_pieces() 方法在更新 meta_board 以反映游戏当前状态方面发挥着至关重要的作用。该方法遍历提供的队伍上的所有棋子,该队伍作为引用传递给 BlackTeam 节点或 WhiteTeam 节点。然后,它使用 TileMap.local_to_map() 方法转换棋子的位置,并将每个棋子映射到 meta_board 中相应的单元格坐标。这确保 meta_board 准确地表示视觉棋盘上棋子的放置。

最后,代码在Piece.selected信号和_on_piece_selected()回调函数之间建立了一个连接。通过连接这个信号,我们将当前棋子绑定到回调函数作为其参数。这使得我们能够在玩家选择它时方便地访问Piece节点。

这个连接确保了当棋子发出选择信号时,相关的回调函数_on_piece_selected()将被调用,并提供了Piece节点作为其参数。这种机制允许我们根据玩家的选择执行特定操作或访问Piece节点的属性。

通过建立这个连接,我们在Piece节点和相应的回调函数之间创建了一个无缝的交互,增强了游戏的灵活性和响应性。

有一些辅助函数帮助我们计算可用单元格并协调棋子的移动;请随意查看它们,了解我们是如何检查可用单元格的,我们是如何捕获单元格的,以及其他游戏玩法特性。在下一节中,我们将探索我们跳棋开发旅程的不同方面。

我们将关注我们如何打包和传输有关玩家回合的所有相关信息,确保其他玩家能够及时更新游戏板当前的状态。

通过理解这个过程,我们将能够建立玩家之间的有效通信,促进无缝的多玩家体验。这种功能对于保持同步和实现在线多人游戏中的实时游戏至关重要。请继续关注,我们将深入了解在网络中传输和更新游戏状态的所有细节。

序列化玩家回合

第二章 发送和接收数据中,我们探讨了在网络中跨多个玩家重新创建游戏状态的基本技术。通过序列化相关数据并以小部分传输,我们确保了网络带宽的有效利用,同时保持了玩家之间的同步。

理解在玩家之间复制游戏状态所需的关键信息,涉及掌握游戏开发中的抽象概念。在我们的案例中,这主要围绕meta_board,它是我们游戏相关元数据(如棋子的位置数据和棋王状态以及棋盘中的空单元格)的抽象。

此外,我们需要考虑棋子的可用性,这取决于玩家的回合。幸运的是,游戏中的大多数其他元素可以在本地管理,无需网络同步。

为了简化在网络节点之间同步属性的过程,我想向您介绍MultiplayerSynchronizer。这个强大的节点承担了自动同步节点属性的责任,使我们免去了手动同步的繁琐任务。

MultiplayerSynchronizer就位后,我们可以专注于开发游戏逻辑,让节点处理玩家之间数据的高效传输。

与 MultiplayerSynchronizer 一起工作

MultiplayerSynchronizer通过允许我们轻松同步和共享多个玩家之间节点的状态,发挥着至关重要的作用,而无需编写任何额外的代码。为了开始利用这一功能,我们将向 Piece 的场景添加一个MultiplayerSynchronizer节点。这将确保每个玩家的游戏状态一致性。让我们深入了解集成MultiplayerSynchronizer并利用其功能的过程。

在 Piece 场景中设置 MultiplayerSynchronizer

打开res://06.building-online-checkers/Piece.tscn场景,并将MultiplayerSynchronizer作为Piece节点的子节点添加。然后,我们将设置我们想要同步的属性:

  1. 在选择MultiplayerSychronizer节点后,在复制选项卡的底部面板中,点击添加属性以同步按钮。

图 6.6 – MultiplayerSynchronizer 节点的复制菜单

图 6.6 – MultiplayerSynchronizer 节点的复制菜单

  1. 从弹出菜单中选择Piece节点。

图 6.7 – 从选择要同步的节点弹出菜单中选择 Piece 节点

图 6.7 – 从选择要同步的节点弹出菜单中选择 Piece 节点

  1. 在选择Piece节点后,将出现另一个弹出菜单,要求您选择要同步的属性。从那里,选择位置属性。

图 6.8 – 从选择属性弹出菜单中选择位置属性

图 6.8 – 从选择属性弹出菜单中选择位置属性

就这样。有了这个,一旦玩家连接到同一网络,他们就会自动同步棋盘上 Piece 的位置。然而,我们仍然有一个问题,因为这只会更新 Piece 的视觉表示,我们仍然需要更新其在meta_board中的数据。现在到了有趣的部分。

在下一节中,我们将开始了解 CheckerBoard 中我们需要转换为 RPC 的方法,以保持玩家在同一个页面上。

更新和同步 CheckerBoard

在开发任何应用程序时,一个核心问题是我们资源有限。在通过网络传输数据的情况下,我们谈论的是带宽。游戏是一个特殊情况,因为所有事情都应该实时发生,所以我们不能冒险进行会损害网络性能的大量数据传输。

为了将这一优势转化为我们的优势,我们需要以最抽象和轻量级的方式传递数据。在我们的例子中,我们的 meta_board 是表示游戏当前状态的一种手段。通过使用 Vector2i 坐标,我们可以访问和更改游戏状态。这就是我们将如何保持玩家更新的。在下一节中,我们将处理 CheckerBoard.update_cells() 方法,这是我们更新系统的核心。

使用坐标更新棋盘

由于 meta_board 是一个字典,我们可以使用 Godot 引擎内置的类型来访问和设置其键的值。例如,如果我们想将第三行第二列单元格的内容更改为 null,我们可以编写 meta_board[Vector2i(1, 2)] = null

当棋子进行移动时,我们只需要知道这个移动的先前单元格和新单元格的内容,这样我们就可以更新它。这正是 update_cells() 方法所做的事情。让我们看看它:

func update_cells(previous_cell, target_cell):
     meta_board[target_cell] = meta_board[previous_cell]
     meta_board[previous_cell] = null

由于这是我们更新系统的核心,我们需要将其转换为 RPC 函数并按此方式调用。

为了做到这一点,让我们在我们的脚本中进行适当的更改:

  1. 使用 any_peercall_local 选项将 @rpc 注解添加到这个方法中。我们使用这些选项是因为我们希望每个玩家都能更新其他玩家的棋盘变化,并且我们也希望他们的棋盘自己更新,因此有 call_local 选项:

    @rpc("any_peer", "call_local")
    func update_cells(previous_cell, target_cell):
    
  2. move_selected_piece() 方法中,将 update_cells(current_cell, target_cell) 行更改为 RPC 调用。这将使这个方法在其它对等机上也是本地和远程调用:

    func move_selected_piece(target_cell):
         var current_cell = local_to_map
             (selected_piece.position)
         Selected_piece.position = map_to_local
             (target_cell)
         rpc("update_cells", current_cell, target_cell)
         if not is_free_cell(target_cell):
              crown(target_cell)
    

这样,每当 CheckerBoard 移动一个棋子时,它就会在网络上所有对等机上更新其 meta_board 数据。

注意,我们还可以将另一个方法转换为 RPC。每个玩家都应该更新到达对手王行的棋子的 Piece.is_king。为此,我们在 move_selected_piece() 逻辑的底部调用了 crown() 方法。

让我们用与 update_cells() 相同的方式做同样的事情:

  1. 首先,我们使用 @rpc 注解并添加 any_peercall_local 选项:

    @rpc("any_peer", "call_local")
    func crown(cell):
    
  2. 然后,我们将 crown(target_cell) 调用更改为它的 rpc() 版本:

    func move_selected_piece(target_cell):
         var current_cell = local_to_map
             (selected_piece.position)
         selected_piece.position = map_to_local
             (target_cell)
         rpc("update_cells", current_cell, target_cell)
         if not is_free_cell(target_cell):
              rpc("crown", target_cell)
    

这样,当一个棋子到达王行时,所有玩家的 CheckerBoard 都会更新他们的王状态,无论是对手的棋子还是盟友的棋子。

我们的工作还没有完成。在下一节中,我们将看到当玩家执行捕获动作时如何更新 meta_board 内容,这意味着我们需要从棋盘上移除一个棋子。

从棋盘上移除棋子

当玩家移动棋子并最终捕获对手的棋子时,我们应该相应地更新游戏板。这意味着除了更新移动涉及的单元格外,我们还应该更新捕获的棋子所在的单元格,将其内容设置为 null - 也就是说,将其变成一个空单元格。这正是 remove_piece() 方法所做的事情。

让我们看看它的代码:

func remove_piece(piece_cell):
     if not is_on_board(piece_cell):
          return
     if is_free_cell(piece_cell):
          return
     var piece = meta_board[piece_cell]
     piece.get_parent().remove_child(piece)
     piece.free()
     meta_board[piece_cell] = null

由于这种行为影响双方玩家,我们需要将此方法也转换为 RPC,以便每次玩家捕获一个棋子时,他们都会更新对手这个令人难过的事实。

让我们做出适当的更改,以确保这个功能符合我们的在线多人游戏需求:

  1. @rpc注解添加到remove_piece()方法上,并使用any_peercall_local选项:

    @rpc("any_peer", "call_local")
    func remove_piece(piece_cell):
    
  2. capture_piece()方法中,将remove_piece(cell)行更新为其rpc()版本:

    func capture_pieces(target_cell):
         var origin_cell = local_to_map(selected_piece.
             position)
         var direction = Vector2(target_cell -origin_cell)
             .normalized()
         direction = Vector2i(direction.round())
         var cell = target_cell - direction
         if not is_on_board(cell):
              return
         if not is_free_cell(cell):
              rpc("remove_piece", cell)
              move_selected_piece(target_cell)
    

现在,每次玩家捕获一个单元格时,它都会在所有连接的玩家上本地和远程调用remove_piece()方法!

这样,我们就有了玩家回合的正确序列化,并准备好通过网络传递给其他玩家,具有良好的性能和低数据使用,如果我们愿意,还可以留下良好的带宽。例如,如果我们想在将来添加聊天功能,我们可以使用新的 RPC 通道。

在本节中,我们学习了抽象相关数据对于我们的网络通信的重要性,以及如何将本地功能转换为远程功能,同时保持所有逻辑和整体结构。在这里,我们看到了call_local RPC 选项的相关性,以及使用rpc()方法将方法调用转换为 RPC 调用的简单性。

在下一节中,我们将看到如何管理回合逻辑。这是一个重要的功能,因为在那里我们需要积极添加一层网络验证来正确处理回合。本地回合转换和远程回合转换的逻辑非常不同。

处理远程回合转换

在线玩游戏的最重要的方面之一是保持玩家对其资源的自主权和控制权——在这种情况下,是他们团队的棋子。Godot 引擎提供了一个有趣的系统,其中 SceneTree 可以使用不同的多玩家权限来结构化其节点的层次结构。

为了递归地设置节点及其子节点的多玩家权限,我们可以使用set_multiplayer_authority()并传递相应的玩家 ID 作为参数。在我们的例子中,我们将更改BlackTeamWhiteTeam节点多玩家权限以匹配它们各自玩家的玩家 ID。

这将由服务器来完成,为了保持应用程序简单,我们将允许客户端和服务器共享相同的脚本,并且我们将通过在 CheckerBoard 上使用is_multiplayer_authority()来检查哪个正在运行服务器实例。只有在游戏在网络中运行并且有连接的玩家时,我们才应该运行这个逻辑。为此,我们可以检查multiplayer.get_peers().size()是否大于0,这意味着有玩家连接。让我们看看实际操作吧,好吗?

设置玩家团队

要处理玩家轮次转换,我们首先需要理解的是,代表团队的每个节点——换句话说,BlackTeamWhiteTeam节点——应该将其各自的玩家同伴 ID 设置为它们的多人权限。

在这个意义上,我们需要在CheckerBoard类中创建一个方法,该方法接收团队和同伴 ID 作为参数。记住,我们无法在这个方法中传递对象作为参数,因为它需要在网络中工作。因此,我们需要将团队抽象为一个enum,我们可以通过 RPC 传递它,然后所有同伴都将能够理解消息并在他们的端点访问正确的团队节点。让我们深入行动,创建一个名为setup_team()的方法:

  1. setup_team()函数定义之前添加@rpc("authority", "call_local")装饰器。authority选项表示只能由多人权限调用此 RPC;记住,CheckerBoard 的权限仍然是服务器。call_local参数指定该函数还应在本地调用者上执行:

    @rpc("authority", "call_local")
    func setup_team(team, peer_id):
    
  2. 在函数内部,检查team的值是否等于Teams.BLACK;如果是这样,在black_team对象上调用set_multiplayer_authority()方法,并传递peer_id作为参数。这实际上指定了指定的同伴为BlackTeam及其所有子节点——换句话说,黑棋——的权限:

    @rpc("authority", "call_local")
    func setup_team(team, peer_id):
         if team == Teams.BLACK:
              black_team.set_multiplayer_authority
                  (peer_id)
    
  3. 否则,在white_team对象上调用set_multiplayer_authority()方法,并传递peer_id作为参数:

    @rpc("authority", "call_local")
    func setup_team(team, peer_id):
         if team == Teams.BLACK:
              black_team.set_multiplayer_authority
                  (peer_id)
         else:
              white_team.set_multiplayer_authority
                  (peer_id)
    

此方法根据接收到的团队,black_teamwhite_team,使用提供的peer_id设置团队节点的多人权限。这确保了每个团队的多人权限都得到正确建立,允许游戏逻辑在网络同伴之间同步。由于服务器在所有同伴上调用此方法,玩家和服务器都将相应地同步他们的团队节点。

现在,为了确保这种机制将在所有同伴之间建立,我们将在_ready()回调内部直接添加以下代码行:

  1. _ready()回调内部,通过检查multiplayer.get_peers()数组的大小是否大于0来检查多人会话中是否有同伴连接:

    func _ready():
         if multiplayer.get_peers().size() > 0:
    
  2. 如果是这样,使用is_multiplayer_authority()函数检查当前节点是否是多人权限。这确保我们只会在服务器同伴中调用以下逻辑:

    func _ready():
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
    
  3. 然后,使用rpc()方法,传递"setup_team"Teams.BLACKmultiplayer.get_peers()[0]参数进行 RPC 调用。这将调用所有连接同伴的setup_team()方法,告诉他们使用连接同伴列表中的第一个同伴 ID 设置 BlackTeam 的多人权限。因此,在会话中首先连接的玩家将负责黑棋:

    func _ready():
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
                   rpc("setup_team", Teams.BLACK,
                       multiplayer.get_peers()[0])
    
  4. 在上一行下面,我们将做同样的事情,但使用Teams.WHITE和连接节点的第二个索引,即第二个连接到会话的玩家:

    func _ready():
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
                   rpc("setup_team", Teams.BLACK,
                       multiplayer.get_peers()[0])
                   rpc("setup_team", Teams.WHITE,
                       multiplayer.get_peers()[1])
    

如此一来,我们的团队设置已经到位。请注意,由于服务器已经将团队节点多玩家权限分配给了比赛中每位玩家,因此服务器本身无法在棋盘的棋子中进行任何移动。

说到那个预防机制,它是如何工作的?跳棋棋盘是如何防止玩家与棋子互动的,尤其是与他们的对手棋子的?在下一节中,我们将看到我们如何检测哪个玩家被分配到哪个团队,并且只重新启用他们适当的团队棋子。

启用和禁用团队棋子

在我们的游戏中,当玩家结束回合时,我们使用disable_pieces()方法禁用所有他们的棋子。在回合转换时,我们确保禁用两队的棋子,并检查上一回合是否有赢家;如果没有,我们根据回合的团队开始重新启用玩家棋子的程序。

所有这些都在toggle_turn()方法中发生,但就目前而言,它无法在线多人场景中工作,因为目前该方法仅执行本地逻辑。所以,让我们将其转换为一个适用于我们改进的在线多人跳棋游戏的方法。

然而,在那之前,让我们看看代码目前的状况,这样我们就可以提前确定我们需要在哪里进行调整:

func toggle_turn():
     clear_free_cells()
     disable_pieces(white_team)
     disable_pieces(black_team)
     var winner = get_winner()
     if winner:
          player_won.emit(winner)
          return
     if current_turn == Teams.BLACK:
          current_turn = Teams.WHITE
          enable_pieces(white_team)
     else:
          current_turn = Teams.BLACK
          enable_pieces(black_team)

此函数负责管理游戏的回合逻辑。它首先清除可移动的可用单元格,然后禁用两队的棋子。然后,它检查是否有赢家,如果有,则向CheckersGame脚本的_on_checker_board_player_won()方法发出信号,指示获胜的团队。如果没有赢家,则将回合切换到另一队并启用相应团队的棋子。

你能指出我们需要在哪里进行必要的更改,以便使其在我们的游戏在线版本中工作吗?请记住,游戏应该本地和远程都能工作,因此我们需要保持此方法的整体结果。让我们开始这个过程:

  1. 使用@rpc注解装饰toggle_turn()方法,使用any_peercall_local选项。这表示任何节点都可以在多人会话中远程调用此方法,但他们也应该本地调用它。这确保了即使我们没有加入多人会话玩游戏,我们也可以使用rpc()方法本地调用此方法,一切仍然会正常工作:

    @rpc("any_peer", "call_local")
    func toggle_turn():
    
  2. 在其中,检查current_turn是否为Teams.BLACK;我们将enable_pieces(white_team)移动到另一个检查中。这次,我们将检查是否没有连接的节点,这意味着我们是在单独或本地玩游戏:

    if current_turn == Teams.BLACK:
              current_turn = Teams.WHITE
              if not multiplayer.get_peers().size() > 0:
                   enable_pieces(white_team)
    
  3. 如果我们不在本地玩游戏,我们需要检查当前玩家是否是 WhiteTeam 的多玩家权限,使用multiplayer.get_unique_id()方法;如果是,我们可以启用WhiteTeam的棋子。这就是我们确保只有正确的玩家才能重新启用他们的棋子的方法:

    if current_turn == Teams.BLACK:
              current_turn = Teams.WHITE
              if not multiplayer.get_peers().size() > 0:
                   enable_pieces(white_team)
              elif white_team.get_multiplayer_authority()
                  == multiplayer.get_unique_id():
                   enable_pieces(white_team)
    
  4. 我们将要执行相同的事情,但是在else语句内部,这个语句处理的是current_turn是否来自Teams.WHITE

    else:
              current_turn = Teams.BLACK
              if not multiplayer.get_peers().size() > 0:
                   enable_pieces(black_team)
              elif black_team.get_multiplayer_authority()
                  == multiplayer.get_unique_id():
                   enable_pieces(black_team)
    

因此,每次我们调用toggle_turn()方法时,我们都会检查对等方是否有权控制当前正在进行的Pieces,并且我们只允许他们从他们的队伍中选择Pieces。现在,我们还需要进行一个小改动,以便使其符合我们的网络要求。在_free_cell_selected()回调中,让我们更改直接调用toggle_turn()方法的行,改为使用rpc()方法进行远程调用:

func _on_free_cell_selected(free_cell_position):
     var free_cell = local_to_map(free_cell_position)
     if can_capture(selected_piece):
          capture_pieces(free_cell)
     else:
          move_selected_piece(free_cell)
     rpc("toggle_turn")
     selected_piece.deselect()

注意,can_capture()方法负责检查在selected_piece周围是否有任何敌方棋子,这可能导致捕获移动。如果是这种情况,我们调用capture_pieces()方法,它将在选定的方向上对所有可能的敌方棋子执行捕获移动。否则,如果没有可用的捕获移动,我们将执行简单的移动,调用move_selected_piece()方法,并将free_cell作为参数传递。

现在,每当玩家选择一个可用的空闲单元格来移动棋子时,他们将对toggle_turn()进行远程过程调用,告诉所有连接的对等方正确地禁用和重新启用他们各自的棋子。太棒了,不是吗?

到目前为止,我们已经将游戏的所有核心机制都设置好了,我们可以与其他连接到我们网络的玩家进行在线比赛。唯一缺少的是,当玩家赢得比赛时,我们仍然需要在网络上进行通信,并允许玩家再次进行游戏。

在下一节中,我们将创建一个简单的机制,允许玩家在其中一个玩家赢得比赛后进行重赛。

管理胜负条件

太棒了!我们已经成功完成了 CheckerBoard 场景的开发,现在我们的游戏核心功能已经就绪。下一步是将 CheckersGame 场景的逻辑从本地游戏转变为远程游戏。

首先,让我们打开res://06.building-online-checkers/CheckersGame.tscn文件,熟悉其结构。

图 6.9 – CheckersGame 的场景节点层次结构

图 6.9 – CheckersGame 的场景节点层次结构

注意,CheckerBoard 的player_won信号连接到CheckersGame._on_checker_board_player_won()回调。这个回调负责处理玩家队伍在棋盘上没有剩余棋子的情况。现在,让我们打开 CheckersGame 的脚本继续操作。

我们将处理脚本中的所有方法,确保它们适合在线多人游戏功能进行适当调整:

  1. 首先,让我们给 update_winner() 方法添加 @rpc 注解,并使用 any_peercall_local 选项。

    @rpc("any_peer", "call_local")
    func update_winner(winner):
    
  2. 然后,我们将对 rematch() 方法做同样的处理。这个方法是由重赛按钮的 pressed 方法调用的:

    @rpc("any_peer", "call_local")
    func rematch():
    
  3. 现在,我们需要使用 rpc() 方法远程调用这些方法,而不是直接在 CheckersGame 中调用它们。让我们在 _on_checker_board_player_won() 中这样做,将 update_winner(winner) 转换为 rpc("update_winner", winner)。这是 CheckerBoard 的 player_won signals 连接到的方法:

    func _on_checker_board_player_won(winner):
         rpc("update_winner", winner)
    
  4. 最后,我们对 _on_rematch_button_pressed() 也进行同样的操作,将 rematch() 调用转换为 rpc("rematch")。这是 RematchButtonpressed 信号连接到的方法,因此当玩家按下按钮时,应该发生以下操作:

    func _on_rematch_button_pressed():
         rpc("rematch")
    

经过我们做出的调整,我们的游戏现在已经完全准备好,无论是本地还是远程玩都能流畅运行。当一名玩家成功捕获对手的棋子时,游戏将把所有玩家过渡到重赛状态,任何玩家都可以发起新的比赛并开始新的游戏。这确保了玩家可以选择参与连续的游戏会话,而无需手动退出和重新启动游戏。

图 6.10 – CheckersGame 重赛屏幕

图 6.10 – CheckersGame 重赛屏幕

我们的游戏终于可以工作了!我们有一个完全功能齐全的国际象棋游戏,玩家可以在线玩并挑战彼此进行多轮比赛。

摘要

总结一下,在本章中,我们介绍了 MultiplayerSynchronizer 节点以在网络中同步属性,建立了有效数据传输的抽象概念,利用 @rpc 注解启用多人游戏功能,并学习了如何分配和管理多人权限以确保玩家自主权和资源保护。

在下一章中,我们将看到如何开发在线乒乓球游戏。在那里,我们将介绍将本地游戏转换为在线多人游戏所需的修改,设置在线多人游戏拍子,实时同步远程对象,以及协调拍子的位置。为此,我们将使用比本章更深入的 MultiplayerSynchronizer 节点。此外,我们还将讨论在动作游戏中维护共享游戏世界对玩家的重要性,这与回合制游戏非常不同。

第七章:开发在线 Pong 游戏

是时候逐渐深入到制作在线多人游戏的更复杂方面了。在第六章“构建在线国际象棋游戏”中,我们看到了两位玩家如何共享同一个游戏世界,并看到他们的动作对其他玩家的游戏状态产生影响。这种情况发生在玩家轮流进行时,所以我们没有涉及到在线多人游戏中最麻烦的方面之一:时间。

在本章中,我们将开始处理动作游戏,这些游戏的核心特征是手眼协调和反应时间。我们将从一个最简单的基于物理学的游戏之一:Pong 的复制品开始。以基础项目为起点,我们将将其变成一个在线多人 Pong 游戏,其中每个玩家控制一个球拍,Godot 引擎的高级网络功能将负责在同一个游戏世界中保持玩家的同步。

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

  • 介绍 Pong 项目

  • 设置在线多人球拍

  • 同步远程对象

技术要求

对于本章,我们将使用我们的在线项目仓库,您可以通过以下链接找到:

github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

在 Godot 引擎中打开项目后,打开 res://07.developing-online-pong 文件夹;本章所需的一切都在那里。话虽如此,让我们首先了解我们的 Pong 项目是如何工作的,以及我们需要做什么才能将其变成一个在线多人游戏。正如前一章所述,我们也将使用 Godot 引擎版本 4.0,所以如果你有其他版本的引擎,请确保你使用的是正确的版本。

介绍 Pong 项目

欢迎来到我们虚构的独立游戏开发工作室的另一个项目,网络工程师!这次,我们需要为我们的下一个项目进行入职。

我们有一个 Pong 游戏,我们认为我们可以通过一些排行榜和所有这些酷炫的功能将其变成一个具有竞争力的在线多人游戏。您在这里的核心任务是使游戏的核心功能可以通过网络由两名玩家进行游戏。让我们了解我们目前拥有的内容,以便指出您将要修改的内容。

玩家球拍的工作原理

玩家的球拍是我们项目中最重要的部分。它们是玩家唯一可以控制的东西,因此它们是玩家与游戏互动的主要方式。通过移动它们,玩家可以将球反弹给对方。

让我们简要地看一下 res://07.developing-online-pong/Paddle.tscn 场景。其场景树结构如下:

图 7.1 – 球拍场景的节点层次结构

图 7.1 – 球拍场景的节点层次结构

注意,球拍本身是一个Node2D节点,而实际的物理体是其子节点。这是一种很好的抽象游戏实体的方式。它们有一个物理体,但它们并不是物理体。这允许我们在更高级别的抽象中更好地理解它们。现在,让我们看看它的脚本:

extends Node2D
@export var speed = 500.0
@export var up_action = "move_up"
@export var down_action = "move_down"
@onready var body = $CharacterBody2D
func _physics_process(delta):
     body.move_and_slide()
func _unhandled_input(event):
     if event.is_action_pressed(up_action):
          body.velocity.y = -speed
     elif event.is_action_released(up_action):
          if Input.is_action_pressed(down_action):
               body.velocity.y = speed
          else:
               body.velocity.y = 0.0
     if event.is_action_pressed(down_action):
          body.velocity.y = speed
     elif event.is_action_released(down_action):
          if Input.is_action_pressed(up_action):
               body.velocity.y = -speed
          else:
               body.velocity.y = 0.0

这段代码允许根据用户输入移动球拍,speed变量决定了移动速度,而up_actiondown_action变量代表了移动球拍的输入动作。脚本处理输入事件并相应地调整角色的速度。球拍以恒定速度移动,或者在没有按键按下时停止。

在下一节中,让我们看看球是如何工作的。它是我们游戏中另一个核心对象,我们需要对其进行一些工作,以便将其变成一个在线多人游戏。

理解 Ball 场景

球是游戏中的被动元素,当它撞击另一个物理体时(无论是球拍、天花板还是地板),它本质上会弹跳。后两者是StaticBody2D,它们在CollisionShape2D中使用WorldBoundaryShape2D

让我们看看res://07.developing-online-pong/Ball.tscn场景。场景结构如下:

图 7.2 – Ball 场景的节点层次结构

图 7.2 – Ball 场景的节点层次结构

在我们打开Ball脚本之前,请注意,CollisionShape2D资源附有一个内置的工具脚本。这是一个非常简单的脚本,它使用CanvasItem.draw_circle()方法绘制CircleShape2D资源。这是其背后的逻辑:

@tool
extends CollisionShape2D
@export var color = Color.WHITE
func _draw():
     draw_circle(Vector2.ZERO, shape.radius, color)

话虽如此,让我们打开Ball脚本,看看它是如何工作的,特别注意弹跳逻辑,因为它使用了一些有趣的Vector2方法:

extends Node2D
@export var speed = 600.0
@onready var body = $CharacterBody2D
func move():
     body.velocity.x = [-speed, speed][randi()%2]
     body.velocity.y = [-speed, speed][randi()%2]
func reset():
     body.global_position = global_position
     move()
func _physics_process(delta):
     var collision = body.move_and_collide
         (body.velocity  delta)
     if collision:
          body.velocity = body.velocity.bounce
              (collision.get_normal())

这段代码以指定速度移动球的CharacterBody2D节点,随机化其运动方向,检测与其他对象的碰撞,并在碰撞时使角色从表面上弹起。通过使用此脚本,球可以进行动态和响应式的运动,并具有碰撞检测功能。

在下一节中,我们将了解如何检测玩家得分的情况。

管理玩家得分

当球到达屏幕的左侧或右侧时,另一侧的玩家应该得分。为了检测这种条件,我们使用了一个ScoreArea节点,本质上它是一个Area2D节点。

打开res://07.developing-online-pong/ScoreArea.tscn场景,我们将查看其场景树结构:

图 7.3 – ScoreArea 场景的节点层次结构

图 7.3 – ScoreArea 场景的节点层次结构

如前所述,它只是一个Area2D节点;它甚至没有CollisionShape2D资源,因为在这种情况中,在最终场景中添加它更为友好,这样我们就可以为每个ScoreArea节点选择一个特定的Shape2D资源。现在,让我们看看它的代码:

extends Area2D
signal scored(score)
@export var score = 0
func _on_body_entered(body):
     score += 1
     scored.emit(score)

此代码提供了一个简单的方法,使用 Area2D 节点在游戏中跟踪得分。每当另一个物理体(在这种情况下,是球)进入区域时,它会发出得分信号,并将得分增加一分。通过连接到这个信号,其他游戏对象可以响应得分更新并执行相关操作。我们稍后在 PongGame 类中使用此信号。

为了确保 ScoreArea 节点只检测球,我们使用了 碰撞层碰撞掩码。在球上,这些属性看起来是这样的:

图 7.4 – 球碰撞层和碰撞掩码属性

图 7.4 – 球碰撞层和碰撞掩码属性

球位于第二个物理层,但它遮蔽了第一个层。这样做是为了检测与挡板、地板和天花板的碰撞。它只需要位于第二个物理层,因为 ScoreArea 节点只遮蔽第二个层。我们这样做是为了防止 ScoreArea 节点检测到任何其他物理体,例如地板或天花板:

图 7.5 – ScoreArea 碰撞层和碰撞掩码属性

图 7.5 – ScoreArea 碰撞层和碰撞掩码属性

这样我们就确保了 ScoreArea 节点只会与球交互。在下一节中,我们将看到如何使用 ScoreArea 节点发出的这个信号来实际更新显示的得分,以及 PongGame 类背后的整体逻辑。

将一切联系在一起

你可能已经注意到,这些类独立运行,没有任何耦合,这意味着它们不能单独形成一个完整的系统。将所有内容整合成一个连贯系统的责任落在 PongGame 类上。

让我们先看看它的场景树结构,这样我们就可以了解所有内容将如何交互。打开 res://07.developing-online-pong/PongGame.tscn 场景,并注意 场景 选项卡:

图 7.6 – PongGame 场景的节点层次结构

图 7.6 – PongGame 场景的节点层次结构

到目前为止,你已经对这些节点有了大致的了解。让我们花一点时间来理解 ScoreLabel 节点的作用。它本质上只是在屏幕上显示每个玩家得分的文本。为此,它使用一个根据 ScoreArea.scored 信号(一个整数)转换为字符串的方法来改变其文本属性。整个 ScoreLabel 节点的代码如下:

extends Label
func update_score(new_score):
     text = "%s" % new_score

在这个前提下,让我们进入 PongGame 代码:

extends Node2D
@export var speed = 600.0
@onready var body = $CharacterBody2D
func move():
     body.velocity.x = [-speed, speed][randi()%2]
     body.velocity.y = [-speed, speed][randi()%2]
func reset():
     body.global_position = global_position
     move()
func _physics_process(delta):
     var collision = body.move_and_collide
         (body.velocity  delta)
     if collision:
          body.velocity = body.velocity.bounce
              (collision.get_normal())

此代码跟踪得分,当玩家达到目标得分时显示获胜者,并且当任一玩家按下 WinnerDisplay 节点接口时允许游戏重新开始。为了视觉参考,这是 WinnerDisplay 节点开启时的样子:

图 7.7 – 显示比赛获胜者的 WinnerDisplay 叠加层

图 7.7 – 显示比赛获胜者的 WinnerDisplay 叠加层

它还通过随机化球体的运动并开始其初始运动来初始化游戏。此外,当玩家得分时,它通过重新居中球体并再次开始其运动来重置球体。

在本节中,我们回顾了我们 Pong 游戏中的所有核心类。它们目前旨在支持本地多人游戏,因此我们需要修改它们以支持在线多人游戏。

在下一节中,我们将进行必要的工作,将我们的游戏转变为一个远程可玩的 Pong 游戏,其中两名玩家通过各自的拍子相互交互,以便他们可以一起竞争!

设置在线多人游戏拍子

是时候开始你的实际工作了。在理解整个项目之后,让我们做必要的工作,让玩家能够在线玩游戏!

第六章《构建在线国际象棋游戏》中,我们看到了通过更改SceneTree分支的多人游戏权限,新的对等方可以接管对该节点分支所做的更改。这就是我们让白色队伍的玩家无法移动黑色队伍的棋子,反之亦然的方法。

能够动态改变多人游戏权限是我们需要培养的核心技能,以保持玩家共享世界的连贯性。在我们提到的情境中,玩家轮流进行,每个玩家执行一个动作,然后由对方玩家控制他们的棋子。另一方面,在本章中,玩家必须同时移动,因为这是一款动作游戏。

在接下来的章节中,我们将实现一个简单的方法,为每个玩家提供一个可以玩耍的拍子。

更改拍子的所有者

在我们的拍子实现中,我们有一个小问题需要解决。两个拍子都在_physics_process()回调中调用CharacterBody2D.move_and_slide(),同时在_unhandled_input()回调中检查InputEvent。这使得如果其他玩家移动他们的拍子,移动可能会在对手的游戏中被覆盖。因此,除了重新分配拍子的多人游戏权限外,我们还需要禁用对手的拍子回调。打开res://07.developing-online-pong/Paddle.gd并让我们来做这件事!按照以下步骤操作:

  1. 创建一个名为setup_multiplayer()的方法,并包含player_id参数,它代表玩家的网络标识符:

    func setup_multiplayer(player_id):
    
  2. 使用@rpc注解装饰setup_multiplayer()函数并使用call_local选项。这将确保只有服务器可以调用此方法,并且它还会在服务器的端点上本地调用它:

    @rpc("call_local")
    func setup_multiplayer(player_id):
    
  3. 在函数内部,调用set_multiplayer_authority()方法并传递player_id参数。这样,我们就设置了拍子的新多人授权。现在,如果player_id参数不匹配其多人授权 ID,我们需要防止其移动。我们这样做是因为这个 RPC 函数将在所有对等方上调用,所以对手的拍子应该运行以下代码块:

    @rpc("call_local")
    func setup_multiplayer(player_id):
         set_multiplayer_authority(player_id)
    
  4. 使用is_multiplayer_authority()方法检查当前拍子的对等方 ID 是否与多人授权的对等方 ID 不匹配:

         if not is_multiplayer_authority():
    
  5. 如果是这样,调用set_physics_process()函数并传递false参数以禁用物理处理:

         if not is_multiplayer_authority():
              set_physics_process(false)
    
  6. 同样,调用set_process_unhandled_input()函数并传递false以禁用在此拍子上处理未处理输入事件:

    if not is_multiplayer_authority():
              set_physics_process(false)
              set_process_unhandled_input(false)
    
  7. 最后,整个setup_multiplayer()方法应该看起来像这样:

    @rpc("call_local")
    func setup_multiplayer(player_id):
         set_multiplayer_authority(player_id)
         if not is_multiplayer_authority():
              set_physics_process(false)
              set_process_unhandled_input(false)
    

此代码通过将拍子的多人权限分配给指定的玩家来设置多人功能。然后根据当前实例是否是授权对等方来调整脚本的行为。如果实例不是授权对等方,它将禁用物理和未处理输入处理,以确保只有授权玩家在此实例中执行这些操作。

在下一节中,让我们了解我们如何收集并将玩家的 ID 分配给每个相应的拍子。

分配玩家的拍子

现在既然每个拍子都可以有自己的多人权限,并为每个玩家拥有独立的物理和输入处理过程,是时候了解我们如何将每个玩家分配到他们各自的拍子上了。为了做到这一点,让我们打开res://07.developing-online-pong/PongGame.gd脚本,并在其_ready()函数中创建必要的逻辑:

  1. 首先,包含await关键字后跟get_tree().create_timer(0.1).timeout表达式。这会创建一个 0.1 秒的延迟并等待其timeout信号发出。这很重要,因为我们将要使用 RPC 调用远程节点上的函数,而这些节点可能不会在游戏执行此代码时准备好,因此它会在执行其行为之前等待一小段时间:

    func _ready():
         randomize()
         await(get_tree().create_timer(0.1).timeout)
         ball.move()
    
  2. 然后,使用multiplayer.get_peers().size()方法检查连接的对等方的大小是否大于0。这将确保以下行为仅在存在对等方时发生;否则,游戏将像本地一样运行:

         await(get_tree().create_timer(0.1).timeout)
         if multiplayer.get_peers().size() > 0:
    
  3. 如果是这样,使用is_multiplayer_authority()检查当前实例是否是当前多人授权。这确保只有服务器将执行玩家分配:

         await(get_tree().create_timer(0.1).timeout)
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
    
  4. 在这个条件内部,将第一个连接的对等方分配给player_1变量。这将在此变量中存储第一个玩家的 ID:

         await(get_tree().create_timer(0.1).timeout)
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
                   var player_1 = multiplayer.get_peers
                       ()[0]
    
  5. 然后,将第二个连接的对等方分配给player_2变量。这将在此变量中存储第二个玩家的 ID:

         await(get_tree().create_timer(0.1).timeout)
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
                   var player_1 = multiplayer.get_peers
                       ()[0]
                   var player_2 = multiplayer.get_peers()
                       [1]
    
  6. 然后,让我们使用rpc()方法在player_1_paddleplayer_2_paddle节点上远程调用setup_multiplayer方法,传递它们各自的player变量:

    if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
                   var player_1 = multiplayer.get_peers()
                       [0]
                   var player_2 = multiplayer.get_peers()
                       [1]
                   player_1_paddle.rpc
                       ("setup_multiplayer", player_1)
                   player_2_paddle.rpc
                       ("setup_multiplayer", player_2)
    
  7. 在这些更改之后,整个PongGame._ready()回调应如下所示:

    func _ready():
         randomize()
         await(get_tree().create_timer(0.1).timeout)
         if multiplayer.get_peers().size() > 0:
              if is_multiplayer_authority():
                   var player_1 = multiplayer.get_peers()
                       [0]
                   var player_2 = multiplayer.get_peers()
                       [1]
                   player_1_paddle.rpc
                       ("setup_multiplayer", player_1)
                   player_2_paddle.rpc
                       ("setup_multiplayer", player_2)
         ball.move()
    

此代码演示了异步编程和多玩家设置。它首先随机化随机数生成器,然后引入 0.1 秒的延迟。它检查是否有连接的对等体以及当前实例是否是多玩家权限者——换句话说,是服务器。如果这些条件得到满足,它将连接的对等体分配给变量,并使用 RPC 调用Paddle.setup_multiplayer()方法,传递相应的对等体信息。最后,它移动ball对象。

为了正确设置谁控制它,我们向Paddle.setup_multiplayer()提供所需的数据——具体来说,是玩家 ID。然而,当每个玩家只能控制他们自己的桨时,会出现一个小问题。玩家将如何更新对手的桨位置?此外,谁应该控制球,以及如何在两个玩家的游戏实例中更新其位置?这些问题将在下一节中解决。

同步远程对象

由于每个玩家都在他们自己的游戏实例中控制他们各自的桨,我们遇到了一个小问题。对手的桨不会更新其移动,因为我们确保在分配新的多玩家权限后,其物理和输入过程都被禁用了。因此,球也可能在对手的桨上弹跳,并在每个玩家的游戏实例中创建不同的运动轨迹。我们需要确保玩家共享相同的游戏世界,为此,我们将使用MultiplayerSynchronizer在网络中同步对象。在下一节中,我们将开始同步球。

更新球的位置

我们首先要确保球的位置在所有对等体之间同步。这是因为我们希望防止玩家在他们的游戏实例中处理不同的球,因为这可能导致他们根据错误的信息做出决策。例如,一个玩家可能朝着在他们自己的游戏实例中朝天花板移动的球移动,而在服务器的游戏实例中,球实际上是在朝地板移动。让我们打开res://07.developing-online-pong/Ball.tscn并开始简单的流程:

  1. MultiplayerSynchronizer作为Ball节点的子节点添加。我们将使用此节点的功能来确保所有对等体都更新球的CharacterBody2D位置:

图 7.8 – 添加了 MultiplayerSynchronizer 节点的 Ball 场景的节点层次结构

图 7.8 – 添加了 MultiplayerSynchronizer 节点的 Ball 场景的节点层次结构

  1. 然后,使用CharacterBody2D:position属性在连接的对等体之间复制:

图 7.9 – 球体的 CharacterBody2D 位置属性在 MultiplayerSynchronizer 复制菜单中

图 7.9 – 球体的 CharacterBody2D 位置属性在 MultiplayerSynchronizer 复制菜单中

  1. 最后,由于我们在这里使用物理体,我们需要确保 MultiplayerSynchronizer 更新到本地的 Physics 更新,确保游戏在更新 Paddle 实例时会考虑任何碰撞和其他物理模拟:

图 7.10 – 球体的 MultiplayerSynchronizer 的 Visibility Update 属性设置为 Physics

图 7.10 – 球体的 MultiplayerSynchronizer 的 Visibility Update 属性设置为 Physics

  1. 为了防止任何玩家实例中球体位置的覆盖,让我们打开球体的脚本,并在其 _ready() 回调中添加一个代码片段,表示如果这个节点不是多人权限,它将禁用球体的 _physics_process() 回调。这样,只有服务器才有权限实际计算球体的运动和最终位置,而玩家只需在他们的游戏实例中复制这些信息:

    func _ready():
         if not is_multiplayer_authority():
              set_physics_process(false)
    

这样,球体的运动应该在整个连接的节点之间保持一致,防止他们根据其他节点看到的不同对象做出决策。这会破坏游戏体验,因为最终,玩家会在一个不同的游戏世界中玩游戏,他们的动作对其他节点来说没有意义。在下一节中,让我们对 Paddle 对象执行相同的过程;当然,在这个例子中,我们不需要禁用 _physics_process(),因为我们是在设置其多人权限时做的。

协调 paddle 的位置

最后,是时候同步玩家们的 paddle 位置了,这样他们就能看到对手的动作,并保持一致。让我们打开 res://07.developing-online-pong/Paddle.tscn 场景并开始工作:

  1. 将一个新的 MultiplayerSynchronizer 作为 Paddle 子项添加:

图 7.11 – 添加了新的 MultiplayerSynchronizer 节点的 Paddle 场景的节点层次结构

图 7.11 – 添加了新的 MultiplayerSynchronizer 节点的 Paddle 场景的节点层次结构

  1. CharacterBody2D:position 属性中跨连接节点进行复制:

图 7.12 – MultiplayerSynchronizer 复制菜单中的 Paddle CharacterBody2D 位置属性

图 7.12 – MultiplayerSynchronizer 复制菜单中的 Paddle CharacterBody2D 位置属性

  1. 就像在 Ball 场景案例中一样,我们在这里也使用了一个物理体,因此将 Visibility Update 属性更改为在 Physics 过程中更新:

图 7.13 – Paddle MultiplayerSynchronizer Visibility Update 属性设置为 Physics

图 7.13 – 橡皮球多玩家同步器的可见性更新属性设置为物理

通过这种实现,每个对手的橡皮球都将拥有其CharacterBody2D位置在游戏中的所有节点之间同步。这导致了一个共享的游戏世界,玩家可以在一个公平的环境中竞争的同时一起享受游戏。

摘要

在本章中,我们学习了 Godot 引擎高级网络 API 如何提供快速简便的解决方案来分配游戏对象的正确“所有者”并在网络中同步其状态。这确保了玩家在一个共享的环境中玩游戏,另一边是一个真实的人类对手。

我们学习了如何检查当前游戏实例是否是多玩家权限,并使其相应地执行正确的行为。我们还学习了如何在SceneTree上的节点层次结构中更改多玩家权限,确保只有指定的玩家可以对此节点及其子节点进行创建和同步更改。为了同步更改,我们使用了具有物理模式的可见性更新MultiplayerSynchronizer,以确保游戏对象的物理交互在网络所有节点之间同步。

在接下来的章节中,我们将通过创建一个两个或更多玩家可以一起玩并随意探索游戏世界的平台游戏来加强我们对在线多人游戏的了解。我们相信这将是我们游戏开发技能的一个令人兴奋的补充。

第八章:创建在线合作平台游戏原型

在本章中,我们将深入探讨创建动作多人在线游戏的工作。我们的目标是把一个本地多人拼图平台游戏原型转变为在线版本。

这就是最终拼图平台游戏原型的样子:

图 8.1 – 拼图平台游戏原型的预览

图 8.1 – 拼图平台游戏原型的预览

到本章结束时,您将了解如何使用MultiplayerSpawner节点的功能来创建和分配可玩角色给游戏中的每个玩家,并使用MultiplayerSynchronizer提供的功能同步相关属性。有了这些功能,我们不仅可以更新节点的位置;它们还将允许我们同步其他属性,尤其是动画。您还将学习如何利用远程过程调用(RPCs)来操纵节点的多人权限。这将使我们能够实现一个令人兴奋的对象抓取机制,这将是我们的原型中的关键元素。

技术要求

要访问本章的资源,您可以点击此处提供的链接找到我们的在线项目仓库:github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

一旦您有了仓库,请打开 Godot 引擎编辑器中的res://08.designing-online-platformer文件夹。本章所需的所有文件都位于那里。

现在,让我们开始入职流程。在接下来的部分,我们将熟悉项目,探索其主要类,并确定我们需要实现网络功能的地方。

介绍平台游戏项目

我们的项目展示了一个引人入胜的拼图平台游戏,玩家在克服挑战性障碍的过程中将测试他们的战略思维和协作技能。这个游戏的核心机制围绕着对象的操控,利用它们为其他玩家构建可以穿越的平台。

现在,让我们深入了解构成我们项目基础的基石类。我们的第一次接触将是Player类,它代表了每个玩家控制的化身。作为主要的主角,Player类处理诸如移动和与各种环境元素交互等基本功能。值得注意的是,Player类集成了InteractionArea2D,它可以检测与InteractiveArea2D的接触,使玩家能够在它们上执行特定操作。

接下来,我们遇到了 InteractiveArea2D 类。这个类扩展了 Area2D 节点的功能,并承担了检测交互的关键触发区域角色。当 InteractionArea2DInteractiveArea2D 重叠时,它会对输入事件做出响应。触发指定的 interact 输入动作会发出信号,使我们能够创建更吸引人的游戏交互。

在我们的游戏中,Crate 类代表一个玩家可以熟练操作的交互式对象。每个 Crate 实例都有一个 InteractiveArea2D 节点和 CharacterBody2D 节点,为玩家提供了与它们碰撞和跳跃的机会,这使得玩家可以将它们作为可行的平台来导航关卡。这些箱子是解决谜题和通过游戏关卡的主要元素。

最后,我们遇到了多才多艺的 PlayerSpawner 类,它负责在游戏中动态生成和管理玩家。这个类能够灵活地适应参与玩家的数量,为每个玩家无缝地实例化一个 Player 实例。此外,在本地多人游戏中,PlayerSpawner 类通过为每个玩家设置独特的控制并优化游戏定制来确保流畅和沉浸式的游戏体验。

在接下来的部分,我们将深入研究 Player 对象,它由脚本和场景组成。我们将了解脚本如何与场景上的可用节点协同工作,并构建我们本地 Player 节点的期望行为。

理解 Player 类

Player 类和场景代表了游戏中的玩家角色。玩家正是通过这个场景和脚本与游戏世界进行交互。场景是一个带有 CollisionShape2D 资源的 CharacterBody2D 节点,一个名为 SpritesNode2D 节点,我们用它来分组和旋转一个 AnimatedSprite2D 节点,以及一个 InteractionArea2D 节点,我们将在 InteractiveArea2D 类的工作原理 部分进行讨论。InteractionArea2D 节点也包含一个 CollisionShape2D 资源和一个 RemoteTransformer2D 节点,我们称之为 GrabberRemoteTransformer2D。一个 RemoteTransformer2D 节点允许我们远程同步一个节点(它位于 RemoteTransformer2D 父节点的层次结构之外)的位置、旋转和缩放,就像它是 RemoteTransformer2D 的兄弟节点一样,这非常有用。在这种情况下,我们使用 GrabberRemoteTransformer2D 节点来远程变换玩家可以抓取的对象,例如 箱子,我们将在 揭开 Crate 类的面纱 部分进行讨论。最后,Player 类还有一个 Label 节点,我们用它来视觉上传达控制角色的玩家。

图 8.2 – 玩家的场景节点层次结构

图 8.2 – 玩家的场景节点层次结构

现在,让我们看看脚本本身。在本节中,我们不会深入探讨一些方面,因为它们与基本平台游戏玩家移动更相关,而我们在这里的重点是网络多人游戏方面,所以我们将超出我们的范围。但对你工作来说重要的是要知道,当两个玩家在本地玩游戏时,Player节点可以动态设置其控制器,以便每个玩家只控制一个角色。在原型网络版本中,你必须实现这一点:每个玩家将只控制自己的角色。为了参考,以下代码片段在本地执行此操作:

func setup_controller(index):
    for action in InputMap.get_actions():
        var new_action = action + "%s" % index
        InputMap.add_action(new_action)
        for event in InputMap.action_get_events(action):
            var new_event = event.duplicate()
            new_event.device = index
            InputMap.action_add_event(new_action, event)
        for property in get_property_list():
            if not typeof(get(property.name)) ==
                TYPE_STRING:
                continue
            if get(property.name) == action:
                set(property.name, new_action)

之前的代码遍历InputMap单例中的操作,并使用索引创建针对给定控制器设备的新操作。它还更新与操作相关的事件和属性,以确保它们针对特定设备。此代码的目的是为游戏中的不同玩家或设备设置控制器映射,允许自定义和区分输入控制。

在下一节中,让我们看看Crate场景是如何工作的,这是一个相当简单的场景,本质上作为一个被动对象,玩家可以用它作为移动的平台。

揭示Crate

Crate场景在我们的游戏原型中起着至关重要的作用。它代表玩家可以熟练操作的交互式对象,以克服障碍并通过关卡。Crate场景的每个实例都配备了两个重要的组件:InteractiveArea2D节点和CharacterBody2D节点。

图 8.3 – *Crate*场景节点层次结构

图 8.3 – Crate场景节点层次结构

CharacterBody2D节点代表游戏物理模拟中Crate节点的物理身体。它确保箱子与玩家的角色或其他对象发生碰撞。CharacterBody2D节点处理碰撞检测和响应,使玩家能够无缝地跳上并站在箱子上,就像它是一个坚固的平台。

至于InteractiveArea2D节点,它是一个特殊的Area2D节点,用于检测当InteractiveArea2D节点与其重叠时。在Crate类的上下文中,InteractiveArea2D节点允许玩家在按下interact动作且其InteractionArea2D节点与CrateInteractionArea2D节点重叠时抓住并举起Crate节点。这种交互使玩家能够将Crate节点作为坚固的平台来导航关卡,因为他们将能够移动它们并在其他玩家抓住它们时跳上它们。InteractiveArea2D节点充当触发器,检测玩家的角色与Crate节点接触时,并将Crate节点分配给玩家的角色GrabbingRemoteTransform2D节点,即使他们在移动时也会同步其位置。

Crate脚本相当简单,它定义了Crate节点如何响应和更新与Player节点的交互:

extends Node2D
@onready var body = $CharacterBody2D
@onready var shape = $CharacterBody2D/CollisionShape2D
@onready var interactive_area = $CharacterBody2D/
    InteractiveArea2D
var lift_transformer = null
func _on_interactive_area_2d_area_entered(area):
    lift_transformer = area.get_node
        ("GrabbingRemoteTransform2D")
func _on_interactive_area_2d_interacted():
    lift_transformer.remote_path =
        lift_transformer.get_path_to(body)

上述代码设置了Crate场景层次结构中节点的引用。它还定义了两个回调函数,用于处理来自Crate节点InteractiveArea2D节点的信号。当一个InteractionArea2D节点进入Crate节点的InteractiveArea2D节点时,我们假设它是Player节点进行交互,并检索Player节点的"GrabbingRemoteTransform2D"节点,将其分配给lift_transformer变量。

当发生交互时,代码将lift_transformer.remote_path节点分配给从lift_transformer变量到Crate节点身体的路径。记住,lift_transformer变量是一个RemoteTransform2D节点。这就是我们允许Player节点的GrabbingRemoteTransform2D节点远程变换Crate节点的CharacterBody2D节点位置的方式。

在下一节中,我们将了解InteractiveArea2D节点如何检测玩家与Crate节点的交互以及它在我们的游戏中的作用。

InteractiveArea2D 类的工作原理

在本节中,我们将了解我们游戏机制核心中的一个主要场景的作用。这个场景被称为InteractiveArea2D节点,它在检测和启用玩家与游戏环境中的各种对象交互方面发挥着基本作用。InteractiveArea2D节点使我们能够将任何对象转换为玩家可以与之交互的对象。例如,在我们的原型中,我们使用InteractiveArea2D节点来允许玩家抓住Crate节点并将其移动。

基于Area2D节点构建的InteractiveArea2D场景是我们游戏中的基本组件。其主要功能是检测和简化玩家与对象的交互,尤其是在玩家与箱子交互机制中。通过使用信号和输入处理,InteractiveArea2D场景确保了流畅的游戏交互。

图 8.4 – InteractiveArea2D 场景节点层次结构

图 8.4 – InteractiveArea2D 场景节点层次结构

我们游戏的一个突出特点是玩家与Crate节点交互机制,它为玩家提供了操作交互对象的能力。InteractiveArea2D场景是这个交互的催化剂,它作为玩家可以与之互动的游戏世界中对象的门户。

通过使用信号,InteractiveArea2D场景与其他游戏对象和系统建立了通信渠道。每当玩家成功与一个对象交互时,InteractiveArea2D节点会发出interacted信号。除此之外,场景还会发出信号以指示交互的可用性或不可用性,使我们能够向玩家提供视觉和听觉反馈。

为了检测玩家输入,InteractiveArea2D 场景使用 _unhandled_input 回调。当玩家按下指定的 interact 输入动作时,它触发 interacted 信号,表示发生了交互。这种控制方案允许玩家与游戏世界进行交互。

理解 InteractiveArea2D 场景的作用以及它与玩家-Crate 节点交互系统的无缝集成是关键。现在,是时候深入代码,发挥这个关键场景在我们游戏中的全部潜力了:

class_name InteractiveArea2D
extends Area2D
signal interacted
signal interaction_available
signal interaction_unavailable
@export var interact_input_action = "interact"
func _ready():
    set_process_unhandled_input(false)
func _unhandled_input(event):
    if event.is_action_pressed(interact_input_action):
        interacted.emit()
        get_viewport().set_input_as_handled()
func _on_area_entered(_area):
    set_process_unhandled_input(true)
    interaction_available.emit()
func _on_area_exited(_area):
    set_process_unhandled_input(false)
    interaction_unavailable.emit()

InteractiveArea2D 脚本扩展了 Area2D 节点,并提供了交互功能。当发生交互时,当交互变得可用时,以及当交互变得不可用时,它会发出信号。它还处理未处理的输入事件以触发交互。

在下一节和入门的最后部分,我们将看到如何根据有多少玩家在玩,动态地在游戏世界中创建和插入 Player 实例。

理解 PlayerSpawner 类

PlayerSpawner 场景是我们游戏中另一个关键组件,负责创建和定位 Player 实例。基于 Marker2D 节点的 PlayerSpawner 类遵循 Spawner 模式,使我们能够在游戏世界中动态生成 Player 实例。

图 8.5 – PlayerSpawner 的场景节点层次结构

图 8.5 – PlayerSpawner 的场景节点层次结构

PlayerSpawner 类的一个关键特性是其能够定位生成的 Player 实例。作为一个 Marker2D 节点,PlayerSpawner 节点提供了一个方便的方式来指定玩家在游戏世界中的位置和朝向。这确保了每个玩家都能从适当的位置开始,准备好开始他们的冒险。

让我们看看它的代码,以了解这个类在底层做了什么:

extends Marker2D
@export var players_scene = preload("res://08.designing-
    online-platformer/Actors/Player/Player2D.tscn")
func _ready():
    if Input.get_connected_joypads().size() < 1:
        var player = players_scene.instantiate()
        add_child(player)
        return
    for i in Input.get_connected_joypads():
        var player = players_scene.instantiate()
        add_child(player)
        player.setup_controller(i)

上述脚本展示了基于 Marker2D 节点的 Spawner 类的实现。它检查连接的控制器,并根据连接的控制器创建相应的 Player 场景实例。如果没有连接的控制器,它创建一个单独的实例。如果有连接的控制器,它为每个控制器创建一个 Player 实例并设置它们各自的控制。上述代码片段允许在多人游戏中动态创建 Player 实例,简化了我们开发多人游戏体验的工作。

我们终于完成了我们的入门;在下一节中,我们将开始实现我们的在线多人游戏功能,将我们的本地原型转变为我们可以安全工作并打磨的产品,因为我们知道它已经准备好与远程多人游戏功能一起发布。

在比赛中生成玩家

在本节中,我们将了解如何改进 PlayerSpawner 类,以将在线多人功能引入我们的游戏。利用 理解 PlayerSpawner 类揭示 Crate 类理解 Player 类 等章节所奠定的基础,这些增强功能使多个玩家能够无缝连接并在同步的游戏环境中交互。

PlayerSpawner 节点在我们游戏的多玩家架构中扮演着基本角色,作为负责为每个连接的玩家动态创建 Player 类实例的核心机制。这些实例代表玩家通过它们与游戏世界进行交互的化身。

通过集成多人功能,我们将添加专为在线多人体验设计的功能。这包括处理多人权限的机制,确保所有连接节点上的游戏玩法正确。在此基础上,代码将使用节点 ID 建立独特的玩家名称,使我们能够轻松识别网络上的玩家。为了确保同步动作,我们将使用 RPC,这将允许我们在所有连接的玩家之间共享事件和动作,特别是其他玩家的实例化。

我们在这里将要介绍的一个基本概念是 MultiplayerSpawner 节点。在 Godot Engine 4 高级网络 API 中,MultiplayerSpawner 节点是在网络化多人设置中创建同步场景的无价资产。在我们的上下文中,它是同步创建玩家核心组件,确保每个玩家都能实时看到并与其他玩家的化身进行交互。

使用 MultiplayerSpawner 节点,我们可以轻松地在所有连接的游戏实例中实例化和定位玩家化身。因此,首先,让我们打开位于 res://08.designing-online-platformer/Levels/PlayerSpawner.tscnPlayerSpawner 场景,并将其作为子节点添加一个 MultiplayerSpawner 节点。

图 8.6 – PlayerSpawner 节点作为 PlayerSpawner 节点的子节点

图 8.6 – PlayerSpawner 节点作为 PlayerSpawner 节点的子节点

之后,我们需要配置 MultiplayerSpawner 节点的 PlayerSpawner 属性。这告诉 MultiplayerSpawner 应该将哪个场景作为生成场景的父级。然后,第二个属性应该指向 PlayerSpawner 节点生成的相同 PackedScene 资源。这将确保,当本地创建新实例时,MultiplayerSpawner 节点将在连接的节点上复制它。

图 8.7 – 配置 MultiplayerSpawner 的生成路径和自动生成列表属性

图 8.7 – 配置 MultiplayerSpawner 的生成路径和自动生成列表属性

这样,我们的MultiplayerSpawner节点就准备好在每个人的游戏实例上同步新玩家了。但我们仍然需要配置这些新实例,否则,只有服务器才能控制它们。所以,让我们看看我们如何赋予玩家控制自己化身的能力。打开PlayerSpawner脚本,位于res://08.designing-online-platformer/Levels/PlayerSpawner.gd。在下一节中,我们将对此脚本进行一些修改。

PlayerSpawner中赋予玩家控制权

新的PlayerSpawner代码引入了增强游戏多人功能性的更改。具体来说,此代码包括处理多人同步的机制,并在多个 Peer 连接时正确设置Player实例。这些更改包括检查多人权限、设置玩家名称和使用 RPC 为每个连接的玩家设置多人功能。让我们实现这些功能:

  1. _ready()回调的开始处添加await(get_tree().create_timer(0.1).timeout)。这一行使用计时器引入了 0.1 秒的延迟,为多人网络初始化完成留出了时间:

    func _ready():
        await(get_tree().create_timer(0.1).timeout)
    
  2. 然后,让我们检查是否有连接的 Peer,通过检查multiplayer.get_peers()数组的大小。有了这个,我们可以检查多人会话中是否有连接的 Peer。这个条件验证了这是否是一个本地游戏会话:

    func _ready():
        await(get_tree().create_timer(0.1).timeout)
        if multiplayer.get_peers().size() < 1:
    
  3. 如果是这样,我们使用在理解 Player 类部分看到的原始逻辑来设置本地玩家化身控制器。稍作修改,我们在最后使用return关键字来防止_ready()执行到下一步,这些步骤只有在是在线游戏会话时才是必要的:

    func _ready():
        await(get_tree().create_timer(0.1).timeout)
        if multiplayer.get_peers().size() < 1:
            if Input.get_connected_joypads().size() < 1:
                var player = players_scene.instantiate()
                add_child(player)
                return
            for i in Input.get_connected_joypads():
                var player = players_scene.instantiate()
                add_child(player)
                player.setup_controller(i)
            return
    
  4. 然后,如果这是一个在线游戏会话,我们检查这个游戏实例是否是多人权限(换句话说,是服务器),如果是的话,我们进入一个循环,遍历连接的 Peer:

        if is_multiplayer_authority():
                for i in range(0, multiplayer.get_peers().
                    size()):
    
  5. 与本地会话逻辑类似,我们为每个连接的玩家创建一个Player实例:

        if is_multiplayer_authority():
                for i in range(0, multiplayer.get_peers().
                    size()):
                    var player = players_scene.
                        instantiate()
    
  6. 这里有个关键点:在创建Player实例后,我们将其实例的名称设置为玩家的 Peer ID。只有在这个步骤之后,我们才将其添加为PlayerSpawner节点的子节点。这确保了每个Player实例都有一个唯一的名称,并将防止 RPC 和MultiplayerSpawner节点返回错误:

    if is_multiplayer_authority():
            for i in range(0, multiplayer.get_peers().
                size()):
                var player = players_scene.instantiate()
                var player_id = multiplayer.get_peers()[i]
                player.name = str(player_id)
                add_child(player)
    
  7. 然后,我们添加另一个0.1秒的计时器延迟。这个延迟给 Peer 的游戏实例同步多人设置留出了时间:

    if is_multiplayer_authority():
            for i in range(0, multiplayer.get_peers().
                size()):
                var player = players_scene.instantiate()
                var player_id = multiplayer.get_peers()[i]
                player.name = str(player_id)
                add_child(player)
                await(get_tree().create_timer(0.1).
                    timeout)
    
  8. 最后,我们通过传递player_id作为参数调用 RPC 到Player.setup_multiplayer()方法。Player.setup_multiplayer()负责根据玩家 ID 配置玩家的多人权限,最终允许这个玩家,而且只有这个玩家,控制这个实例。我们将在设置玩家多人控制部分实现此方法:

    if is_multiplayer_authority():
            for i in range(0, multiplayer.get_peers().
                size()):
                var player = players_scene.instantiate()
                var player_id = multiplayer.get_peers()[i]
                player.name = str(player_id)
                add_child(player)
                await(get_tree().create_timer(0.1).
                    timeout)
                player.rpc("setup_multiplayer", player_id)
    

我们还没有完成。我们还需要在MultiplayerSpawner节点创建其他玩家的化身实例时设置多人游戏功能。为此,让我们使用名为_on_multiplayer_spawner_spawned的方法将MultiplayerSpawner节点的spawned信号连接到PlayerSpawner节点。

图 8.8 – MultiplayerSpawner 产生的信号连接到 PlayerSpawner 的 _on_multiplayer_spawner_spawned 回调

图 8.8 – MultiplayerSpawner 产生的信号连接到 PlayerSpawner 的 _on_multiplayer_spawner_spawned 回调

然后,我们使用节点名称作为参数在产生的节点的setup_multiplayer方法上执行 RPC。由于名称是一个StringName变量,我们需要将其转换为字符串,然后再将其转换为整数,以便Player类可以处理它。在做出这些更改后,完整的PlayerSpawner脚本应如下所示:

extends Marker2D
@export var players_scene = preload("res://08.designing-
    online-platformer/Actors/Player/Player2D.tscn")
func _ready():
    await(get_tree().create_timer(0.1).timeout)
    if multiplayer.get_peers().size() < 1:
        if Input.get_connected_joypads().size() < 1:
            var player = players_scene.instantiate()
            add_child(player)
            return
        for i in Input.get_connected_joypads():
            var player = players_scene.instantiate()
            add_child(player)
            player.setup_controller(i)
        return
    if is_multiplayer_authority():
        for i in range(0, multiplayer.get_peers().size()):
            var player = players_scene.instantiate()
            var player_id = multiplayer.get_peers()[i]
            player.name = str(player_id)
            add_child(player)
            await(get_tree().create_timer(0.1).timeout)
            player.rpc("setup_multiplayer", player_id)
func _on_multiplayer_spawner_spawned(node):
    node.rpc("setup_multiplayer", int(str(node.name)))

更新的脚本通过为网络中的每个玩家创建Player实例来集成多人游戏功能。它检查连接的摇杆和多人游戏对等体的存在,以确定需要创建的Player实例的数量。代码还设置了Player实例的控制并同步它们的多人游戏设置。这些更改使得PlayerSpawner节点现在可以启用多人游戏,允许多个玩家同时控制他们的化身并在游戏世界中交互,没有任何控制冲突。

在接下来的部分中,我们将探讨Player.setup_multiplayer()方法的实现,该方法负责配置Player类的在线多人游戏设置。在setup_multiplayer()方法中,我们设置多人游戏权限,根据本地玩家对实例的权限禁用物理和输入处理,并设置一个视觉玩家索引标签,该标签会更新控制实例的玩家。

设置玩家多人游戏控制

在本节中,我们将探讨如何实现Player.setup_multiplayer()方法,该方法在设置Player类的在线多人游戏控制中起着核心作用。

setup_multiplayer()方法中,我们需要采取一些关键步骤来实现我们的在线多人游戏控制。首先,我们需要建立新的多人游戏权限,验证玩家在多人游戏环境中的控制和决策能力。然后,我们将根据玩家 ID 是否与我们使用节点名称指定的玩家 ID 匹配来调整物理和输入处理。这确保了每个玩家控制正确的Player实例。

此外,该方法还更新了视觉玩家索引标签,使玩家能够看到分配给他们的化身。这种视觉反馈通过提供每个玩家身份和游戏中的存在感的明确指示,增强了多人游戏体验。

通过实现setup_multiplayer()方法,游戏实现了同步多人功能,创造了一个统一且沉浸式的多人体验。玩家可以相互互动和协作,在游戏世界中鼓励共享冒险和享受的感觉。

话虽如此,让我们深入代码,解锁我们原型中多人游戏功能的潜力!打开位于res://08.designing-online-platformer/Actors/Player/Player2D.gdPlayer脚本,并实现setup_multiplayer()方法,最终允许玩家控制他们的化身:

  1. Player脚本中,创建一个新的方法名为setup_multiplayer()。它应该接收一个参数来获取玩家的 ID;在这里,我们将它称为player_id

    func setup_multiplayer(player_id):
    
  2. 然后,使用@rpc注解装饰该方法,使用"any_peer""call_local"选项。这指定了该方法可以被任何同伴调用并在本地执行。因此,当玩家生成他们的化身时,他们会告诉其他同伴设置他们的化身,同时在本地设置化身实例:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
    
  3. setup_multiplayer()方法内部,让我们调用set_multiplayer_authority(),传递player_id作为参数来设置这个Player实例的新多人权限。记住,多人权限决定了节点上同伴的控制和决策能力:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
        set_multiplayer_authority(player_id)
    
  4. 然后,让我们创建一个变量来存储player_id是否等于Player实例名称。有了这个,我们检查当前化身是否应该由本地玩家控制:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
        set_multiplayer_authority(player_id)
        var is_player = str(player_id) == str(name)
    
  5. 之后,我们根据is_player变量的值设置物理和未处理输入处理。有了这个,我们禁用了不属于本地玩家的Player实例上的物理处理和输入处理:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
        set_multiplayer_authority(player_id)
        var is_player = str(player_id) == str(name)
        set_physics_process(is_player)
        set_process_unhandled_input(is_player)
    
  6. 最后,我们将label节点的文本更新为显示玩家索引。在这里,%s是一个占位符,它会被get_index()返回的值替换,代表玩家在PlayerSpawner子节点层次结构中的索引(记住第一个节点是MultiplayerSpawner),因此玩家索引从1开始:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
        set_multiplayer_authority(player_id)
        var is_player = str(player_id) == str(name)
        set_physics_process(is_player)
        set_process_unhandled_input(is_player)
        label.text = "P%s" % get_index()
    

这样,我们就有了准备在在线多人环境中表现的Player实例。setup_multiplayer()方法配置了Player实例中的多人功能。它设置多人权限,根据本地玩家 ID 调整物理处理和输入处理,并更新一个带有玩家索引的标签。

但是请注意,由于我们正在禁用物理和输入处理,从技术上讲,其他玩家的化身在整个游戏过程中将保持静态,对吧?每个玩家只能控制并看到他们自己的角色移动,我们不想这样。我们希望玩家能够相互互动,并看到其他玩家在这个共享体验中的行为。

在下一节中,我们将使用MultiplayerSynchronizer节点来确保所有玩家在彼此的角色的各个方面保持一致,包括不仅仅是角色的位置,还包括其动画等。我们还将了解如何处理Crate节点:由于玩家可以抓住并携带它,谁应该控制它?Crate节点的多玩家权限应该由谁拥有?

同步物理对象

在本节中,我们将了解如何使用MultiplayerSynchronizer节点进行位置更新以外的同步。该节点在确保玩家与游戏中其他玩家的角色保持同步方面发挥着重要作用。正如我们在在 PlayerSpawner 中给予玩家控制权部分所看到的,保持玩家之间的一致性对于创建无缝的多人游戏体验至关重要。

MultiplayerSynchronizer类作为玩家之间的桥梁,实现了各种属性的实时更新和同步。我们将探讨的一个关键方面是,如何根据携带Crate对象的玩家更新该对象的位置。这一功能允许玩家进行交互式和协作式游戏,玩家可以一起解决谜题或完成任务。

此外,我们将了解MultiplayerSynchronizer节点如何处理与角色动画相关的属性。通过利用MultiplayerSynchronizer类,我们可以确保所有玩家观察到其他玩家角色的相同动画状态,从而实现视觉上的一致体验。

通过使用MultiplayerSynchronizer节点,我们可以建立一个强大的同步框架,用于同步玩家动作、角色位置和动画。这种同步确保了所有玩家感知到一个统一和沉浸式的多人游戏环境,促进协作并提升整体游戏体验。

让我们探索MultiplayerSynchronizer的实现细节!

同步玩家的位置和动画

我们的Player场景中有些节点负责根据玩家的动作和当前角色状态播放动画,即SpritesAnimatedSprite2D节点。同步Sprites节点的缩放和AnimatedSprite动画及帧非常重要,因为如果玩家跳跃、奔跑和保持静止,而游戏世界中没有视觉反馈来更新这些动作的表现,玩家角色看起来会相当奇怪。因此,在本节中,让我们确保除了位置之外,其他相关属性也在玩家之间同步。为此,让我们打开Player场景,路径为res://08.designing-online-platformer/Actors/Player/Player2D.tscn,当然,将其子节点添加为MultiplayerSynchronizer。有了这个,我们将执行以下步骤:

  1. 首先,我们需要将MultiplayerSynchronizer可见性更新模式更改为物理,以便它在远程玩家的游戏实例上同步物理模拟。

图 8.9 – 玩家的 MultiplayerSynchronizer 属性

图 8.9 – 玩家的 MultiplayerSynchronizer 属性

  1. 之后,在PlayerCharacter2D节点的AnimatedSprite2D节点的Sprite节点的MultiplayerSynchronizer节点也同步了动画相关属性,使得玩家可以看到他们的同伴的化身正在做什么。

图 8.10 – MultiplayerSynchronizer 的复制属性

图 8.10 – MultiplayerSynchronizer 的复制属性

就这样!有了这个,我们的玩家就可以在共享的游戏世界中互动了。MultiplayerSynchronizer节点是我们工具箱中一个了不起的盟友,在开发在线多人游戏时。正如我们在这个部分所看到的,这些节点允许同步一系列不同的属性,可以帮助我们使在线游戏体验愉快。在这方面有一个小但非常重要的观察要提。正如我们在整本书中看到的,特别是在第一部分,我们不能传递对象,我们应该避免通过网络进行大量数据传输。所以,在向MultiplayerSynchronizer节点的复制菜单添加属性时要记住这一点。例如,如果你尝试同步一个纹理属性,你很可能会失败复制。

话虽如此,在下一节中,我们将使用MultiplayerSynchronizer节点来同步Crate节点的位置属性,但有一个转折。由于任何玩家都可以抓取一个Crate节点并移动它,那么它的多玩家权限应该归谁?嗯,这正是我们即将看到的!

远程更新箱子的位置

到目前为止,我们对MultiplayerSynchronizer节点的工作方式和节点多玩家权限的整体概念相当熟悉,对吧?我们在线多人解谜平台游戏中的一个核心机制是玩家能够通过拿取物体并将它们用作平台来通过关卡障碍。

在本节中,我们将看到如何根据当前与对象交互的是哪个玩家动态地更改对象的多玩家权限,以便只有那个玩家可以更改对象的属性。打开Crate场景在res://08.designing-online-platformer/Objects/Crate/Crate.tscn,并添加一个新的MultiplayerSynchronizer节点作为其子节点。然后,按照以下步骤操作:

  1. 就像在Player场景中一样,我们需要将MultiplayerSynchronizer节点的可见性更新模式更改为物理,以保持物理模拟的一致性。

图 8.11 – 箱子的 MultiplerSynchronizer 属性

图 8.11 – 箱子的 MultiplerSynchronizer 属性

  1. 然后,在CharacterBody2D节点的位置属性中进行同步。

图 8.12 – 复制菜单中箱子的 CharacterBody2D 位置属性

图 8.12 – 复制菜单中箱子的 CharacterBody2D 位置属性

相信与否,我们已经有同步箱子位置所需的一切了。目前,箱子没有内置的移动行为,因为它的位置预计将被与之交互的玩家更改。为了启用此功能,我们将对Crate脚本进行一些修改。要开始,让我们打开位于res://08.designing-online-platformer/Objects/Crate/Crate.gd的脚本文件。

_on_interactive_area_2d_area_entered()方法中,我们需要将箱子的多人游戏权限更改为与它交互的玩家匹配。为此,我们可以调用set_multiplayer_authority()方法,传入区域的多人游戏权限。刚刚进入的这个区域是玩家的InteractionArea2D节点,因此它的多人游戏权限与玩家相同:

func _on_interactive_area_2d_area_entered(area):
    lift_transformer = area.get_node
        ("GrabbingRemoteTransform2D")
    set_multiplayer_authority
        (area.get_multiplayer_authority())

有了这个,每当玩家的角色进入箱子的InteractiveArea2D节点时,玩家将成为箱子的多人游戏权限,并且一旦与它交互,就能抓住它并改变它的位置。有了这个新增功能,我们就可以见证箱子位置在玩家交互时的无缝同步。您可以测试原型来探索协作游戏的可能性,并享受我们刚刚创建的沉浸式多人游戏体验!

摘要

在本章中,我们深入探讨了在线多人解谜平台游戏的世界,这强调了团队合作和协作。玩家将面临挑战,需要共同努力,利用他们的技能克服障碍并穿越复杂的关卡。在整个章节中,我们探讨了增强多人游戏体验和创建无缝协作游戏环境的关键概念和技术。

为了启用多人游戏功能,我们引入了MultiplayerSpawner类,该类根据连接的玩家数量动态实例化Player实例。这确保了每个玩家在游戏中都有一个独特的角色,促进了个性化且沉浸式的多人游戏体验。Player类发挥了关键作用,我们实现了setup_multiplayer()方法来配置其多人游戏设置。此方法允许我们设置每个实例的多人游戏权限,调整物理和输入处理,并更新一个视觉玩家索引标签,为玩家提供清晰的识别。

为了实现玩家之间的同步,我们利用了MultiplayerSynchronizer节点的力量。这个强大的工具使我们能够同步玩家的位置以及他们的动画。通过整合MultiplayerSynchronizer节点,我们创造了一个视觉上引人入胜的多玩家体验,玩家在游戏中移动和互动,达到了完美的和谐。这种同步让多玩家游戏玩法栩栩如生,增强了沉浸感,并确保了一个连贯且愉快的共享体验。

我们实现的一个令人兴奋的功能是玩家能够抓取并操作Crate对象。通过动态改变箱子的多玩家权限,我们确保只有与箱子互动的玩家才能控制其移动。这增加了一层额外的协作和解决问题,因为玩家可以战略性地使用箱子作为平台来穿越关卡,促进团队合作和协调。

总结来说,这一章为使用 Godot 引擎高级网络 API 理解和实现多人功能提供了一个坚实的基础。通过结合探索的概念和技术,我们创建了一个在线多人益智平台游戏原型,玩家可以无缝协作,同步他们的行动,并共同克服挑战。这一章为未来多人游戏开发中的无限可能性打开了大门,赋予你创造引人入胜和互动的多玩家体验的能力。

在下一章中,我们将利用本书第二部分所看到的所有知识,创建一个具有持久部分系统的多人在线冒险游戏,玩家可以登录和登出并保持他们的进度。玩家还将同步服务器的世界与他们的游戏实例世界,这也意味着他们能够看到当前正在玩的所有其他玩家,并相互互动。这本质上是一个原型,如果你愿意,可以扩展成一个大型多人在线角色扮演游戏MMORPG)。

第九章:创建在线冒险原型

在本章中,我们将探索一个在线太空冒险游戏的迷人世界,该游戏有可能演变成一个大型多人在线角色扮演游戏MMORPG)。在整个旅程中,我们将为沉浸式游戏体验奠定基础,允许玩家加入持久的世界,并无缝地将他们的游戏状态与游戏世界的当前状态同步。

这是一张最终太空射击冒险原型快照:

图 9.1 – 两位玩家共同完成摧毁 10 颗小行星的任务

图 9.1 – 两位玩家共同完成摧毁 10 颗小行星的任务

我们的主要关注点将是构建一个强大的网络系统,通过使用 Godot 引擎网络 API 促进玩家之间的实时交互。通过这个系统,玩家将连接到一个中央服务器,确保每个人共享相同的游戏世界,并能够见证彼此的行动,促进协作和团队精神。

此外,我们将深入研究创建一个动态任务系统,该系统能够跟踪玩家进度并将此数据存储在数据库中,以便当玩家回来时,他们可以保持自己的进度。在我们的太空冒险原型中,玩家将合作完成诸如摧毁小行星等任务。

我们将首先了解我们游戏中的每个部分的作用:小行星、宇宙飞船和玩家场景。然后,我们将转向冒险游戏的核心功能——任务系统,我们将学习如何将数据拉取和推送到服务器,以及从服务器和玩家的角度构建这个强大系统的要素。

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

  • 介绍原型

  • 将玩家登录到服务器

  • 分离服务器和客户端的责任

  • 在服务器上存储和检索数据

到本章结束时,您将拥有一个在线冒险游戏的基础,该游戏可以扩展成一个庞大而吸引人的大型多人在线角色扮演游戏(MMORPG)。配备持久的世界、同步游戏玩法和任务系统,您将准备好构建一个引人入胜且动态的在线游戏体验。

技术要求

要访问本章的资源,请访问我们的在线项目仓库,网址为github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

在您的计算机上拥有仓库后,在 Godot 引擎编辑器中打开res://09.prototyping-space-adventure文件夹。您将在这里找到本章所需的全部文件。

现在,让我们开始入职流程。在下一节中,我们将介绍项目,探索其主要类,并确定我们需要实现网络功能的地方。

介绍原型

在本节中,我们将全面了解驱动我们原型的核心系统。作为我们虚构工作室的网络工程师,我们的角色在将我们的本地游戏原型转变为激动人心的在线多人游戏原型中至关重要。为了实现这一目标,我们必须熟悉构成我们项目的重大类和文件。

在我们加入一个项目时,不要忽视入职流程的重要性。作为网络工程师,应用我们的知识和洞察力对于无缝融入开发过程至关重要。通过理解核心系统和概念,我们创造了一个协作和高效的环境,使团队能够共同将我们的愿景变为现实。

那么,让我们深入到我们的原型核心,解锁在线多人游戏的潜力。在本节结束时,你将具备必要的系统,可以对其进行调整以塑造沉浸式和引人入胜的在线体验,将玩家团结在一个动态和互联的世界中。在下一节中,让我们了解Player类和场景的工作原理。

理解玩家场景

在任何游戏中,玩家的化身是玩家体验的基本元素。在本节中,我们的目标是理解Player场景的组成,即在我们原型中表示玩家化身的那一场景。

Player场景是玩家作为游戏中的实体的抽象表示。它是一个 Node2D 类,具有Spaceship场景、Weapon2D场景、Sprite2D节点、HurtArea2D节点、CameraRemoteTransform2D节点,当然还有Camera2D节点。

图 9.2 – 玩家场景节点层次结构

图 9.2 – 玩家场景节点层次结构

现在,让我们了解这个场景的主要组件的作用,即SpaceshipWeapon2D节点。

Spaceship节点是Player节点的直接子节点,携带了除Camera2D节点之外的大部分其他组件;相反,它使用CameraRemoteTransform2D节点来远程转换Camera2D节点。Spaceship节点是一个 RigidBody2D 节点,它模拟在无重力且摩擦力非常低的环境中身体的运动。它有两个主要方法,Spaceship.thrust()Spaceship.turn()。在下面的代码中,我们可以看到我们如何实现这些方法:

class_name Spaceship2D
extends RigidBody2D
@export var acceleration = 600.0
@export var turn_torque = 10.0
func thrust():
    var delta = get_physics_process_delta_time()
    linear_velocity += (acceleration * delta) *
        Vector2.RIGHT.rotated(rotation)
func turn(direction):
    var delta = get_physics_process_delta_time()
    angular_velocity += (direction * turn_torque) * delta

thrust()方法将加速度力应用到Spaceship.linear_velocity属性,使其移动。然后,turn()方法将加速度力应用到Spaceship.angular_velocity属性,使其旋转。我们已经设置了Spaceship节点,使其只需这些操作就能实现流畅的运动。它还有一些阻尼力。在下面的图中,我们可以看到与Spaceship场景相关的属性,以更好地理解这种运动。

图 9.3 – 星船 RigidBody2D 属性设置

图 9.3 – Spaceship RigidBody2D 属性设置

Player 场景通过根据玩家的输入调用 Spaceship.thrust()Spaceship.turn() 方法来简单地控制 Spaceship 节点的移动。在以下代码片段中,我们可以看到在 _physics_process() 回调中它是如何工作的:

extends Node2D
@export var thrust_action = "move_up"
@export var turn_left_action = "move_left"
@export var turn_right_action = "move_right"
@export var shoot_action = "shoot"
@onready var spaceship = $Spaceship
@onready var weapon = $Spaceship/Weapon2D
func _process(delta):
  if Input.is_action_pressed(shoot_action):
    weapon.fire()
func _physics_process(delta):
  if Input.is_action_pressed(thrust_action):
    spaceship.thrust()
  if Input.is_action_pressed(turn_left_action):
    spaceship.turn(-1)
  elif Input.is_action_pressed(turn_right_action):
    spaceship.turn(1)

现在,如果你是一个细心的工程师,在这个阶段我们可以假设你是,你可能会注意到在 _process() 回调中,我们在 Weapon2D 节点上调用 fire() 方法,对吧?让我们了解 Weapon2D 节点是如何工作的;它对我们来说是一个核心类。

Weapon2D 场景是一个包含 BulletSpawner2DTimerSprite2DAnimationPlayer 节点的 Marker2D 节点。以下截图展示了 Weapon2D 场景的结构:

图 9.4 –  场景的节点层次结构

图 9.4 – Weapon2D 场景的节点层次结构

BulletSpawner2D 根据其 global_rotation 值实例化子弹并赋予它们方向。我们可以在以下代码块中看到它是如何工作的:

extends Spawner2D
func spawn(reference = spawn_scene):
  var bullet = super(reference)
  bullet.direction = Vector2.RIGHT.rotated(global_rotation)

对于 Weapon2D,它使用 Timer 来建立射击速率,如果 Timer 当前处于活动状态,则不能射击。否则,它播放 "fire" 动画,使用我们在其 bullet_scene 属性中设置的任何场景实例化一个子弹,并根据 Weapon2Dfire_rate 值启动 Timer。默认情况下,它每秒射击三颗子弹。在以下代码中,我们可以看到我们是如何实现这个行为的:

class_name Weapon2D
extends Marker2D
@export var bullet_scene: PackedScene
@export_range(0, 1, 1, "or_greater") var fire_rate = 3
@onready var spawner = $BulletSpawner2D
@onready var timer = $Timer
@onready var animation_player = $AnimationPlayer
func fire():
  if timer.is_stopped():
    animation_player.play("fire")
    spawner.spawn(bullet_scene)
    timer.start(1.0 / fire_rate)

因此,玩家可以在完成任务的同时射击子弹并保护自己。其中一项任务就是摧毁一些小行星。所以,在下一节中,我们将了解 Asteroid 场景是如何工作的,以便我们之后可以继续到任务系统。

评估 Asteroid 场景

Asteroid 场景在我们原型中起着基本的作用。它代表玩家必须摧毁以在特定任务中前进的对象。当 Asteroid 场景按计划工作,我们可以评估任务系统。在本节中,我们将了解 Asteroid 场景是如何工作的,以便我们了解在将本地游戏原型转换为在线多人游戏原型过程中的操作。

Asteroid 场景是一个包含 AnimationPlayer 节点、Sprite2D 节点、GPUParticle2D 节点、HitArea2D 场景、HurtArea2D 场景、StaticBody2D 节点和 QuestProgress 场景的 Node2D 节点。我们可以在以下截图中看到场景结构:

图 9.5 –  场景的节点层次结构

图 9.5 – Asteroid 场景的节点层次结构

HitArea2D节点对接触它的玩家太空船造成1点伤害。当玩家射击子弹时,如果他们击中HurtArea2D节点,他们的HitArea2D节点会对Asteroid节点造成1点伤害。如果Asteroid节点没有剩余的击中点数,它将播放"explode"动画,通过GPUParticles2D节点发射一些粒子,并将自己放入队列中,以便SceneTree在动画完成后立即从内存中释放它。

这样做会发出tree_exiting信号,该信号连接到QuestProgress.increase_progress()方法。我们将在揭示任务系统部分讨论QuestProgress节点。以下代码片段展示了Asteroid节点的行为:

extends Node2D
@export var max_health = 3
@onready var health = max_health
@onready var animator = $AnimationPlayer
func apply_damage(damage):
  health -= damage
  if health < 1:
    animator.play("explode")
  elif health > 0:
    animator.play("hit")
func _on_hurt_area_2d_damage_taken(damage):
  apply_damage(damage)
func _on_animation_player_animation_finished(anim_name):
  if anim_name == "explode":
    queue_free()

有了这些,Asteroid节点就成为了我们任务系统的一个很好的测试对象。在下一节中,让我们了解这个系统是如何工作的,以及我们应该考虑的重要方面,以便为我们的原型在线多人版本考虑。

揭示任务系统

现在是了解定义冒险游戏核心要素的时候了。在本节中,我们将了解我们原型中的任务系统是如何工作的以及我们可以用它做什么。这将使我们能够更好地理解我们需要更改什么才能将其转变为适用于游戏在线多人版本的系统。

让我们开始吧!

将任务表示为节点

在本节中,我们将了解Quest节点的工作原理以及我们可以用它做什么。为此,打开res://09.prototyping-space-adventure/Quests/Quest.tscn场景。与其他所有任务系统组件一样,它是一个带有脚本的节点。打开脚本,让我们来理解它。

Quest节点最终代表玩家任务日志中的任务,为此,它捆绑了与任务本身相关的所有数据:

  • 代表数据库中任务的id属性,默认为"asteroid_1"

  • 任务的相关属性titledescriptiontarget_amount。请注意,这些是导出变量,因此这允许我们的(假)任务设计师直接使用检查器创建新的任务。您可以在以下图中看到检查器显示的所有这些属性:

图 9.6 – 检查器中的任务属性

图 9.6 – 检查器中的任务属性

  • 它还有一个current_amount属性来跟踪玩家向任务目标数量的进度,以及一个completed属性来告知玩家是否已经完成了任务。

  • 此外,它还有一个用于current_amount属性的 setter 方法来处理接收到的值。它确保该值在0target_amount属性之间夹紧。它还会发出一个信号通知任务已被更新,如果current_amount属性等于target_amount属性,它还会发出一个信号通知任务已完成。

在以下代码片段中,我们可以看到它是如何具体实现的:

extends Node
signal updated(quest_id, new_amount)
signal finished(quest_id)
@export var id = "asteroid_1"
@export var title = "Quest Title"
@export var description = "Insert Quest description here"
@export var target_amount = 1
var current_amount = 0 : set = set_current_amount
var completed = false
func set_current_amount(new_value):
  current_amount = new_value
  current_amount = clamp(current_amount, 0, target_amount)
  updated.emit(id, current_amount)
  if current_amount >= target_amount:
    finished.emit(id)

因此,我们有一个可以代表我们任务系统中任务的对象。这是这个系统的基本组成部分。正如我们所见,有一些数据构成了一个Quest,对吧?这些数据存储在数据库中,以便我们可以加载和存储任务的内容。在下一节中,我们将看到这个数据库的样子以及如何加载任务内容并存储对任务的任何更改。

使用 QuestDatabase 加载和存储任务

在任何一款冒险游戏中,玩家需要任务来推进游戏故事和整体世界设计。这些任务可能存储在一个地方,任务设计师可以简单地编写它们并创建一个 NPC 来向玩家提供这些任务。

由于在我们的假任务设计师需要某种类型的数据库来设计任务,我们创建了QuestDatabase单例。它加载包含游戏中所有可用任务和玩家在每个任务中的进度的 JSON 文件。在本节中,我们将看到如何加载这些文件并存储玩家的进度,以便他们在离开游戏时不会丢失,以及QuestDatabase单例如何将这些数据提供给其他类。

打开res://09.prototyping-space-adventure/Quests/QuestDatabase.tscn文件中提供的场景,你也会注意到它不过是一个带有脚本的节点。在检查器中,你会注意到两个重要文件的路径:

图 9.7 – QuestDatabase 的检查器

图 9.7 – QuestDatabase 的检查器

这些是QuestDatabase用来加载游戏中的任务和玩家在已开始的任务中的进度的 JSON 文件。PlayerProgress.json文件的内容如下所示:

{
  "asteroid_1": {
    "completed": false,
    "progress": 0
  }
}

因此,每个任务都通过其 ID 和一个字典来表示,字典中指明了它们是否已经完成以及玩家目前所取得的进度。现在,对于QuestDatabase.json文件,它要复杂一些;文件内容如下所示:

{
    "asteroid_1" : {
        "title": "Destroy 10 Asteroids",
        "description": "Destroy some asteroids,
            please. They are taking too much space here",
        "target_amount": 10}
}

再次强调,每个任务都被抽象为一个字典,反映了任务的 ID。在字典内部,我们有"title""description""target_amount"键,它们包含关于任务对象序列化和反序列化过程的重要数据。

现在,QuestDatabase单例有一些重要的方法来加载、读取、处理、存储,甚至允许其他对象访问这些数据。让我们简要地了解一下主要方法;你会注意到类中还有一些额外的方法,但它们本质上是为了检索关于任务数据的特定信息,例如任务的标题。

但让我们关注更相关的几个方法:

  • QuestDatabase.load_database(): 加载并反序列化QuestDatabase.jsonPlayerProgress.json文件,并将它们的内容分别存储在quests_databaseprogress_database成员变量中。

  • QuestDatabase.store_database(): 与前一个方法相反,将quests_databaseprogress_database成员变量序列化到各自的文件中。

  • QuestDatabase.get_player_quests(): 为progress_database字典中的每个键创建一个quest_data字典,使用辅助方法收集它们的数据,并返回一个包含玩家已开始的所有任务及其数据的quests字典。

  • QuestDatabase.update_player_progress(): 更新玩家在特定任务中的进度。它接收一个quest_idcurrent_amountcompleted参数来执行此操作。

QuestDatabase脚本中,我们可以看到这个行为的具体实现和辅助方法。你会注意到有一个_notification()回调的实现,它本质上是在应用程序的窗口接收到关闭请求时调用store_database()方法:

func _notification(notification):
    if notification == NOTIFICATION_WM_CLOSE_REQUEST:
        store_database()

这保证了如果玩家通过常规方式退出游戏,例如点击关闭按钮,他们的进度将被保存。

这样,我们在运行时就有任务数据和玩家的进度,任务系统几乎完成了。我们只需要知道最后我们如何处理所有这些,对吧?在下一节中,我们将了解我们如何使用有趣的QuestProgress节点来更新系统,每当玩家在某个任务中取得进展时。

管理玩家的任务

现在我们知道当玩家在某个任务中取得进展时,我们可以使用QuestProgress类,我们需要了解这些任务如何在系统中本身被管理。在本节中,我们将了解我们如何从任务数据库检索任务,如何根据检索到的可用任务为当前玩家创建新任务,如何管理玩家在特定任务中的进度,以及如何通知玩家他们在任务日志中有新的任务。

打开位于res://09.prototyping-space-adventure/Quests/QuestSingleton.tscn的场景,你会看到它是一个带有脚本的节点。打开脚本,让我们了解这个场景的作用。

正如单例名称Quests所暗示的,这个场景是玩家当前拥有的所有任务的集合。在将任务表示为节点部分,我们将看到我们如何将每个任务抽象为一个具有所有相关属性的对象,例如titledescriptionidQuestSingleton类负责检索和管理任务。

要做到这一点,它有三个核心方法:

  • QuestSingleton.retrieve_quests(): 从QuestDatabase单例请求所有可用的任务。我们在使用 QuestDatabase 加载和存储任务部分讨论了QuestDatabase

  • QuestSingleton.create_quest(): 接收一个包含创建 Quest 节点所需所有相关数据的 quest_data 字典,然后它实例化一个 Quest 并将其映射到 QuestSingleton.quests 字典中,使用任务 ID。这允许其他类在即将到来的方法中使用任务 ID 访问 Quest 节点。

  • QuestSingleton.get_quest(): 接收一个作为参数的 quest_id 值,并使用它来返回与提供的 ID 相关的给定 Quest 节点。

  • QuestSingleton.increase_quest_progress(): 接收一个作为参数的 quest_id 值和一个 amount 值,以确定在提供的任务进度中增加多少。

在下面的代码中,我们可以看到这些行为是如何实现的:

extends Node
signal quest_created(new_quest)
var quest_scene = preload("res://09.prototyping-space-
    adventure/Quests/Quest.tscn")
var quests = {}
func retrieve_quests():
  var player_quests = QuestDatabase.get_player_quests()
  for quest in player_quests:
    create_quest(player_quests[quest])
func create_quest(quest_data):
  var quest = quest_scene.instantiate()
  quest.id = quest_data["id"]
  quest.title = quest_data["title"]
  quest.description = quest_data["description"]
  quest.target_amount = quest_data["target_amount"]
  quest.current_amount = quest_data["current_amount"]
  quest.completed = quest_data["completed"]
  add_child(quest)
  quests[quest.id] = quest
  quest_created.emit(quest)
func get_quest(quest_id):
  return quests[quest_id]
func increase_quest_progress(quest_id, amount):
  var quest = quests[quest_id]
  quest.current_amount += amount
  QuestDatabase.update_player_progress(quest_id,
      quest.current_amount, quest.completed)

有了这个功能,QuestSingleton 能够检索玩家当前参与的所有任务,并将它们提供给用户类,以便它们可以访问并处理这些任务。这将使我们能够实际上增加玩家在特定任务中的进度。为此,我们将了解 QuestProgress 节点的工作原理。

增加任务进度

Asteroid 场景中,我们有 QuestProgress 节点。此节点负责在玩家在特定任务中取得进展时向任务系统通信。为了知道 QuestProgress 指的是哪个任务,我们使用一个名为 quest_id 的变量,这是我们任务系统中的一个基本概念。通过这些数据,系统中的其他类可以相互通信,请求更改或检索有关特定任务的信息。

此外,QuestProgress 类有一个名为 increase_quest_progress() 的方法,该方法请求 QuestSingleton(称为 Quests),通过提供的 amount 值(默认为 1)来增加任务的进度。

我们在 管理玩家任务 部分中看到了 QuestSingleton 的工作方式。尽管如此,在下面的代码片段中,我们可以看到 QuestProgress 类的代码:

extends Node
@export var quest_id = "asteroid_1"
func increase_progress(amount = 1):
  Quests.increase_quest_progress(quest_id, amount)

QuestProgress 节点本身是系统的一个小组件,它在系统的最后端工作,即最终输出被处理的地方。它旨在被其他类用来触发其行为。例如,如 评估小行星场景 部分中提到的,Asteroid 节点使用其 tree_exiting 信号来触发 QuestProgress.increase_progress() 方法。

这就完成了我们的任务系统入门。在本节中,我们了解了对象如何增加任务的进度,我们如何从数据库中检索任务和玩家的进度,我们在数据库文件中存储了哪些数据和类型的数据,以及这些数据最终如何进入我们可以实现高级行为的节点。

我们的入门流程还没有结束。在接下来的部分中,我们将了解玩家如何在 QuestPanel 节点中看到任务信息,这是我们的原型最后一块,即 World 场景的一个组件。实际上所有的动作都发生在这个场景中,所以请保持专注,让我们看看它是如何工作的。

拆解世界场景

到目前为止我们所看到的一切都将汇聚成世界场景。这是一个将所有元素组合起来进行交互的场景。这就是我们用来测试当前原型的世界场景。为此,打开res://09.prototyping-space-adventure/Levels/World.tscn场景并点击运行当前场景按钮。你将能够测试游戏并感受原型的效果。

现在,在本节中,我们将了解如何在游戏中创建小行星和玩家,以及如何在屏幕上显示玩家的任务日志。世界本身是一个高级抽象场景,因此事情更容易理解。

场景本身是一个名为Main的节点,它有一个名为AsteroidsRadialSpawner子节点,负责在其周围生成小行星,一个名为PlayersSpawner节点,负责生成Player实例,以及一些CanvasLayers节点来创建游戏的整体视觉,即BackgroundLayer节点,它使用ColorRect节点设置游戏背景颜色,然后是ParallaxBackground节点,它包含一个ParallaxLayer节点,该节点包含一个GPUParticles2D节点,用于创建背景的重复星系。

最后,我们还有一个名为InterfaceCanvasLayer的节点,正如其名称所暗示的,它包含界面元素。在这里,我们有一个重要的元素来总结任务系统:QuestPanel节点。我们将在显示任务信息部分讨论它。在下面的屏幕截图中,我们可以看到World场景节点的层次结构:

图 9.8 – 世界场景节点的层次结构

图 9.8 – 世界场景节点层次结构

在这里,我们能够构建一个原型,在该区域周围生成一些小行星,生成一个 pPlayer,并显示带有当前活跃任务及其信息的玩家任务日志。在下一节中,我们将了解QuestPanel节点如何收集、显示和更新玩家任务的信息。

显示任务信息

最后,任务系统有一个主要责任,总结了迄今为止我们所看到的所有关于它的内容。它必须向玩家显示当前活跃任务的信息。这最终归结为QuestPanel节点,它是一个 UI 元素,根据从QuestSingleton节点收集的数据显示此类信息。在本节中,我们将了解QuestPanel节点的工作原理。为此,打开res://09.prototyping-space-adventure/Quests/QuestPanel.tscn场景。

注意,QuestPanel节点本身扩展了ScrollContainer类,并且它有一个作为其子节点的VBoxContainer节点。这使我们能够为玩家显示许多任务,并且他们可以使用滚动条来导航这些任务。我们目前只有一个任务,如QuestDatabase.json文件所示,但为更多任务铺平了道路。现在,打开QuestPanel脚本,让我们看看它是如何实现显示任务信息的。

它在 _ready() 回调中首先做的事情是将 Quests 单例的 quest_created 信号连接到 QuestPaneladd_quest() 方法。然后它告诉 Quests 单例检索任务,这将用玩家的任务填充 Quests 单例。每当 Quests 单例创建一个新的 Quest 节点并将其作为其子节点添加时,它都会发出一个信号,QuestPanel 节点会监听并调用 add_quest() 方法。让我们来谈谈 QuestPanel 节点的成员变量和方法:

  • quests_labels 是一个字典,用于使用 Quest.id 属性作为键将 Label 节点映射到它们的引用。

  • add_quest() 方法创建一个新的 Label 节点,并使用存储在 quest 属性中的 Quest 节点的信息将 text 属性设置为格式化的字符串。它还将 quest.updated 信号连接到其 update_quest() 方法,我们将在稍后讨论该方法。然后,它将这个 Label 节点作为 VBoxContainer 节点的子节点添加,并在 quests_labels 属性中映射以供进一步参考。

  • update_quest() 方法接受 quest_id 字符串和 current_amount 整数作为参数,并使用 quest_id 参数找到适当的 Label 节点,用更新的任务数据更新文本。

如果你想了解这一切的具体实现,以下代码片段表达了这种行为:

extends ScrollContainer
var quest_labels = {}
func _ready():
  Quests.quest_created.connect(add_quest)
  Quests.retrieve_quests()
func add_quest(quest):
  var label = Label.new()
  var quest_data = "%s \n %s/%s \n \n %s" %[quest.title,
      quest.current_amount, quest.target_amount,
          quest.description]
  label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
  label.text = quest_data
  $VBoxContainer.add_child(label)
  quest.updated.connect(update_quest)
  quest_labels[quest.id] = label
func update_quest(quest_id, current_amount):
  var quest = Quests.get_quest(quest_id)
  var quest_data_text = "%s \n %s/%s \n \n %s" %
      [quest.title, quest.current_amount,
          quest.target_amount, quest.description]
  var label = quest_labels[quest_id]
  label.text = quest_data_text

有了这些,我们就完成了对任务系统的入门,你现在准备好了解你将如何使用它来处理我们的在线多人游戏原型的版本!你已经看到了一切,包括对象如何更新任务进度,任务在哪里收集和存储,我们如何加载和保存玩家在特定任务中的进度,我们如何在游戏中实现表示任务的节点,以及最后,这一切是如何组合在一起,在 UI 元素中向玩家显示任务信息的。

在接下来的部分,我们将了解世界的主节点是如何工作的。其主要职责是确保游戏世界按计划运行,所有对象都处于正确的位置。

初始化游戏世界

为了确保游戏按我们的计划运行,至少是初始化,我们有 Main 节点。它本质上使用 Asteroids 节点生成 30 个 Asteroid 实例,并使用 Players 节点创建 Player 场景的实例。正如在 分解 World 场景 部分的开头所解释的,后两个节点是生成器。

在这个本地游戏原型中,Main 节点非常简单,但请记住,特别是关于其职责,当你开始实现在线多人游戏功能时。为了参考,Main 节点脚本在下面的代码片段中显示:

extends Node
@onready var asteroid_spawner = $Asteroids
@onready var player_spawner = $Players
func _ready():
  for i in 30:
    asteroid_spawner.spawn()
  create_spaceship()
func create_spaceship():
  player_spawner.spawn()

注意,它有一个 create_spaceship() 方法,而不是直接调用 player_spawner.spawn() 方法。这将有助于你后续的工作,所以你可以感谢我们的假团队使你的工作变得更简单。

就这样,你的入职流程完成了!我们已经看到了玩家如何控制他们的宇宙飞船,小行星如何受到打击并爆炸,从而增加玩家在任务中的进度,任务系统是如何工作的,以及它处理和输出的内容。我们还刚刚看到了游戏世界是如何初始化并确定每个对象应该在哪里以及应该有多少个对象。

现在,是时候展示魔法了。在接下来的章节中,我们将看到如何将这个原型转变为一个在线多人游戏原型,玩家可以随时加入,因此不会有大厅。我们还将了解我们需要做什么来保持玩家的世界与服务器世界的同步,以及我们如何使用相同的脚本区分服务器和客户端的职责。这在我们处理数据库时特别有用,以防止玩家作弊和轻松完成任务。

将玩家登录到服务器

在本节中,我们将实现不同类型的日志记录系统。这次,玩家没有大厅屏幕,他们可以在那里等待其他玩家加入游戏并开始比赛。不,在这里,世界始终处于活跃状态;它不仅仅是在玩家请求服务器开始比赛或游戏时才开始。这种类型的连接需要不同的方法。

主要问题是由于游戏世界始终处于活跃和运行状态,加入这个世界的玩家需要将他们的游戏实例与服务器上的游戏实例同步。这包括对象的位置,通常不是世界一部分的新对象,例如,其他玩家和对象的数量(在我们的例子中,当前有多少小行星可用),以及构建共享世界所需的其他许多因素。

一切都从玩家认证开始,因为现在服务器和客户端处于游戏生命周期的不同部分;当玩家刚刚打开游戏时,服务器已经在处理游戏世界了。

认证玩家

不要恐慌,尽管认证的条件不同,但整体逻辑与迄今为止我们所使用的非常相似。这里的主要区别是我们将需要一个专门的Authentication节点,针对连接的每一方执行认证程序,根据客户端或服务器的职责进行。

这些节点将位于连接两端的两个主要交互点:

  • 对于客户端,在这种情况下是玩家,我们将在LoggingScreen场景中设置Authentication节点

  • 对于服务器,我们将在World场景本身上设置其Authentication节点,等待玩家加入

注意,对于这个关系的每一方,我们将实施不同的认证程序。所以,除了这两个节点都被称为Authentication并且有相同的路径,换句话说,它们都是名为Main的父节点的直接子节点之外,它们将是完全不同的类。

他们将需要共享的方法,但我们会看到方法实现是不同的。这都归因于 RPC 的工作方式。记住,当创建 RPC 时,它将在所有对等游戏实例中寻找具有相同节点路径的节点,并且这个节点必须拥有与创建 RPC 的节点相同的所有方法,即使我们并没有调用这些其他方法。这意味着服务器端将共享客户端方法,反之亦然。

一旦我们开始实现它,这就会变得不那么令人困惑,所以让我们打开 res://09.prototyping-space-adventure/LoggingScreen.tscn 场景并实现身份验证的客户端部分。

实现客户端身份验证

LoggingScreen 场景中,你会注意到一个旨在作为简单登录屏幕的场景结构,玩家在此处输入他们的凭据,进行身份验证并登录游戏。这与我们在之前章节中使用的,例如在 第八章 中使用的 设计在线合作平台游戏 的大厅有些相似。

这次,我们没有展示当前玩家的面板;这不是必要的,因为即使其他玩家不在场,玩家也可以加入游戏并单独体验。请注意,这次场景的脚本被附加到了 Authentication 节点,而不是 Main 节点。

这是因为在 World 场景中,Main 节点有其他职责,所以最好将身份验证委托给一个专门的节点。因此,LoggingScreen 的身份验证也被委托给了它的 Authentication 节点。

图 9.9 – LoggingScreen 场景的节点层次结构

图 9.9 – LoggingScreen 场景的节点层次结构

现在,打开 res://09.prototyping-space-adventure/LoggingScreen.gd 脚本文件。你会注意到其中有很多与我们之前在 第三章 中创建的内容相似,即 制作一个大厅以聚集玩家,所以让我们专注于这次需要完成的工作:

  1. 由于这次我们只与服务器通信,我们不需要在所有对等体上启动游戏,所以在这个 _on_StartButton_pressed() 回调中,我们需要直接向服务器发送 RPC 请求它启动游戏:

    func _on_StartButton_pressed():
      rpc_id(1, "start_game")
    
  2. 然后,服务器本身将对玩家进行身份验证,如果一切顺利,服务器还将调用客户端的 start_game() 方法,该方法有不同的实现。在客户端,start_game() 是一个只有网络权威可以调用的 RPC,并且是本地调用的。当调用时,它将当前场景切换到 next_scene 属性,在这种情况下将是 World 场景:

    @rpc("authority", "call_local")
    func start_game():
      get_tree().change_scene_to_file(next_scene)
    

好的,大部分代码与我们在 Lobby 场景实现中拥有的代码非常相似。这个更简洁,因为我们已经移除了其他方法,例如我们用来显示已登录玩家或显示头像的方法。

通过这些更改,此代码能够发送直接请求认证给服务器,并在游戏的此实例上启动游戏。在下一节中,我们将看到这个系统的另一面,即服务器端。

实现服务器端认证

现在,服务器端认证要复杂一些。之前,我们只需要处理玩家的认证请求。但现在,由于认证是在服务器运行游戏的同时进行的,我们还需要转移设置托管的责任。

这意味着如果当前实例是服务器,它将需要在认证玩家凭证的基础上设置ENetMultiplayerPeer。打开res://09.prototyping-space-adventure/Authentication.gd文件,让我们进行必要的更改。

同样,我们只会关注基于之前工作的更改,所以如果你不记得所有这些是如何工作的,可以自由地浏览脚本的其他部分:

  1. _ready()回调中,让我们使用默认的PORT创建ENet服务器。记住,由于这个脚本将在客户端和服务器上运行,我们需要检查当前运行的实例是否是服务器。为此,我们使用multiplayer.is_server()方法。设置服务器后,我们将加载用户数据库,以便像往常一样正确地认证他们:

    func _ready():
        if multiplayer.is_server():
            peer.create_server(PORT)
            multiplayer.multiplayer_peer = peer
            load_database()
    
  2. 我们需要做的第二件事是如果实例不是服务器,则以客户端的身份连接:

        else:
            peer.create_client(ADDRESS, PORT)
            multiplayer.multiplayer_peer = peer
    
  3. 我们需要做的第三件事是设置start_game()方法以响应所有远程的节点,因此这个方法在服务器的实例中不会本地调用。在这个方法内部,我们将对请求启动游戏的节点进行 RPC 调用,告诉它们的实例启动游戏。

    这允许服务器决定玩家是否可以加入游戏,如果玩家尝试本地连接,仅仅按下开始按钮实际上并不会启动游戏,因为他们的实例会挂起,等待服务器的响应:

    @rpc("any_peer", "call_remote")
    func start_game():
        var peer_id = multiplayer.get_remote_sender_id()
        rpc_id(peer_id, "start_game")
    

通过这些更改,我们能够拥有一个可以运行独立游戏实例并等待玩家加入的服务器。使用rpc_id()方法,我们可以确定我们想要联系哪个节点,并在游戏实例之间建立直接通信。在这种情况下,我们是在服务器和客户端之间这样做,但如果需要,我们也可以在两个客户端之间这样做。

在下一节中,我们将关注如何同步服务器的持久World场景与玩家的World场景,这些场景可能不会反映共享游戏世界的当前状态。

同步World场景

当玩家登录游戏时,他们的世界可能不同于服务器世界。例如,只有服务器应该能够生成小行星,即使客户端能够生成它们,也无法保证它们会在相同的位置。所以,这是我们首先要解决的问题。

在下一节中,我们将看到如何将服务器世界的 Asteroid 实例同步到客户端的世界中。

同步小行星

再次打开 Asteroid 场景,并添加一个新的 MultiplayerSynchronizer 作为其子节点。这个 MultiplayerSynchronizer 将复制小行星的 positiontop_level 属性。以下图展示了小行星的 MultiplayerSynchronizer 复制 菜单 设置。

图 9.10 – 小行星的多人同步器复制菜单设置

图 9.10 – 小行星的多人同步器复制菜单设置

然后,我们将使用一些关于网络复制的非常有趣的东西。MultiplayerSynchronizer 节点有一个名为 World 场景的属性。因此,我们将关闭该属性。在下面的屏幕截图中,我们可以看到这个属性在 检查器 中应该是什么样子。

图 9.11 – 小行星的 MultiplayerSynchronizer 公共可见性属性在检查器中

图 9.11 – 小行星的 MultiplayerSynchronizer 公共可见性属性在检查器中

在我们继续进行下一步必要的步骤之前,我想向您展示一个技巧,这个技巧将帮助您一次性同步相关对象。在组内添加 MultiplayerSynchronizer 节点,这样您就可以稍后使用 SceneTree 执行组调用。在这种情况下,为了更清楚地说明组的意图,让我们称它为 Sync。下面的屏幕截图展示了这个组,其中包含 Asteroid 节点的 MultiplayerSynchronizer 节点。

图 9.12 – 小行星的 MultiplayerSynchronizer 在 Sync 组内

图 9.12 – 小行星的 MultiplayerSynchronizer 在 Sync 组内

这样,Asteroid 实例就准备好在客户端的 World 实例上复制它们的属性了。但还有一个问题。客户端的 World 实例不应该创建这些 Asteroid 实例;相反,只有服务器应该能够生成这些对象。所以,让我们打开 World 场景并为其设置同步:

  1. 我们需要做的第一件事是添加一个指向 Asteroids 节点的 MultiplayerSpawner 节点。让我们称它为 AsteroidsMultiplayerSpawner,并且它应该在 自动生成列表 属性中设置 Asteroid 场景作为其第一个也是唯一元素。我们可以在下面的屏幕截图中看到这些属性的配置:

图 9.13 – 世界的小行星 MultiplayerSpawner 属性在检查器中

图 9.13 – 世界的小行星 MultiplayerSpawner 属性在检查器中

这将确保服务器世界中小行星的所有实例也将存在于客户端世界中。但请注意,到目前为止,它们只是被生成,还没有同步。所以,让我们解决这个问题。打开World脚本,让我们为同步逻辑进行设置。

  1. 首先,在_ready()回调中,我们需要防止如果不是服务器,World节点生成小行星。它应该从服务器请求同步。为此,它将向服务器的sync_world方法发出 RPC 调用,我们将在下一步创建该方法:

    func _ready():
      if not multiplayer.get_unique_id() == 1:
        rpc_id(1, "sync_world")
      else:
        for i in 30:
          asteroid_spawner.spawn()
    
  2. 然后,让我们创建sync_world() RPC 方法,它可以由任何本地玩家调用。它需要在本地调用,因为我们将会告诉服务器的Asteroid实例的MultiplayerSynchronizer节点,它们位于Sync组中,将玩家添加到它们的可见列表中,从而有效地同步Asteroid实例。

    @rpc("any_peer", "call_local")
    func sync_world():
        var player_id = multiplayer.get_remote_sender_id()
        get_tree().call_group("Sync", "set_visibility_for
            ",player_id, true)
    

set_visibility_for()MultiplayerSynchronizer节点的一个方法,它将一个玩家添加到其可见列表中,这基本上意味着一个白名单,列出了它应该同步的玩家。

为了做到这一点,它使用玩家的 ID,并接收一个布尔值来告诉它这个玩家是否应该或不应该看到在multiplayer.get_remote_sender_id()方法中设置的属性的复制,所以任何请求同步的玩家都将被同步。

这就是同步小行星所需的所有内容。现在,我们仍然缺少玩家和他们的宇宙飞船,对吧?在下一节中,我们将了解如何远程在所有已连接的玩家上创建Player实例,同步他们的宇宙飞船,并且只允许其所有者控制宇宙飞船。

同步玩家

是时候将我们的玩家放入这个广阔的世界中了。在本节中,我们将了解当另一个玩家加入游戏时,如何同步已经在世界中的玩家场景。

让我们开始吧:

  1. 仍然在World脚本中,我们将把create_spaceship()方法转换成一个任何玩家都可以远程调用的 RPC 方法:

    @rpc("any_peer", "call_remote")
    func create_spaceship():
    
  2. 由于Players生成器的工作方式,在与其他玩家同步之前,我们无法对新建的宇宙飞船进行适当的重命名和识别。因此,create_spaceship()方法同时负责生成宇宙飞船。

    在我们将spaceship实例作为Players节点的子节点之前,我们将将其名称设置为与玩家的 ID 匹配。这确保了这个实例有一个唯一的名称,我们可以使用这个名称来识别实例的正确权限:

      var player_id = multiplayer.get_remote_sender_id()
    var spaceship = preload("res://09.prototyping-space-
    adventure/Actors/Player/Player2D.tscn").instantiate()
      spaceship.name = str(player_id)
      $Players.add_child(spaceship)
    
  3. 现在我们来到了一个非常关键的部分。我们将在玩家中实现setup_multiplayer()方法,这本质上与我们在第八章中制作的相同,即设计一个在线合作平台游戏。因此,我们可以在等待0.1秒后对此函数进行 RPC 调用:

      await(get_tree().create_timer(0.1).timeout)
      spaceship.rpc("setup_multiplayer", player_id)
    

    这样,每当一个玩家请求服务器的World实例创建一艘飞船时,它将实例化一个Player场景,分配一个唯一的 ID,并要求它配置其多人设置。记住,由于我们正在使用 RPC 来完成此操作,这意味着此Player实例将在所有当前连接的对等节点中配置其多人设置。但就目前而言,只有服务器有一个Player节点的实例。

  4. 为了解决这个问题,我们将在World场景中添加另一个名为PlayersMultiplayerSpawnerMultiplayerSpawner节点。它的Players节点及其Player场景res://09.prototyping-space-adventure/Actors/Player/Player2D.tscn。在以下截图中,我们可以看到这些属性在检查器中已设置。

图 9.14 – 世界场景的节点属性在检查器中的设置

图 9.14 – 世界场景的PlayersMultiplayerSpawner节点属性在检查器中的设置

现在,由于由PlayersMultiplayerSpawner节点创建的实例仍然尚未配置,我们还需要在它们生成时立即调用它们的setup_multiplayer()方法。

  1. 为了实现这一点,让我们将PlayersMultiplayerSpawnerspawned信号连接到World场景的Main节点脚本,并在_on_players_multiplayer_spawner()回调函数中,对最近生成的节点执行一个带有set_up_multiplayer作为参数的 RPC。这次,我们将使用节点的名称作为参数而不是player_id。这是因为我们没有访问到应该拥有此实例的玩家的 ID,因此我们可以使用实例名称:

    func _on_players_multiplayer_spawner_spawned(Node):
        Node.rpc("setup_multiplayer", int(str(Node.name)))
    
  2. 这样,每当一个玩家加入游戏时,服务器将为他们创建一个新的Player实例并设置此实例。这也适用于已经在服务器World中的Player实例。

    如果一个玩家加入游戏,服务器将生成所有其他当前正在玩的游戏的Player实例。现在,我们需要Player场景本身同步其相关属性到其对等节点,并实现其setup_multiplayer方法。

  3. 打开res://08.designing-online-platformer/Actors/Player/Player2D.tscn场景,让我们首先将MultiplayerSynchronizer节点作为Player节点的子节点添加。这个MultiplayerSynchronizer节点应该同步Player实例的positiontop_level属性以及Spaceship节点的positionrotation属性。以下截图展示了Player场景的MultiplayerSynchronizer 复制菜单

图 9.15 – 玩家的多人同步复制菜单

图 9.15 – 玩家的多人同步复制菜单

由于这个MultiplayerSynchronizer节点将通过同步物理属性来工作,我们需要将其可见性更新模式属性设置为物理。这将防止一些奇怪的行为,例如身体重叠和未处理或处理不当的碰撞。

现在,让我们实现setup_multiplayer()方法。在以下说明中,我们将创建一个 RPC 方法,用于检查当前实例是否是当前玩家的能力,并禁用一些重要进程以防止玩家与其不拥有的实例交互,以及防止覆盖由网络同步的属性。

  1. Player.gd脚本中,让我们首先创建一个名为setup_multiplayer的 RPC 方法,任何对等体都可以在本地调用。它应该接收player_id作为参数:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
    
  2. 然后,在这个方法内部,我们需要比较接收到的作为参数的player_id值是否与当前玩家的对等 ID 匹配。我们将把这个信息存储在一个名为is_player的变量中,以便进一步引用:

      var self_id = multiplayer.get_unique_id()
      var is_player = self_id == player_id
    
  3. 拥有这些信息后,我们可以设置玩家的进程。我们还需要禁用camera,如果这不是当前玩家,如果它是,则将camera设置为当前摄像头:

      set_process(is_player)
      set_physics_process(is_player)
      camera.enabled = is_player
      if is_player:
        camera.make_current()
    
  4. 最后,我们将玩家的多人权限设置为player_id。这将最终防止这个客户端对Player实例进行任何更改并将它们传播到网络上:

      set_multiplayer_authority(player_id)
    setup_multiplayer() method’s code implementation:
    
    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
      var self_id = multiplayer.get_unique_id()
      var is_player = self_id == player_id
      set_process(is_player)
      set_physics_process(is_player)
      camera.enabled = is_player
      if is_player:
        camera.make_current()
      set_multiplayer_authority(player_id)
    

在本节中,我们学习了如何在游戏世界已经运行时,当玩家登录时,如何将已存在的对象生成到玩家的游戏实例中。我们还看到了如何使用MultiplayerSynchronizer.set_visibility_for()方法有选择性地同步对象。

此外,我们使用了MultiplayerSpawner.spawned信号来配置客户端上的对象生成实例。在我们的例子中,我们需要配置玩家的多人游戏设置。为此,我们创建了一个方法,检查这个实例是否属于当前玩家,适当地禁用或启用其处理和摄像头,并相应地设置其多人权限。

在下一节中,我们将学习如何将游戏中的某些责任分离,以防止作弊并建立一个更连贯的多人游戏系统,例如,防止玩家的游戏实例删除小行星,因为这是服务器的责任。

我们还将看到如何同步所有对等体上的玩家动作。这将在本地复制动作时很有用;例如,当一个玩家在他们的游戏实例中射击时,所有其他游戏实例也应该执行射击动作。

分离服务器和客户端的责任

现在我们有玩家共享同一个世界,我们需要确定他们负责哪些动作,哪些动作是服务器责任的组成部分。例如,如果一个玩家在他们的游戏实例中射击,并且他们的子弹损坏了一颗小行星,但这个小行星已经被另一个玩家摧毁了,那么应该发生什么?对于这种情况,服务器是完美的调解者,可以防止实例冲突。

在所有这些背景信息就绪的情况下,玩家会告诉所有对等节点,包括服务器,根据他们的行为更新他们的 Player 实例,但只有服务器应该有权管理这些行为在游戏世界中的实际影响,例如玩家是否成功摧毁了一颗小行星。在下一节中,我们将了解玩家如何同步他们的行为,而不仅仅是他们的对象属性,到所有网络连接的对等节点。

在所有实例上射击子弹

由于 Player 场景的 Spaceship 节点运动已经通过 MultiplayerSynchronizer 节点同步,我们现在可以集中精力同步 Bullet 实例的运动。我们可以采取的一种方法是通过 MultiplayerSpawnerMultiplayerSynchronizer 节点远程生成 Bullet 实例并复制它们的位姿等。但相反,我们可以发送一个 RPC 通知所有 Spaceship 实例在其 Weapon2D 节点上调用 fire() 方法。

这是一个快速且经济的方法。由于 Bullet 节点具有恒定的轨迹,没有必要同步它们的运动属性。唯一相关的是它们开始的位置,即生成位置,以及它们应该移动的方向。这两个属性已经通过 Player 节点的 MultiplayerSynchronizer 节点同步。因此,我们可以利用它们。这是个不错的技巧,对吧?

要做到这一点,打开 res://09.prototyping-space-adventure/Actors/Player/Player2D.gd 脚本,并在 _process() 回调中,将 weapon.fire() 行更改为 weapon.rpc("fire"),如下面的代码片段所示:

func _process(delta):
  if Input.is_action_pressed(shoot_action):
    weapon.rpc("fire")

如果你现在测试游戏,你应该看到 Player 实例的 Spaceship 节点在所有游戏实例上射击子弹:服务器和客户端。在下面的屏幕截图中,我们可以看到一个玩家射击动作在服务器的游戏实例上被复制:

图 9.16 – Payer 场景的飞船节点在服务器的游戏实例上使用 RPC 函数射击子弹实例

图 9.16 – Payer 场景的飞船节点在服务器的游戏实例上使用 RPC 函数射击子弹实例

现在既然 Player 实例可以在游戏的每个对等实例上射击子弹,我们需要了解谁应该管理被 Bullet 节点击中的对象的伤害计算和破坏。我们已经讨论过这个问题,这个责任属于服务器。

但我们如何做到这一点?在下一节中,我们将分解 Asteroid 节点的行为,以便区分客户端和服务器端应该发生什么。

计算小行星的破坏力

在这里,是时候大量使用multiplayer.is_server()方法了。我们需要分解Asteroid节点的行为,并确定当客户端游戏实例上的Bullet实例击中Asteroid节点时应该发生什么,以及当这些Bullet实例击中服务器上的Asteroid节点时应该发生什么。

打开res://09.prototyping-space-adventure/Objects/Asteroid/Asteroid.gd脚本,让我们实现承受伤害的行为,同时尊重连接每一方的责任:

  1. 我们需要做的第一件事是防止在当前对等方不是服务器的情况下对Asteroid节点应用任何伤害。因此,在客户端的游戏实例中,Bullet节点应该击中Asteroid节点并消失,但不应对Asteroid节点应用伤害。为此,在_on_hurt_area_2d_damage_taken()回调中,我们将检查对等方是否是服务器,如果是,我们将调用apply_damage()方法,并将damage作为参数传递:

    func _on_hurt_area_2d_damage_taken(damage):
        if multiplayer.is_server():
            apply_damage(damage)
    
  2. 现在,在apply_damage()方法中,在进行适当的计算后,服务器必须告诉所有对等方的Asteroid实例播放它们适当的动画,无论是播放"hit"还是"explode"。但这里有个技巧:animator没有RPC方法来做这件事。

    因此,我们将提取这种行为到两个 RPC 方法中,并调用这些方法。这些 RPC 方法应由网络权限调用,并且也应该在本地调用:

    func apply_damage(damage):
      health -= damage
      if health < 1:
        rpc("explode")
      elif health > 0:
        rpc("hit")
    @rpc("authority", "call_local")
    func explode():
      animator.play("explode")
    @rpc("authority", "call_local")
    func hit():
      animator.play("hit")
    
  3. 最后,我们需要做的是只允许服务器在Asteroid节点上调用queue_free()方法,防止玩家作弊并以不可预测的方式完成任务。为此,在_on_animation_player_animation_finished()回调中,我们将检查对等方是否是服务器,并在当前动画是"explode"时调用queue_free()

    func _on_animation_player_animation_finished(anim_name):
      if multiplayer.is_server():
        if anim_name == "explode":
          queue_free()
    

    由于服务器,即Asteroid节点的多人游戏权限,正在从其SceneTree中移除Asteroid实例,因此World节点的AsteroidsMultiplayerSpawner节点将确保在客户端游戏实例上生成的Asteroid实例也会被移除。Godot 引擎的网络 API 不是非常聪明吗?

因此,连接的每一方都在履行其职责。客户端根据Asteroid节点的状态播放动画,而服务器端处理Bullet节点击中Asteroid节点的实际影响。在本节中,我们看到了当需要在所有对等方远程复制行为时如何解决这个问题,但内置类,例如AnimationPlayer,没有提供这样做的方法。

我们还学习了如何分离事物,并赋予连接的每一方执行其责任的能力。虽然客户端必须实例化子弹并处理射击的所有处理,但服务器端通过处理Bullet节点造成的伤害和处理Asteroid节点的生命周期来完成其部分工作。

在下一节中,我们将通过将相同的原则应用于任务系统来加强这一知识。玩家如何检索他们的任务?客户端的责任是什么?客户端是否应该存储玩家的进度?至于服务器呢?它是如何处理客户端请求并在游戏会话之间保持信息一致性的?这正是我们要讨论的内容。

在服务器上存储和检索数据

当涉及到在线冒险游戏时,处理一个敏感话题的时间到了:数据库。请注意,在本书中,我们不是关注处理和保护数据库的最佳和最安全的方法。相反,我们正在实践和理解 Godot 引擎网络 API 允许我们实现的内容,并探索其可能性。

话虽如此,在本节中,我们将实施建立通信通道所需的必要步骤,以便客户端可以从服务器检索其任务数据,并将进度更新发送到服务器。

为了做到这一点,我们将与我们的任务系统中的两个主要类一起工作,即QuestSingleton节点和QuestDatabase节点。但在我们为这个新挑战设置这些类之前,我们需要更改数据库的结构。由于现在QuestDatabase节点将通过交付和处理多个玩家的数据来工作,因此PlayerProgress.json文件中的数据需要与一个用户链接。因此,让我们创建这些假用户,与FakeDatabase.json文件中的用户匹配,并存储这些任意数据。

打开res://09.prototyping-space-adventure/Quests/PlayerProgress.json文件,并为user1user2创建两个新键。然后,为每个用户键添加一些与原始结构匹配的手动数据:

{
  "user1":{
    "asteroid_1":{
      "completed":false,
      "progress":4
    }
  },
  "user2":{
    "asteroid_1":{
      "completed":false,
      "progress":2
    }
  }
}

现在,考虑到这一点,请记住,我们可以,也应该使用我们存储在AuthenticationCredentials单例中的用户来引用任何敏感的用户数据。这很重要,因为这是我们正确管理用户请求并相应地提供数据的方式。在下一节中,我们将看到QuestSingleton节点应该如何检索和更新任务数据。

实现任务系统的客户端

现在是时候让我们的任务系统准备好在网络中工作,在这个网络中,玩家可以从远程数据库检索任务,对他们进行进度,并存储他们的信息以备将来使用。在本节中,我们将了解QuestSingleton节点在这个在线多人游戏环境中的角色。

因此,让我们打开res://09.prototyping-space-adventure/Quests/QuestSingleton.gd脚本并开始。在以下说明中,我们将了解如何从远程QuestDatabase检索和更新任务数据:

  1. retrieve_quests() 方法需要全面、但更简单、更优雅的改进。在这个在线环境中创建新任务的最简单方法就是让客户端请求远程 QuestDatabase 创建它们。我们将在 实现任务系统的服务器 部分 中深入了解这是如何发生的。

  2. 但现在,我们将等待 0.1 秒以确保一切就绪,然后我们可以在 QuestDatabase 节点的 get_player_quests() 方法上执行 RPC,如果这个 QuestSingleton 节点不是服务器的话。请记住将 AuthenticationCredentials.user 属性作为参数传递给 get_player_quests() 方法:

    func retrieve_quests():
      if multiplayer.is_server():
        return
      await(get_tree().create_timer(0.1).timeout)
      QuestDatabase.rpc_id(1, "get_player_quests",
          AuthenticationCredentials.user)
    
  3. 现在,由于服务器的 QuestDatabase 节点将在客户端的 QuestSingleton 节点上创建任务,我们需要将 create_quest() 方法转换为只有授权者可以远程调用的 RPC 方法:

    @rpc("authority", "call_remote")
    func create_quest(quest_data):
    
  4. 最后,在 increase_quest_progress() 方法中,我们需要直接通过 RPC 调用服务器的 QuestDatabase.update_player_progress() 方法。别忘了也将 AuthenticationCredentials.user 作为参数传递:

    func increase_quest_progress(quest_id, amount):
      if not quest_id in quests.keys():
        return
      var quest = quests[quest_id]
      quest.current_amount += amount
      QuestDatabase.rpc_id(1, "update_player_progress",
          quest_id, quest.current_amount, quest.completed,
              AuthenticationCredentials.user)
    

因此,QuestSingleton 节点将请求服务器的 QuestDatabase 节点创建当前玩家的任务,并且每当玩家在任务中取得进展时,客户端的 QuestSingleton 节点会更新服务器的 QuestDatabase 节点关于当前任务进展的信息,最终将处理这些重要数据的责任委托给服务器。

现在,我的朋友们,是我们拼图中的最后一部分。在下一节中,我们将了解在将其置于在线多人游戏环境后,这个系统的服务器端是如何工作的。

实现任务系统的服务器端

在本节中,我们将向 QuestDatabase 节点添加必要的功能,以便它能够正确地提供和存储玩家的任务数据。为此,我们将广泛使用 RPC 与 multiplayer.is_server() 方法一起,以防止某些行为在玩家的 QuestDatabase 节点实例上发生。

我们这样做主要是为了只在服务器端维护任务数据,这样客户端就不会直接在自己的机器上作弊的诱惑。这将是一个内容丰富的部分,所以请稍作休息。到目前为止,我们已经了解了很多信息。休息一下,当你感觉准备好了,就立刻回来。

准备好了吗?好的,打开 res://09.prototyping-space-adventure/Quests/QuestDatabase.gd 脚本,让我们开始吧。在接下来的说明中,我们将调整当前 QuestDatabase 节点的现有方法,甚至创建新的方法,以便使其按预期工作,从 _ready() 方法开始:

  1. _ready() 回调中,我们应仅当当前节点是服务器时才加载数据库文件:

    func _ready():
      if multiplayer.is_server():
      load_database()
    
  2. 我们在 _notification() 回调中也做同样的事情。我们应该只存储文件,如果当前 peer 是服务器,所以让我们添加这个检查以及通知检查:

    func _notification(notification):
        if notification == NOTIFICATION_WM_CLOSE_REQUEST
           and multiplayer.is_server():
            store_database()
    
  3. 接下来是 get_player_quests(),它现在应该是一个任何 peer 都可以远程调用的 RPC 方法:

    @rpc("any_peer", "call_remote")
    func get_player_quests():
    
  4. 由于现在是一个 RPC,我们将存储请求此数据的客户端的 peer ID,这样我们可以在稍后响应请求时使用它:

    func get_player_quests():
        var requester_id = multiplayer.get_remote_
            sender_id()
    
  5. 然后,在方法签名中添加一个名为 user 的参数,这样我们就可以知道在 progress_database 字典中查看哪个键。记住,现在每个用户都有自己的键,其进度数据存储在 PlayerProgress.json 文件中,因此我们就是这样访问正确的用户数据的:

    func get_player_quests(user):
      var requester_id = multiplayer.get_remote_
          sender_id()
      var quests = {}
      for quest in progress_database[user]:
    
  6. 现在,我们在 get_progress()get_completion() 方法签名中需要做一些更改,对吧?我们需要将用户作为参数添加。所以,让我们使用这个参数来调用它们:

      for quest in progress_database[user]:
        var quest_data = {}
        quest_data["id"] = quest
        quest_data["title"] = get_title(quest)
        quest_data["description"] = get_description(quest)
        quest_data["target_amount"] = get_target_amount
            (quest)
        quest_data["current_amount"] = get_progress
            (quest, user)
        quest_data["completed"] = get_completion
            (quest, user)
        quests[quest] = quest_data
    
  7. 现在,我们需要修复方法的签名以匹配之前的更改,并允许它们接收一个 user 参数并访问 progress_database 中的 user 键:

    func get_progress(quest_id, user):
      return progress_database[user][quest_id]["progress"]
    func get_completion(quest_id, user):
      return progress_database[user][quest_id]
          ["completed"]
    
  8. 回到 get_player_quests() 方法——对于在 progress_database 字典中找到的每个用户的 quest 键,我们将直接向客户端 QuestSingleton 节点的 create_quest() 方法发送 RPC,传递我们刚刚创建的 quest_data 字典:

        for quest in progress_database[user]:
            var quest_data = {}
            quest_data["id"] = quest
            quest_data["title"] = get_title(quest)
            quest_data["description"] = get_description
                (quest)
            quest_data["target_amount"] = get_target_
                amount(quest)
            quest_data["current_amount"] = get_progress
                (quest, user)
            quest_data["completed"] = get_completion
                (quest, user)
            quests[quest] = quest_data
            Quests.rpc_id(requester_id, "create_quest",
                quest_data)
    
  9. 最后,让我们也将 update_player_progress() 转换为一个任何 peer 都可以远程调用的 RPC 方法。它也应该接收一个 user 参数来更新 progress_database 中正确的 user 键的进度。当然,这应该只发生在 QuestDatabase 节点的服务器实例上:

    @rpc("any_peer", "call_remote")
    func update_player_progress(quest_id, current_amount,
        completed, user):
      if multiplayer.is_server():
        progress_database[user][quest_id]["progress"] =
            current_amount
        progress_database[user][quest_id]["completed"] =
            completed
    

这样,我们就完成了客户端和服务器两端的任务系统和数据库管理逻辑。如果你测试 res://09.prototyping-space-adventure/MainMenu.tscn 场景并登录为用户,你将能够看到 QuestPanel 正确显示任务数据以及当前玩家在任务中的进度。在下面的屏幕截图中,我们可以看到 user2 的任务信息:

图 9.17 – QuestPanel 显示用户 2 的摧毁 10 银河系任务信息

图 9.17 – QuestPanel 显示用户 2 的摧毁 10 银河系任务信息

下面的屏幕截图显示了 user1 的任务信息。因此,我们可以假设我们的系统按预期工作,正确地加载、显示和修改玩家的任务进度:

图 9.18 – QuestPanel 显示用户 1 的摧毁 10 银河系任务信息

图 9.18 – QuestPanel 显示用户 1 的摧毁 10 银河系任务信息

到此为止,我们的自上而下的太空冒险原型已经准备好了!恭喜你一路走到这里。这一章全面测试了我们迄今为止所看到的一切,并结束了本书的第二部分,即“创建在线多人机制”,在这一部分中,我们创建了五个原型来学习 Godot 引擎高级网络 API 的细节。

希望到现在为止,你已经对独立构建和测试更多原型感到自信。在 第三部分优化在线体验,我们将从下一章开始讨论,我们将看到如何使用可用的工具来改进我们在 第二部分 中创建的体验。我们将讨论如何调试和配置网络,优化数据请求,实现预测和插值等优化技术,缓存数据,等等。

摘要

在本章中,我们学习了如何允许玩家在游戏运行过程中加入;如何同步他们的游戏实例;如何在远程数据库中加载、检索、发送和存储信息;如何创建任务系统;总的来说,如何构建在线多人冒险游戏的基本结构。在下一章中,我们将学习如何调试和配置网络,以便我们可以找到瓶颈和游戏改进和优化的潜在领域。那里见!

第三部分:优化在线体验

开发应用程序的一个核心方面,尤其是游戏,就是使体验流畅,并保持没有任何中断或延迟。当谈到游戏时,它们是实时体验,这一点尤为重要。因此,在本书的这一部分,我们学习了评估潜在瓶颈所需的调试和配置工具,然后我们实施技术来有效地优化从 第二部分 的最终项目的网络使用。

本部分包含以下章节:

  • 第十章, 调试和配置网络

  • 第十一章, 优化数据请求

  • 第十二章, 延迟和丢包补偿

  • 第十三章, 缓存数据以减少带宽

第十章:调试和监控网络

通过第九章**,创建在线冒险原型,我们结束了第二部分,创建在线多人机制,的旅程,其中我们学习了如何使用 Godot 引擎的高级网络 API 将本地游戏机制转换为在线多人机制。现在,是时候超越实现并开始优化我们的机制了。本章开启了第三部分,优化在线体验*,的旅程,通过 Godot 引擎创建在线多人游戏。

重要的是,你已经阅读、理解并实现了在第九章**,创建在线冒险原型*,中提供的内容,因为我们将在第三部分接下来的章节中,以最终项目作为我们的主要主题。

在这个特定的章节中,我们将了解如何使用 Godot 引擎内置的MultiplayerSynchronizers的性能,为我们提供一个关于网络实现中潜在问题的良好概述。最后,我们将学习如何使用调试器窗口的Performance单例来找出游戏中的潜在瓶颈并收集数据以设计潜在解决方案。

到本章结束时,你将了解如何使用强大的调试器工具,以下图中的元素不会再让你感到害怕;相反,它们将成为你最可靠的盟友:

图 10.1 – 调试器的网络分析器(顶部)和监视器(底部)显示并绘制分析数据

图 10.1 – 调试器的网络分析器(顶部)和监视器(底部)显示并绘制分析数据

如果你在本章结束时回到这个图并理解了每个图表和图表的含义,请不要感到惊讶。你会习惯它们的,因为它们将在下一章中大量出现,尤其是在第十一章**,优化数据请求*。

技术要求

如前所述,阅读并遵循在第九章创建在线冒险原型中提供的说明至关重要。在本章中,我们将使用上一章结束时你应该拥有的最终产品。你可以通过以下链接访问本章的资源:

github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0

准备好第九章**,创建在线冒险原型*的结果后,我们可以继续了解如何改进它。

介绍 Godot 的调试器

调试器是开发者的最佳助手。我们的大部分工作与创建和实现功能无关;相反,它与我们评估这些实现可能引起的问题并修复它们有关。调试器窗口是 Godot 引擎与我们交流的地方,显示错误、警告、资源消耗、对象计数等等。因此,我们应该仔细倾听并妥善处理它显示的问题和数据。我们甚至可以要求它跟踪自定义数据,正如我们将在使用监视器标签部分中看到的那样。

如果您已经使用 Godot 引擎开发游戏足够长的时间以至于遇到了错误,您可能比您希望的更频繁地遇到了调试器窗口,对吧?在本节中,我们将深入探讨如何将其变成我们的好朋友,并希望它能够经常出现。让我们从理解它的每个标签开始,如何阅读它们,以及可以期待它们提供什么,从最常见的一个开始,可能也是您已经遇到困难的一个:堆栈跟踪标签。

掌握堆栈跟踪标签

当您点击调试器窗口时,Godot 引擎的编辑器将打开堆栈跟踪标签。让我们使用以下图示来导航它,并理解其每个元素的功能。

图 10.2 – 调试器窗口的堆栈跟踪标签及其元素

图 10.2 – 调试器窗口的堆栈跟踪标签及其元素

您可以看到图中的堆栈跟踪标签的每个元素都与一个数字相关联,这将有助于更好地理解。在以下列表中,我们有元素名称及其简要说明:

  • 堆栈帧面板是导致错误或断点的函数堆栈(在图 10.2中突出显示并标记为1)。

  • 过滤堆栈变量字段是您可以过滤变量名称以在下面的面板中显示它们的地方(在图 10.2中突出显示并标记为2)。

  • 成员面板是您可以找到给定脚本中的变量的地方,包括临时变量和特定作用域的变量。在这里,您还可以查看和编辑它们的值(在图 10.2中突出显示并标记为3)。

  • 断点面板是您可以查看给定实例脚本中达到的断点信息的地方(在图 10.2中突出显示并标记为4)。

  • 跳过断点按钮,当开启时,允许游戏执行忽略断点(在图 10.2中突出显示并标记为5)。

  • 复制错误按钮会将当前错误(如果有)复制到您的剪贴板(在图 10.2中突出显示并标记为6)。

  • 当应用暂停时,包括达到断点时,点击步入按钮将执行下一个脚本指令(即行)。它将进入它自然进入的缩进代码块,并执行整个代码(如图 10.2 中所示,高亮并标记为7)。

  • 当应用暂停时,包括达到断点时,点击步过按钮将执行下一个脚本指令(即行),但会跳过缩进的代码块(如图 10.2 中所示,高亮并标记为8)。

  • 断点按钮会使应用暂停,就像它达到了断点一样(如图 10.2 中所示,高亮并标记为9)。

  • 继续按钮会在应用暂停时恢复应用(如图 10.2 中所示,高亮并标记为10)。

利用这些元素,我们能够对我们的脚本进行实验,并收集有关我们游戏的无价信息。例如,我们可以通过步入按钮逐步查看 Godot 引擎如何处理一组指令,并查看它执行的函数堆栈以及每个步骤中对象变量的变化。

一个使用这些功能的技巧是不要害怕在脚本中添加断点,以了解何时、什么、如何以及为什么对象发生变化,以及导致这些变化的整个事件链。

在本节中,我们已介绍了堆栈跟踪选项卡,它为我们提供了游戏流程的概述,并提供了多种方式来收集关于通过此流程发生的变化的信息,使我们能够理解导致特定变化的原因和效果的整个链条。这特别有助于我们下一个选项卡,即错误选项卡。我们将在下一节中讨论它。

使用错误选项卡进行调试

这听起来可能有些奇怪,但在许多情况下,你可能希望 Godot 引擎提示错误,尤其是在处理网络功能时,因为有时你会被留在那里等待某个事件发生。如果发送的数据包没有到达目的地,你将挂在那里等待错误提示出现,但数据包没有到达目的地本身并不是错误。然而,这仍然是一种不希望出现的情况,可能会让你感到困惑。

错误选项卡是您与其他数千名开发者一起工作的地方,他们参与了 Godot 引擎核心的开发,并识别了数千个错误并对其进行了记录,以便当它们发生时,您可以对问题有所了解,并能够修复它。

然而,这个选项卡不仅显示错误。错误选项卡还会显示关于您脚本的警告。它们不一定破坏您的应用程序,但这是您应该注意并做出决定的事情。例如,在函数的实现中未使用的参数通常会收到警告。以下图显示了错误选项卡及其与数字关联的元素,就像在掌握堆栈跟踪选项卡部分一样:

图 10.3 – 第 1 次会话调试器选项卡的错误选项卡及其元素

图 10.3 – 第 1 次会话调试器选项卡的错误选项卡及其元素

现在,让我们了解这些元素是什么以及它们如何对我们有用:

  • 错误和警告面板是显示所有警告和致命及非致命错误的地方。您可以点击一个错误或警告来展开它,并跳转到触发它的脚本行。您也可以双击来展开一个错误或警告,并显示导致错误的代码堆栈。当您双击展开的错误或警告时,它会折叠(在图 10.3中突出显示并标记为1)。

  • 展开全部按钮会展开所有错误和警告(在图 10.3中突出显示并标记为2)。

  • 折叠全部按钮会折叠所有错误和警告(在图 10.3中突出显示并标记为3)。

  • 清除按钮会清空错误和警告面板(在图 10.3中突出显示并标记为4)。

处理错误和警告的一个有趣之处在于,您可以创建自己的错误或警告消息。当与队友一起工作时,这尤其有用,而且,由于我们正在处理多个游戏实例运行,这也是将消息分离开到它们各自的print()语句来源的好方法。因此,您可以使用push_error()push_warning()内置方法,Godot 将只在触发错误或警告的游戏会话的调试器选项卡中显示它们。以下图展示了会话 3错误选项卡,其中展开了一个自定义警告,以便我们可以看到它的来源:

图 10.4 – 第 3 次会话错误选项卡突出显示一个自定义警告和其他内置警告

图 10.4 – 第 3 次会话错误选项卡突出显示一个自定义警告和其他内置警告

注意到底部的调试器按钮告诉我们总共有 20 个错误和警告,但当我们打开会话 3中的错误选项卡时,只有 13 个。这是因为其他错误来自其他会话,它们位于各自的错误选项卡中。

在我们的装备中有了这个强大的工具,我们可以在每个单独的游戏会话中触发各种错误和警告,这样我们就可以区分哪个会话是服务器,哪些是玩家(如果有任何对等体得到一个其他人没有的特定错误),等等。在下一节中,我们将讨论我们的第一个基于性能的调试选项卡,即Profiler选项卡,在那里我们可以看到我们的游戏性能如何,它消耗了多少资源,以及哪些对象和函数消耗了我们电脑的最大资源。

探索 Profiler 选项卡

大多数开发者总是在寻找最有效、最经济、最巧妙的优化,以便让他们的代码在烤面包机中运行。好吧,虽然这是一个美好而美丽的幻想,但现实是,除非你真的需要,否则你不应该如此专注于优化你的代码。行业内有一句话,“过早优化是应用程序的厄运。”

这里要关注的是过早这个词。

因此,如果过早优化是件坏事,但优化本身是件好事,那么何时优化你的游戏或应用程序才是正确的时机呢?答案并非一成不变,也没有一个明确的点可以指出并说“在这里,经过 X 天的开发,是时候进行优化了”,或者“当你达到 80% 的生产水平时,是进行优化的信号。”不,相反,你应该在问题出现时解决它们,并养成诊断游戏性能并决定是否根据你目标受众的电脑配置来挤压一些资源的习惯。这可以在游戏发布的第一天发生,或者游戏发布多年后。

因此,你需要养成定期查看游戏性能和寻找改进区域的习惯。

Profiler选项卡是你在优化中的最佳盟友之一。正是在这个选项卡中,你会看到渲染时间、物理模拟时间、音频处理时间,甚至你自定义脚本函数处理所需的时间和它们被调用的次数。让我们看一下下面的图,并了解Profiler选项卡如何显示所有这些信息。

图 10.5 – 第 2 次会话的调试器停靠 Profiler 选项卡及其元素

图 10.5 – 第 2 次会话的调试器停靠 Profiler 选项卡及其元素

让我们再次根据图中的编号来理解每个元素的作用:

  • 函数面板显示当前可用的函数,这些函数是分析器可以跟踪的(在图 10.5中突出显示并标记为1)。

  • 开始按钮初始化测量。请注意,如果不切换这个选项,分析器将不会做任何事情。分析非常消耗资源,所以默认情况下它是关闭的(在图 10.5中突出显示并标记为2)。

  • 清除按钮清除当前收集和显示的数据(在图 10.5中突出显示并标记为3)。

  • 测量下拉菜单允许我们更改我们想要测量的数据类型(在图 10.5中突出显示并标记为4)。当前选项如下:

    • 帧时间(毫秒)是 Godot 引擎处理一个帧所需的时间。

    • 平均时间(毫秒)是一个函数处理所需的时间。这是对任何给定函数的每次调用的时间的平均值。

    • 框架百分比是给定函数相对于帧渲染时间的百分比。例如,资源密集型函数会占用更大的百分比。

    • 物理框架百分比框架百分比相同,但相对于物理框架处理过程。

  • 时间范围下拉菜单允许我们更改我们想要测量的函数的时间范围(在图 10.5中突出显示并标记为5),并且有以下选项:

    • 包含,这将考虑一个函数及其所有嵌套函数渲染所需的时间

    • 自我,这只会考虑每个函数的个体时间,而不考虑被测量的函数所调用的函数调用

  • 帧编号步进器或旋转框标记了你当前正在评估的帧(在图 10.5中突出显示并标记为6)。更改帧编号将允许你在函数面板中准确看到与该帧相关的函数测量。

  • 测量图面板是数据被绘制的地方,因此我们可以看到它并访问任何异常数据。每个测量的函数都有其自己的颜色,以便在图表上容易看到它(在图 10.5中突出显示并标记为7)。

分析器是一个强大的盟友,它使我们能够访问有关资源管理的重要数据。现在我们了解了如何使用它,让我们继续到调试器停靠上的第二个分析器,即视觉分析器

这一个专注于视觉资源和潜在瓶颈,以便我们可以改进我们的游戏在渲染和其他视觉过程方面的视觉效果。

探索视觉分析器选项卡

除了知道你的函数从 CPU 中消耗了多少处理资源外,评估与渲染相关的任务(如剔除、光照和绘制调用)从 GPU 中消耗了多少也同样重要。视觉分析器工具可以帮助你跟踪导致 CPU 和 GPU 渲染帧延迟最长的原因。通过识别由渲染引起的潜在瓶颈的来源,你可以优化 CPU 和 GPU 的性能。

视觉分析器选项卡与分析器选项卡非常相似,但专注于跟踪和测量与渲染相关的任务。看看以下图表,了解视觉分析器选项卡如何显示所有这些信息。

图 10.6 – 第 3 次会话的调试器停靠视觉分析器选项卡及其元素

图 10.6 – 第 3 次会话的调试器停靠视觉分析器选项卡及其元素

为了更深入地理解这些元素中的每一个,让我们更仔细地看看它们各自的作用。再次强调,我们将按照数字顺序来跟踪这些元素:

  • 任务面板显示按类别划分的与渲染相关的任务。请注意,它们被分解为相关视口和画布层等元素(在图 10.6中突出显示并标记为1)。

  • 开始按钮,就像在分析器标签页中一样,初始化分析。默认情况下,视觉分析也被关闭(在图 10.6中突出显示并标记为2)。

  • 清除按钮清除分析会话中收集的当前数据(在图 10.6中突出显示并标记为3)。

  • 测量下拉菜单(在图 10.6中突出显示并标记为4)允许我们选择两个测量选项:

    • 帧时间(毫秒)是渲染一个帧所需的时间(以毫秒为单位)

    • 帧百分比是指给定过程从给定帧的渲染时间中占用的百分比

  • 适应帧复选框将图形适应到默认帧比例(在图 10.6中突出显示并标记为5)。取消选中它以将图形适应到超过 60 每秒帧数FPS)的部分。

  • 链接复选框将 CPU 和 GPU 图表缩放到相同的比例(在图 10.6中突出显示并标记为6)。

  • 帧编号步进器,就像在分析器标签页中一样,标记了你正在评估的当前帧。在任务面板中显示的渲染任务与这个帧相关(在图 10.6中突出显示并标记为7)。

视觉分析器是优化游戏渲染性能时的另一个强大盟友,它是一个改变游戏规则的工具,可以帮助你评估可能造成游戏卡顿和帧降的原因。在下一节中,我们将了解另一个可用于评估游戏健康状况的强大工具,即监视器标签页,在那里我们可以找到有关游戏的各种有趣信息。

好吧,让我们深入探讨,以便我们了解这些以及其他可用数据如何帮助我们解决游戏中性能的潜在问题。

探索监视器标签页

这里是你真正感觉像一名游戏医生的地方。监视器允许我们将重要数据作为图表来评估,并查看游戏的整体健康状况。在这个标签页中,我们可以通过图表跟踪与性能相关的数据。默认情况下,它显示一些有用的数据,如下所示:

  • 与时间相关的数据,例如FPS、进程时间和物理进程时间

  • 与内存相关的数据,例如静态内存、动态内存和消息缓冲区

  • 与对象相关的数据,例如总对象数、资源数、节点数和孤儿节点

有一系列属性您可以跟踪并绘制到图表中,以便您分析游戏的健康状况并发现潜在的改进区域。在下面的图中,您可以看到带有一些属性被跟踪和绘制的调试器监视器选项卡。请注意,这些属性默认在左侧面板中已打开。监视器选项卡将只为我们在左侧面板中打开的属性绘制图表:

图 10.7 – 第 2 次会话的调试器停靠监视器选项卡及其元素

图 10.7 – 第 2 次会话的调试器停靠监视器选项卡及其元素

监视器选项卡似乎是我们迄今为止看到的选项卡中最简单的,但它仍然非常强大,所以让我们了解构建它的两个核心元素:

  • 监视器面板是我们可以找到可用监视器的地方。监视器是标记为跟踪的数据。请注意,默认情况下有大量的监视器。通过使用它们,我们可以获得有关项目健康状况的一些有价值的信息(在图 10.7中突出显示并标记为1)。

  • 图表面板是监视器作为图表绘制的地方,每个监视器都有自己的图表和度量。只有监视器面板中检查的监视器才会在图表面板中绘制(在图 10.7中突出显示并标记为2)。

注意,在监视器选项卡上没有开始停止清除按钮。这是因为 Godot 将始终跟踪可监视的数据。

关于性能单例的一些有趣之处。我们将在识别项目的瓶颈部分讨论这个问题,在那里我们还将深入讨论监视器面板。在下一节中,我们将讨论视频 RAM选项卡,在那里我们可以评估我们的视频相关资源。

了解视频 RAM 选项卡

当您想了解哪些资源对您的视频内存影响最大时,视频 RAM选项卡非常有用。这在 3D 游戏中非常有帮助,但也可以用于 2D 游戏 – 例如,当我们想评估是否需要将更多的精灵打包到一个纹理中时。

视频 RAM选项卡是一个相当简单的面板,其中包含您评估视频相关内存消耗所需的基本信息。在下面的图中,我们可以看到它由一个包含四个列的单个表格组成,位于面板内部:

图 10.8 – 第 2 次会话的调试器停靠视频 RAM 选项卡面板

图 10.8 – 第 2 次会话的调试器停靠视频 RAM 选项卡面板

这是一个直观的面板,包含我们理解资源视频内存使用所需的数据。让我们了解每一列所呈现的信息类型:

  • 资源路径是 Godot 引擎项目中资源的路径。

  • AtlasTexture资源或一组简单的纹理,例如。

  • 格式列是我们可以找到有关文件格式的数据的地方。

  • 使用 是我们最终想要的。它回答了一个重要的问题:鉴于所有之前的信息,这个资源占用了多少内存?

注意

如果你想导出表格并执行一些表格操作或创建图表,可以选择将表格保存为 CSV 文件。这在演示中可能非常有用。

调试部分中的 Control 节点。

理解 Misc 标签

如前所述,在 Control 节点中点击的调试器 Control 节点正在消耗输入,如果我们可以有一个负责该功能的另一个 Control 节点,那么我们应该修复这个问题。例如,当你有一个用于淡出屏幕的 ColorRect 节点时,这是常见的。如果你没有将 鼠标过滤器 设置为 忽略,它将消耗鼠标事件并阻止玩家与其他 UI 元素交互。在下面的图中,我们有我们游戏的 Misc 标签:

图 10.9 – 第 3 次会话的调试器停靠 Misc 标签及其元素

图 10.9 – 第 3 次会话的调试器停靠 Misc 标签及其元素

Misc 标签非常简单,我们用它做不了太多事情。尽管如此,当我们想要处理与界面相关的问题时,它仍然是一个很好的伴侣。让我们了解构建这个调试工具的元素:

  • 调试部分中的 Control 节点(在 图 10.9 中突出显示并标记为 1)。

  • Clicked Control Type 行显示被点击控件的类型(在 图 10.9 中突出显示并标记为 2)。

  • SceneTree 实例(在 图 10.9 中突出显示并标记为 3)。

  • Set From Tree 按钮没有官方文档,并且似乎一直处于禁用状态,所以我们无法测试这个按钮的功能(在 图 10.9 中突出显示并标记为 4)。

  • Clear 按钮清除之前提到的行中的数据(在 图 10.9 中突出显示并标记为 5)。

  • Export measures as CSV 按钮允许你导出一个包含上述行数据的 CSV 文件。这可能有助于跟踪游戏流程,基于与控制器的交互(在 图 10.9 中突出显示并标记为 6)。

这个标签的一个很好的用途可能是 点与点击 游戏。由于这个游戏中大多数交互都是通过鼠标点击完成的,我们可以使用调试器的 Misc 标签来识别导致特定事件的元素。例如,当在显示对话框时点击菜单,哪个应该消耗鼠标点击?好吧,如果你选择的那个没有消耗输入,你可以使用调试器的 Misc 标签来查看发生了什么。

我们刚刚介绍了几乎所有可以用来调试和性能分析的游 戏工具。唯一缺少的一个,对你来说,作为我们假想工作室的网络工程师,是最重要的一个。网络分析器就是你在其中找到你的 RPC 调用和同步器影响的地方,以及其他与高级网络 API 相关联的相关信息。让我们直接进入正题吧!

理解网络分析器

是时候遇见你的最佳盟友了,它将帮助你解决作为我们虚构工作室的网络工程师时遇到的相关问题,并在你的旅程中提出潜在解决方案。正如其名所示,网络分析器是一个专门从事网络相关分析的分析器。它显示了关于 RPCs 的大小和数量信息,包括发送和接收的,发起和接收 RPCs 的节点,MultiplayerSynchronizer节点的网络消耗和同步次数,甚至还有一个带宽计,这些都是我们评估我们网络代码影响所需的一切。

注意,默认情况下,网络分析器仅跟踪高级网络 API 带宽。因此,如果您使用的是低级方法,例如 PacketPeerUDPUDPServerStreamPeerTCPTCPServer,它们的消耗可能默认不会被网络分析器考虑在内。我们将在 使用监控器 标签 部分中看到如何解决这个问题。

让我们深入了解网络分析器标签页中可用的功能。同样,界面中的每个元素都将编号以便进一步参考。

图 10.10 – 第 2 次会话的调试器坞网络分析器选项卡及其元素

图 10.10 – 第 2 次会话的调试器停靠网络分析器选项卡及其元素

尽管网络分析器比其他分析器元素少,但每个元素也更加复杂。你可能也注意到了,没有图形元素,对吧?因此,评估这些数据可能稍微不太自然。但让我们了解每个元素的功能以及我们如何使用它们:

  • RPC 面板显示每个发送和接收 RPC 的节点(带有高亮和标记为 Asteroid 的节点有一个 3,这可能是由于它们接收了 3 次处理伤害的调用,并在第三次调用后立即被摧毁)。

  • Weapon2DOutgoing RPC 值很大,因为它一直在告诉其同伴实例发射子弹。

    • Down 显示在此分析会话期间每秒下载了多少字节

    • Up 显示在此分析会话期间每秒上传了多少字节* MultiplayerSynchronizer 节点,它们的 SceneReplicationConfig 资源,默认情况下总是内置的,同步计数以及以字节为单位的同步大小(突出显示并标记为 MultiplayerSynchronizer 节点的场景* MultiplayerSynchronizer* 与 MultiplayerSynchronizer 相关的 SceneReplicationConfig 资源* MultiplayerSynchronizer 节点同步了其复制数据* 大小 列显示在当前分析会话期间同步所占用数据的总大小,以字节为单位

拥有所有这些信息,我们可以理解我们的工作是如何影响项目整体性能的。了解节点调用其 RPC 的次数,其他节点调用其 RPC 的次数,交换的数据量等等,可以帮助我们正确地处理玩家需要以正确方式玩游戏所需的必要带宽,并优化游戏以适应网络配置较低的玩家。

在下一节中,我们将学习如何使用我们迄今为止看到的强大工具,通过网络分析器来发现我们网络方法中的瓶颈,并通过向 监视器 选项卡添加自定义监视器来扩展我们的分析。这样,我们可以确定 Godot 向我们报告的内容。

识别项目的瓶颈

在本章中我们迄今为止所看到的所有工具都在我们手中,现在是时候使用它们来评估我们项目的健康状况并寻找改进区域。由于你的重点是网络,我们将专注于与此区域相关的功能。在本节中,我们将使用 第九章 的最终版本,创建在线冒险原型 项目,使用 网络分析器监视器 调试工具来寻找改进区域。你将学习如何执行以下操作:

  • 分析入站和出站 RPC 计数和大小,以识别网络代码中的潜在瓶颈

  • 使用带宽计来跟踪总带宽消耗并提出可能的解决方案

  • 评估 MultiplayerSynchronizer 节点的同步计数和大小以优化复制数据

  • 创建自定义监视器以分析项目特定的相关数据并跟踪潜在问题

让我们开始使用这个工具,它将是我们识别与高级网络 API 相关问题的可靠伴侣——网络分析器。

使用网络分析器

在上一节中,我们看到了网络分析器,这是我们用于识别与高级网络 API 相关问题的最强大工具之一。在本节中,我们将更深入地探讨如何使用网络分析器来识别与 RPC 和 MultiplayerSynchronizer 节点相关的瓶颈。为了完成这项任务,我们将使用 第九章 的最终版本,创建在线冒险原型 项目。

如前所述,我们可以使用网络分析器来收集有关节点传入和传出 RPC 的大小和计数的信息,MultiplayerSynchronizer 节点的网络消耗,同步计数,甚至带宽计。通过理解和分析这些数据,我们可以识别我们网络代码中的潜在问题,并提出可能的解决方案。

首先,让我们仔细查看传入和传出的 RPC 计数和大小,以识别我们网络代码中的潜在瓶颈。我们还将使用带宽计来跟踪总带宽消耗,并提出可能的改进措施。

然后,我们将评估 MultiplayerSynchronizer 同步的同步计数和大小,以优化复制数据。

到本节结束时,您将更好地了解如何使用网络分析器来识别和解决与您游戏网络性能相关的问题。那么,让我们开始吧!

RPC 是一种简单高效地在网络上传递数据和触发远程事件的方式。然而,重要的是要谨慎使用它们,以避免网络过载。

在本节中,我们将分析与我们项目 RPC 相关的数据,并探讨可能的改进。我们将在下一章中实施解决方案,但就目前而言,我们的重点是学习如何批判性地查看数据并做出明智的决定。

我们首先打开三个游戏会话来播放 res://09.prototyping-space-adventure/MainMenu.tscn 场景。让我们在所有三个会话上启动网络分析器。

图 10.11 – 第 1 次会话调试器网络分析器开始分析

图 10.11 – 第 1 次会话调试器网络分析器开始分析

然后,让我们选择一个作为服务器,而使用其他作为客户端,换句话说,作为玩家。要启用多个游戏会话,您可以在“调试”→“运行多个实例”菜单中选择“运行 3 个实例”选项。

在所有三个会话都打开的情况下,让我们确定哪个是服务器。为此,打开调试器的“服务器按钮”。在我的情况下,它是第 2 次会话的游戏实例,如图下所示。

图 10.12 – 使用第 2 次会话调试器“其他”选项卡来查找服务器的游戏实例

图 10.12 – 使用第 2 次会话的调试器“其他”选项卡来查找服务器的游戏实例

现在我们知道了 QuestDatabase

为了测试这个修改是否有效,我用其中一个玩家的游戏实例摧毁了小行星,所以你也去做同样的事情。在摧毁所有 30 个小行星后,让我们分析网络分析器收集的数据。此时,如果你想的话,可以停止网络分析器。在下面的图中,我们有第 1 次会话的数据,所以我们可以假设它是一个客户端。

图 10.13 – 第 1 次会话调试器网络分析器显示其收集的数据

图 10.13 – 第 1 次会话调试器网络分析器显示其收集的数据

让我们从对 RPC 计数和大小的简要分析开始。你可以看到,在第一行,我们有一个玩家Spaceship/Weapon2D,它的fire()方法被另一个客户端的游戏实例调用,所以我们可以假设摧毁小行星的玩家使用了第 3 次会话

这个客户端调用了这个方法 693 次。fire()方法不依赖于任何需要流式传输这么多次数的数据。Weapon2D本质上有两个主要状态:

  • 发射

  • 未发射

这意味着我们可以通过在玩家按下发射动作和释放发射动作时通过网络发送一个布尔值来改进这个 RPC 计数。同时,Weapon2D本身会在这两个状态之间切换,发射和不发射,并使用process()根据它们的发射速率生成子弹。这将大大减少这个 RPC 计数。

你是否注意到,在构建你的项目并沿途调整时,这项评估有多么重要?真是太酷了,对吧?

接下来,让我们看看第四行的QuestDatabase节点。这是唯一一个有出站 RPC 计数的节点,对吧?所以,它正在向服务器的游戏实例发送请求。它总共发送了 30 个 RPC,但请注意,它们的大小与 693 个传入的fire() RPC 相比要大得多。这意味着通过这个 RPC 传输的数据更大。我们应该注意这一点。这很可能是update_player_progress()方法。请注意,我们有 30 个小行星,每次我们摧毁其中一个,我们就会向update_player_progress()方法发送一个 RPC。计数是正确的,我看不出在这方面有明显的改进空间。它的比例是 1:1 – 一个事件,一个触发器。所以,我们很可能会找到一种方法来改进数据;也许通过某种方式压缩它以减少整体带宽。

最后,让我们看看小行星的 RPC 计数。每一个都只接收 3 个 RPC;这可能是由于服务器的子弹击中小行星,导致服务器在客户端实例上调用hit()方法两次。然后,当子弹第三次击中时,它调用explode()方法

小行星。看起来这个类在客户端的 RPC 计数方面相当健康。在这一关系的一侧没有需要改进的地方。让我们看看服务器端的情况。以下图展示了服务器的网络分析器。请注意,在这个测试中,服务器由游戏的 会话 3 实例表示。

图 10.14 – 会话 3 调试器网络分析器显示其收集的数据

图 10.14 – 会话 3 调试器网络分析器显示其收集的数据

服务器在其 小行星 实例上有出站 RPC,并且还有一个 SceneTree 实例的总数,因此没有必要在服务器上播放动画。理想情况下,服务器将是一个无头实例,因此在上面播放动画真的没有必要。但我们现在不会深入探讨这些领域。在当前项目中,我们可以将 hit() 方法的 RPC 注解更改为仅远程调用,而不是本地调用。这样,至少击中动画只会播放客户端的一侧。

让我们以正在处理的 小行星 为例,并对它们的 MultiplayerSynchronizer 节点进行分析。您可以在右侧面板的 World 节点中看到它调用了 sync_world() 方法。之后,就没有必要继续更新小行星的属性了。因此,我们可以在 sync_world() 方法内部使用小行星的 MultiplayerSynchronizer update_visibility() 方法,并减少带宽消耗。

通过使用网络分析器,我们已经确定了改进的区域,例如减少发送到 Weapon2D.fire() 方法的 RPC 数量,并手动调用 MultiplayerSynchronizer 同步以减少整体带宽。我们还看到,我们可以将 Asteroid.hit() 的 RPC 注解更改为仅远程调用,而不是本地调用,以减少服务器端不必要的动画。

好吧,仅仅通过简要分析,我们就发现了明显的改进区域,不是吗?而且我们还没有完成评估!在下一节中,我们将看到如何使用 Performance 单例创建自定义监视器,并在监视器跟踪中跟踪它们。

使用监视器标签页

在上一节中,我们学习了网络分析器以及它是如何帮助我们识别游戏中网络性能的潜在瓶颈。在本节中,我们将关注 Godot 引擎中的另一个强大的调试工具:监视器标签页。

监视器标签页允许我们实时跟踪和分析特定的数据点。我们可以用它来跟踪变量、函数,甚至是我们自己定义的自定义数据点。通过监控这些数据点,我们可以深入了解项目性能,并识别出需要改进的领域。

除了内置的监视器外,我们还可以创建自定义监视器来跟踪项目中特定的变量或函数。为此,我们需要使用 Performance.add_custom_monitor() 方法,传递一个 ID、一个 callable 实例,以及可选的数组作为参数。Godot 将在 id 参数中创建一个监视器,并使用传递给 callable 参数的 Callable 实例跟踪数据。这意味着每次我们触发应计入数据跟踪的事件时,我们需要执行 callable 实例。

在本节中,我们将使用 QuestDatabase 节点和 QuestSingleton 节点。通过监控这些数据点,我们将深入了解我们的任务系统性能,并识别潜在的改进区域。

让我们从打开 res://09.prototyping-spaceadventure/Quests/QuestDatabase.gd 脚本开始。我们将创建一个成员变量来跟踪 QuestDatabase.update_player_progress() 方法被调用的次数。我们可以将此变量命名为 quest_update_count 并将其默认值设置为 0。然后,我们需要创建一个返回其当前值的方法;让我们称这个方法为 get_quest_update_count()

func get_quest_update_count():
     return quest_update_count

要更新 quest_update_count,请在服务器成功更新给定任务中玩家的进度后增加其值。因此,在 update_player_progress() 方法中,在 if multiplayer.is_server() 语句内添加一行代码,将 quest_update_count 增加 1

@rpc("any_peer", "call_remote")
func update_player_progress(quest_id, current_amount, completed, user):
     if multiplayer.is_server():
          progress_database[user][quest_id]["progress"] = current_amount
          progress_database[user][quest_id]["completed"] = completed
          quest_update_count += 1

有了这些,我们已经准备好将 get_quest_update_count() 添加到我们的 _ready() 回调中,创建一个指向 QuestDatabaseCallable 变量,使用 self 关键字,并指向 "get_quest_update_count"。我们可以将这个 Callable 变量命名为 callable 以简化过程:

func _ready():
     if multiplayer.is_server():
          load_database()
          var callable = Callable(self, "get_quest_update_count")

然后,让我们调用 Performance.add_custom_monitor() 方法。为了保持组织有序,我们将使用名为 "Network" 的类别来命名我们的自定义监视器。因此,在 id 参数中,我们将传递 "Network/Quests Updates" 并将 callable 作为第二个参数传递:

func _ready():
     if multiplayer.is_server():
          load_database()
         var callable = Callable(self, "get_quest_update_count")
          Performance.add_custom_monitor("Network/Quests Updates", callable)

让我们从打开 res://09.prototyping-space- adventure/Quests/QuestDatabase.gd 脚本开始。我们将创建一个成员变量来跟踪方法被调用的次数。

现在,为了测试这个自定义监视器是否正常工作并评估它将提供的数据,让我们使用三个调试会话来测试游戏,并使用其中一个客户端销毁一些 小行星。这次,我的服务器在 Session 2。以下图展示了 Session 2监视器 选项卡。您可以在 监视器 面板的底部找到 Quests Updates 监视器;勾选复选框,Godot 将显示跟踪数据。

图 10.15 – 显示 Quests Updates 跟踪数据的 Session 2 调试器监视器选项卡

图 10.15 – 显示 Quests Updates 跟踪数据的 Session 2 调试器监视器选项卡

注意到 Godot 只计算了 58 个任务更新。所以我可能错过了一颗小行星。有趣,对吧?为什么一颗 小行星 会触发对 QuestDatabase.update_player_progress() 方法的两次调用?好吧,记住,目前任务进度是在所有对等体之间共享的,所以这可能会呈指数增长。如果有 3 个玩家,就会有 96 次对 QuestDatabase.update_player_progress() 的调用。我们需要找出一种限制它的方法。一个快速的解决方案是检查任务是否已经完成,如果是,就停止更新它。这将把这个特定的任务限制为每个玩家 10 次调用,这将是一个很好的改进。

让我们进行这个比较,仅用于测试目的。打开 res://09.prototyping-space-adventure/Quests/QuestSingleton.gd 并创建一个监控器,该监控器只会递增,直到任务达到完成任务所需的目标数量。为此,让我们创建一个新的成员变量名为 increase_count 并将其默认值设置为 0

var increase_count = 0

然后,让我们创建一个名为 get_quest_increases() 的方法,该方法将返回这个变量:

func get_quest_increases():
  return increase_count

_ready() 回调中,如果这是一个客户端实例,我们将使用之前的方法作为 callable 添加一个新的自定义监控器,就像我们使用 QuestDatabase.get_quest_update_count() 一样:

func _ready():
  if not multiplayer.is_server():
    var callable = Callable(self, "get_quest_increases")
    Performance.add_custom_monitor("Network/Quest Increases", callable)

现在,在 increase_quest_progress() 方法中,我们将创建一个 if 语句,该语句只有在 quest.current_amount 小于 quest.target_amount 时才会递增 increase_count

func increase_quest_progress(quest_id, amount):
  if not quest_id in quests.keys():
    return
  var quest = quests[quest_id]
  quest.current_amount += amount
  QuestDatabase.rpc_id(1, "update_player_progress", quest_id, quest.current_am
ount, quest.completed, AuthenticationCredentials.user)
  if quest.current_amount < quest.target_amount:
increase_count += 1

让我们再次测试游戏,看看客户端的 监控 标签页中会发生什么。在下面的图中,有一些非常有趣的事情正在发生。

图 10.16 – 会话 2 调试器监控标签页显示的任务递增跟踪数据

图 10.16 – 会话 2 调试器监控标签页显示的任务递增跟踪数据

这次是 user2 凭据。这有什么相关性?注意,在这个游戏实例中,increase_count 只增加了四次。这是因为,在 res://09.prototyping-space-adventure/Quests/QuestDatabase.json 文件中,user2 已经摧毁了五颗小行星,所以它只需要再摧毁五颗来完成任务。这意味着我们甚至可以在游戏会话之间改进这个方面。玩家在游戏会话中取得的进步越多,如果我们实施这种方法,我们就需要向服务器发出越少的 RPC 调用;这不是很酷吗?

在本节中,我们学习了如何使用 Performance 单例在 Performance.add_custom_monitor() 方法中创建新的监控器。我们还看到了如何创建方法来收集有关我们游戏中潜在瓶颈的数据。最后,我们看到了一些潜在的修复方法,以解决我们在调试游戏时发现的问题,以便优化它。

摘要

有了这些,我们就结束了本章的内容!在本章中,我们介绍了调试器面板,这是一个强大的工具,用于评估和调试游戏中可能存在的问题,以及优化其性能。

我们探讨了堆栈跟踪标签页,它为我们提供了对游戏流程的概述,并提供了多种方式来收集关于整个流程中发生的变化的信息,使我们能够理解导致特定变化的原因和效果的整个链条。我们还讨论了错误标签页,这是我们与数千名参与 Godot 引擎核心开发的其他开发者一起工作的地方,我们识别了数千个错误并对其进行了记录,以便当它们发生时,我们能够对问题有所了解并修复它。

此外,我们还探讨了两个基于性能的调试标签页:性能分析器标签页和视觉性能分析器标签页。性能分析器标签页是你在这项任务中的最佳盟友之一,因为它允许你看到渲染时间、物理模拟时间、音频处理时间,甚至每个自定义脚本函数处理所需的时间和它们被调用的次数。视觉性能分析器标签页专注于跟踪和测量与渲染相关的任务,可以帮助你追踪在 CPU 和 GPU 上渲染帧时造成最大延迟的原因。

然而,本章的主角是通过分析工具收集的数据来分析MultiplayerSynchronizer节点。通过理解和分析这些数据,我们提出了优化网络代码的可能解决方案。除此之外,我们还学习了如何使用Performance单例并在监控器标签页中创建自定义监控器,以实时跟踪特定的数据点。通过监控这些数据点,我们获得了关于项目性能的见解,甚至对潜在的改进进行了测试。

在下一章中,我们将优化数据请求,特别是关于QuestDatabase.get_player_quests()方法中的任务数据。

通过优化我们请求和处理数据的方式,我们可以提高游戏性能并为用户提供更好的体验。在那里见!

第十一章:优化数据请求

欢迎来到第十一章优化数据请求,我们将使用在第十章中看到的工具,调试和性能分析网络,并最终改进我们在第九章中编写的网络代码,创建在线 冒险原型

在本章中,我们将通过分析我们游戏当前的状态,对带宽和吞吐量有更深入的了解。在第十章中,调试和性能分析网络使用网络分析器部分,我们看到了一些需要改进的地方,特别是在 MultiplayerSynchronizers 和QuestDatabase数据传输方面。因此,在这里,我们将看到如何减少请求数量,以及如何压缩和解压缩数据以减少带宽和吞吐量,使我们的游戏以更可靠和更优化的方式对更多人开放。

到本章结束时,你将了解到优化游戏有许多方法,而大多数优化将取决于游戏本身的具体需求。随着你的进步,你将培养出敏锐的判断力和对如何评估潜在改进区域以及你正在寻找的数据类型的理解,以及一些解决网络代码瓶颈的一般技术。

因此,本章将涵盖以下主题:

  • 理解网络资源

  • 减少请求数量

  • 使用ENetConnection进行数据压缩

技术要求

第十章中所述,调试和性能分析网络第三部分优化在线体验,是基于第九章中制作的项目最终版本,创建在线冒险原型,因此,阅读、实践和实施那里提出的概念是基本要求。

你可以通过以下链接获取开始本章所需的文件。这些文件包含了我们在第十章中实现的调试和性能分析网络

github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0/tree/11.optimizing-data-requests

同时,你也需要阅读并理解第十章 调试和性能分析网络中介绍的概念和工具,这样我们才能在假设你已经知道它们是什么以及如何正确使用它们的前提下继续前进。特别是,你需要理解调试器的网络性能分析器和监视工具是如何工作的。所以,如果你不确定如何使用这些工具,请花些时间回顾第十章 调试和性能分析网络,以便掌握这些和其他调试工具。

理解网络资源

我们已经提到了带宽和吞吐量的重要性;在第一章 设置服务器中,我们甚至对图 1.3图 1.4这一主题进行了简短的介绍和视觉表示。现在是我们深入理解这些概念的时候了,这些概念是网络使用优化的基础,也将是我们衡量我们向优化目标所取得的进步的主要资源。

通常情况下,我们的网络代码带宽越低、吞吐量越低,效果越好。当然,我们需要记住,所有的优化都应该保持游戏体验,所以我们处于一个非常微妙的地位。与其他处理、内存和图形优化不同,我们的工作不能创造“美丽的意外”,比如一个可能导致酷炫机制的优化。不,我们作为网络工程师的职责是复制已经建立好的机制和效果到网络中的所有节点。让我们了解我们是如何做到这一点的。

当谈到带宽时,可能会让人惊讶的是,大多数游戏实际上并不需要拥有巨大的基础设施。例如,一个视频会议需要的带宽比一个复杂的单人第一人称射击游戏或拥有大量物理模拟的战争模拟器要多得多,因为它需要处理以渲染图像形式存在的数据,这些数据必须在每个参与者的会议实例中传递和重新创建。在游戏的情况下,创建模拟所需的大部分必要资源已经存在于用户的机器上,因此我们的大部分工作是通过消息来沟通计算机应该加载什么,以同步客户端和服务器上的游戏实例。众所周知,玩家需要大约 5 Mbps 的带宽才能玩大多数现代在线多人游戏,包括像使命召唤、英雄联盟和堡垒之夜这样的大型游戏系列。

在本书的第一部分 握手和网络中,我们了解到游戏主要使用不可靠的数据,这意味着大多数时候,我们只需要知道游戏中某个对象的最新数据以便同步它。这大大减少了网络使用量,并允许我们专注于复制服务器游戏世界到客户端游戏世界所需的具体数据类型。

在在线多人游戏中,我们主要关心的是我们保持数据传输流的一致性 – 最终,我们能否持续保持网络的吞吐量。这可能会受到延迟和其他外部因素的影响,所以我们能做的就是设计一个考虑延迟如何影响我们吞吐量的通信架构。当然,我们也会尽量将带宽保持在最低,以便如果同一网络连接了多个设备,我们的游戏仍有空间保持数据流动。

因此,请记住带宽和吞吐量是我们主要的资源,我们将查看它们以找到我们可以改进游戏的地方。

你可能还在试图弄清楚带宽和吞吐量之间的区别。那么,让我们简要地评估一下这两个概念。我们在第一章,“设置服务器”,中介绍了这些概念,所以如果你不太记得它们是什么,请花一点时间阅读该章节的“什么是 UDP 协议?”部分。

我们将使用带宽来了解我们的游戏需要多少网络才能正确运行,这意味着我们预计将通过网络传输多少数据,考虑到我们使用网络分析器观察到的所有测量值。这意味着如果我们有 1,000 个 MultiplayerSynchronizers 在游戏的任何一点同步 5 KB 的数据每秒,我们需要一个 5 Mbps 速度的网络。请注意,这并不意味着整个游戏会持续以 5 Mbps 的速度传输,但这是基于我们的测量结果,建议游戏可能需要一个能够管理高达 5 Mbps 的网络才能流畅运行。总之,带宽是连接中可用于数据传输的空间量,而不是传输本身。

现在,我们真正想要优化的是吞吐量,这指的是我们实际上通过网络传输的包的数量和这些包的大小。吞吐量是我们的实际数据流。你可以用类比来考虑它,其中带宽是管道,吞吐量是水。我们不能传输比管道能支持的更多的水;相反,我们可以传输直到可用管道大小的水。同样,我们可以有的吞吐量量是基于可用的带宽容量。

你可以在以下图中看到良好与不良吞吐量-带宽关系的示意图:

图 11.1 – 良好与不良吞吐量-带宽关系的示意图

图 11.1 – 良好与不良吞吐量-带宽关系的示意图

注意,良好的吞吐量下,发送和接收的数据是一致的,不会超过带宽,而糟糕的吞吐量下,数据包的大小和频率不一致,有些甚至在传输过程中丢失。当数据包没有到达目的地时,我们称之为丢包,这可能会给玩家带来很多麻烦和投诉。

在丢包的情况下,客户端不知道如何正确处理他们的游戏实例。第二位玩家的飞船应该在哪个位置?它停止射击了吗?它是在射击Asteroid节点,还是没有更多的Asteroid节点可以射击?他们射击的Asteroid节点还在那里吗?我们将在第十二章实现延迟补偿中看到如何处理这些情况,但理想情况下,我们应该通过关注吞吐量来避免它们。

就像这样,当可用的优化资源不足以让新的机制出现时,网络工程师处于一个非常微妙的地位。我们采取已经实施的机制,并尝试将它们全部纳入可用的资源中,通常是通过挤压它们来跟上可能需要更多这些资源的任何潜在变化,例如,需要更多带宽的新机制。因此,我们在优化网络时不能进行太多的实验。

在下一节中,让我们讨论如何通过减少游戏发出的请求数量来启动我们的优化。我们将评估在第十章调试和性能分析网络中提出的问题,例如在Weapon2D中大量的 RPC 计数和Asteroid节点的MultiplayerSynchronizer节点的非必要同步。

减少请求数量

第十章调试和性能分析网络中,我们看到了对Weapon2D节点的fire() RPC 方法进行了不成比例和不必要的请求,我们甚至提出了可能解决这个问题的方案。我们还看到,我们可以通过仅使用World节点的sync_world() RPC 方法在给定玩家请求同步时更新一次Asteroid节点来减少Asteroid节点的同步问题。

在本节中,我们将实现这些优化方法,并提高我们网络的总体性能。让我们从Weapon2D节点问题开始。

减少武器射击次数

有时我们可能需要修改一个功能的核心代码以改进其网络性能,即使其本地性能保持不变,甚至可能下降。当谈到优化时,我们总是试图平衡事物并找出如何以允许更多玩家享受更好体验的方式使用可用资源。网络资源在大多数在线多人游戏中尤其是一个优先事项,因为只有通过良好的网络,玩家才能充分利用他们的共享体验。所以,让我们改变 Player2D 节点射击 Weapon2D 节点的方式。首先打开 res://09.prototyping-space-adventure/Objects/Weapon/Weapon2D.tscn 场景并执行以下步骤:

  1. Timer 节点的 timeout 信号连接到 Weapon2D 节点,并创建一个名为 _on_timer_timeout() 的回调方法:

图 11.2 – Timer 节点的超时信号连接到 Weapon2D 节点的 _on_timer_timeout() 回调

图 11.2 – Timer 节点的超时信号连接到 Weapon2D 节点的 _on_timer_timeout() 回调

  1. 打开 res://09.prototyping-space-adventure/Objects/Weapon/Weapon2D.gd 脚本,并在 _on_timer_timeout() 回调中调用 fire() 方法,以便 Weapon2D 节点在每个 Timer 节点的 tic 上进行射击。

  2. 然后,让我们创建一个可以被任何对等节点调用并且也应该在本地调用的 RPC 方法。我们将使用此方法来改变 Weapon2D 的射击状态,因此它应该接收一个布尔变量作为参数:

    @rpc("any_peer", "call_local")
    func set_firing(firing):
    
  3. 在这个方法中,我们将检查 firing 状态,如果是 true,我们将调用 fire() 方法;否则,我们将告诉 Timer 停止:

    @rpc("any_peer", "call_local")
    func set_firing(firing):
      if firing:
        fire()
      else:
        timer.stop()
    
  4. 有了这个,我们可以移除 fire() 方法和 if timer.is_stopped() 语句上的 RPC 注释,因为现在 Timer 本身就会告诉 Weapon2D 何时射击。修改后的 fire() 方法应该如下所示:

    func fire():
      animation_player.play("fire")
      spawner.spawn(bullet_scene)
      timer.start(1.0 / fire_rate)
    

有了这个,Weapon2D 将根据来自 Timer 的超时信号进行射击。使用新的 RPC 方法,我们可以改变射击状态,开始或停止创建新的 子弹。在这个阶段,Weapon2D 脚本应该看起来像这样:

class_name Weapon2D
extends Marker2D
@export var bullet_scene: PackedScene
@export_range(0, 1, 1, "or_greater") var fire_rate = 3
@onready var spawner = $BulletSpawner2D
@onready var timer = $Timer
@onready var animation_player = $AnimationPlayer
func fire():
  animation_player.play("fire")
  spawner.spawn(bullet_scene)
  timer.start(1.0 / fire_rate)
@rpc("any_peer", "call_local")
func set_firing(firing):
  if firing:
    fire()
  else:
    timer.stop()
func _on_timer_timeout():
  fire()

现在,我们需要知道 Weapon2D 的射击状态何时改变,为此,我们需要对 Player2D 进行一些修改。所以,打开 res://09.prototyping-space-adventure/Actors/Player/Player2D.gd 并执行以下步骤:

  1. 删除整个 _process() 回调代码。然后,重写 _unhandled_input() 回调:

    func _unhandled_input(event):
    
  2. _unhandled_input() 回调内部,我们将检查是否按下了 "shoot" 动作或释放了它。如果按下,我们将 weapon 射击状态设置为 true,如果释放,则设置为 false(记住,我们应该使用 rpc() 方法来做这件事,以便玩家在所有网络对等实例上射击):

    func _unhandled_input(event):
      if event.is_action_pressed("shoot"):
        weapon.rpc("set_firing", true)
      elif event.is_action_released("shoot"):
        weapon.rpc("set_firing", false)
    
  3. 接下来,我们需要在setup_multiplayer()方法中添加一行代码,以便根据飞船实例是当前玩家还是远程玩家来切换_unhandled_input()过程:

    func setup_multiplayer(player_id):
      var self_id = multiplayer.get_unique_id()
      var is_player = self_id == player_id
      set_process(is_player)
      set_physics_process(is_player)
      set_process_unhandled_input(is_player)
    

这样,Player2D将根据“射击”动作是否被按下或释放来切换Weapon2D的发射状态,而不是在“射击”被按下时每帧调用它。

让我们对这次改进进行评估。打开网络分析器,看看效果如何。记住,根据分析段的持续时间,我们可能会得到不同的结果,所以这并不像单元测试那样准确;但仍然,它将给我们一个关于我们已做的任何潜在改进的良好感觉。在下面的图中,我们有Player2D实例:

图 11.3 – 第 1 次会话调试器的网络分析器标签页突出显示节点实例的传入 RPC

图 11.3 – 第 1 次会话调试器的网络分析器标签页突出显示Player2D节点实例的传入 RPC

如图中所示,我在大约 20.0 秒的游玩过程中摧毁了所有 30 颗小行星,总共进行了六个来自Player2D实例的 RPC 调用。如果你将这个结果与图 10.13进行比较,在那里我们对于大约相同时长的会话进行了 693 次调用,这比之前的调用减少了超过 115 倍。我认为我们在这里做得很好。

因此,在本节中,我们学习了如何优化游戏中的数据请求以提高网络性能。我们关注了如何通过优化Weapon2D节点的触发来减少游戏中发出的请求的数量。我们还看到了如何创建一个可以改变Weapon2D发射状态的 RPC 方法,以及如何根据“射击”动作的按下或释放来切换发射状态,而不是在每帧的_process()回调中触发发射。最后,我们看到了如何使用网络分析器来评估这些优化对游戏性能的影响。在下一节中,我们将致力于减少小行星的MultiplayerSynchronizer请求。

减少小行星的同步计数

第十章中,我们看到了另一个问题,即关于Asteroid节点的MultiplayerSynchronizers如何将位置同步给玩家的问题。由于它们在整个游玩过程中不移动,因此没有必要定期更新它们的位置和其他属性。相反,我们只需要在玩家请求World节点同步时同步一次。

因此,打开res://09.prototyping-space-adventure/Objects/Asteroid/Asteroid.tscn场景,并对这一方面进行必要的修改以改进我们的网络工程。

在这里,我们只需要将MultiplayerSynchronizer节点的可见性更新模式属性设置为None

图 11.4 – 小行星节点的节点可见性更新模式属性设置为 None

图 11.4 – 小行星节点的 MultiplayerSynchronizer 节点的可见性更新模式属性设置为 None

现在,我们只需要手动进行同步。MultiplayerSynchronizer 节点有一个名为 update_visibility() 的方法,它接收一个参数,我们可以传递我们想要同步的同伴的 ID;如果我们传递 0,则更新所有同伴。

注意,此方法考虑了我们在 第九章同步小行星 部分中设置的过滤器,使用 set_visibility_for() 方法,这意味着只有使用此方法添加的同伴才会被同步。因此,在我们的情况下,如果我们不使用 set_visibility_for() 方法将同伴添加到过滤中,即使我们使用 update_visibility() 方法并传递正确的同伴 ID,该同伴也不会被同步。

手动同步 Asteroid 节点属性的最佳位置是在 World 类中,因此打开 res://09.prototyping-space-adventure/Levels/World.gd 脚本。然后,在 sync_world() 方法内部,让我们向 "Sync" 组添加另一个分组调用,但这次是调用 update_visibility 方法,并传递 player_id 作为参数。整个 sync_world() 方法应该看起来像这样:

@rpc("any_peer", "call_local")
func sync_world():
  var player_id = multiplayer.get_remote_sender_id()
  get_tree().call_group("Sync", "set_visibility_for",
      player_id, true)
  get_tree().call_group("Sync", "update_visibility",
      player_id)

这样一来,所有 Asteroid 节点应该只对每个玩家同步一次,从而减少总网络资源的使用。

让我们也将这个更改进行性能分析,以查看其对整体网络消耗的影响。以下图展示了某些小行星的 MultiplayerSynchronizers 的同步计数:

图 11.5 – 第二次会话调试器的网络分析器突出显示小行星节点的 MultiplayerSynchronizer 节点的同步计数

图 11.5 – 第二次会话调试器的网络分析器突出显示小行星节点的 MultiplayerSynchronizer 节点的同步计数

将其与 图 10.13 进行比较,在那里我们有成百上千的更新,并且对于每一帧,更新计数都会增加。在我们所做之后,它们只会在玩家进入会话时增加一次,并且只针对那个特定的玩家。做得不错,对吧?

在本节中,我们了解了如何通过减少 asteroid 节点的 MultiplayerSynchronizer 节点的同步来优化数据请求。为此,我们在玩家加入游戏后禁用了自动同步,设置了 MultiplayerSynchronizer 节点的 MultiplayerSynchronizer.update_visibility() 方法。

在接下来的章节中,我们将了解如何压缩数据以优化数据包的大小。到目前为止,我们只处理了发送或接收的数据包数量,但这些数据包的大小同样非常重要。让我们了解我们有哪些可用的方法来做到这一点。

使用 ENetConnection 类压缩数据

Godot 引擎的高级网络 API 包含 ENetConnection 类。在服务器和客户端等对等体之间建立连接后,此类可用。使用 ENetConnection,我们可以调整对等体的连接,例如,指定压缩方法。在对等体成功连接后,我们可以访问 ENetConnection 实例。为此,我们可以使用 ENetMultiplayerPeer.host 属性。

ENetConnection 类中可以使用五种压缩方法:

  • CompressionMode 枚举的 COMPRESS_NONE 选项。文档中的说明如下:

    这使用最多的带宽,但优点是需要的 CPU 资源最少。此选项还可以用于使用 Wireshark 等工具进行网络调试。

  • ENet 内置的 CompressionMode 枚举的 COMPRESS_RANGE_CODER 选项。文档中的说明如下:

    [这是] ENet 内置的范围编码。在小数据包上表现良好,但大于 4 KB 的数据包上不是最有效的算法。

  • CompressionMode 枚举的 COMPRESS_FASTLZ 选项。文档中的说明如下:

    此选项相比 COMPRESS_ZLIB 使用更少的 CPU 资源,但代价是使用更多的带宽。

  • CompressionMode 枚举的 COMPRESS_ZLIB 选项。文档中的说明如下:

    此选项相比 COMPRESS_FASTLZ 使用更少的带宽,但代价是使用更多的 CPU 资源。

  • CompressionMode 枚举的 COMPRESS_ZSTD 选项。文档中的说明如下:

    请注意,此算法在小于 4 KB 的小数据包上效率不高。因此,在大多数情况下,建议使用其他压缩算法。

您可以通过以下链接在 Godot 文档中找到更多关于 ENetConnectionCompressionMode 的信息:

docs.godotengine.org/en/stable/classes/class_enetconnection.html#enum-enetconnection-compressionmode

是时候着手优化我们通过网络传输的数据的大小了。在以下步骤中,我们将实现压缩以减小游戏的包大小并优化网络数据传输。请注意,如往常一样,带宽和 CPU 资源之间存在权衡。我们的游戏目前没有关于 CPU 使用的问题。因此,在这个阶段,我们可以专注于优化网络资源。为此,我们可以使用 COMPRESS_ZLIB 压缩模式。为此,让我们打开 res://09.prototyping-space-adventure/Authentication.gd 脚本并完成以下步骤:

  1. _ready() 回调中将 multiplayer_peer 设置为对等体之前,我们将更改 ENetConnection 的压缩模式。为此,我们访问 host 属性并使用 compress() 方法,将 EnetConnection.COMPRESS_ZLIB 作为参数传递:

    func _ready():
      if multiplayer.is_server():
        peer.create_server(PORT)
        load_database()
      else:
        peer.create_client(ADDRESS, PORT)
        peer.host.compress(EnetConnection.COMPRESS_ZLIB)
      multiplayer.multiplayer_peer = peer
    

    我们需要在这里做这件事,因为压缩模式需要在建立连接之前设置,而连接是在我们设置multiplayer.multiplayer_peer属性之后建立的。

  2. 我们还需要在res://09.prototyping-spaceadventure/LoggingScreen.gd脚本中做同样的事情,以便这个连接也匹配服务器的连接压缩。再次提醒,在设置multiplayer.multiplayer_peer之前,我们将 ENetConnection 的压缩设置为COMPRESS_ZLIB

    func _ready():
      peer.create_client(ADDRESS, PORT)
      peer.host.compress(ENetConnection.COMPRESS_ZLIB)
      multiplayer.multiplayer_peer = peer
    

通过这样,我们能够在玩家加入游戏世界时立即更改游戏网络连接中使用的压缩模式。目前,这不会产生很大的影响。正如我们在文档中的先前引用中看到的,大多数压缩算法的目标是使数据包的大小要么低于 4 KB,要么高于 4 KB。我们游戏的包目前还没有达到千字节,所以……坦白说,这可能不会有太大的影响,如果有的话。

如果我们想要测量我们正在使用的带宽量,并至少浏览一下任何可能的改进,我们可以使用调试器的EnetConnection实例的发送和接收数据。为此,我们可以使用ENetConnection.pop_statistic()方法,通过使用Performance单例来添加我们的自定义监视器,创建两个相关的监视器。让我们来做这件事:

  1. 仍然在World类脚本中,创建一个名为get_received_data()的方法。这个方法需要返回一个整数或浮点数,这样我们就可以用它来创建一个监视器。在这种情况下,它将返回当前ENetConnection的接收数据统计信息。为此,我们可以使用pop_statistic()方法,传递ENetConnection.HOST_TOTAL_RECEIVED_DATA作为参数:

    func get_received_data():
         var enet_connection = multiplayer.multiplayer_
             peer.host
         var data_received = enet_connection.pop_statistic
             (ENetConnection.HOST_TOTAL_RECEIVED_DATA)
         return data_received
    
  2. 然后,我们将创建另一个名为get_sent_data()的方法,并做同样的事情,但这次传递ENetConnection.HOST_TOTAL_SENT_DATA作为参数:

    func get_sent_data():
      var enet_connection = multiplayer.multiplayer_
          peer.host
      var data_sent = enet_connection.pop_statistic
          (ENetConnection.HOST_TOTAL_SENT_DATA)
      return data_sent
    
  3. 现在,在_ready()回调中,我们检查这个实例是否是服务器,就在创建Asteroid实例的上方,我们将使用Performance.add_custom_monitor()方法将相应的callable添加到Performance单例中,如下所示:

      var callable = Callable(self, "get_received_data")
      Performance.add_custom_monitor("Network/Received
          Data", callable)
      callable = Callable(self, "get_sent_data")
      Performance.add_custom_monitor("Network/Sent
          Data", callable)
      for i in 30:
        asteroid_spawner.spawn()
    

现在,我们能够监控不同压缩模式之间的差异,并看到哪个更适合我们的游戏。在下面的图中,我们比较了COMPRESS_ZLIBCOMPRESS_NONE压缩模式的用法:

图 11.6 – 使用 COMPRESS_ZLIB 和 COMPRESS_NONE 压缩模式发送和接收数据监视器的比较

图 11.6 – 使用 COMPRESS_ZLIB 和 COMPRESS_NONE 压缩模式发送和接收数据监视器的比较

注意,使用COMPRESS_ZLIB压缩时,接收数据达到了峰值 12,509 字节,而发送数据达到了峰值 48,802 字节。同时,使用COMPRESS_NONE,接收数据峰值达到 14,470 字节,发送数据峰值达到 80,234 字节——即使是非常小的数据包,我们也取得了巨大的收益,尤其是在服务器的发送数据指标上,所以我们在这方面也做得很好!

摘要

在本章中,我们学习了如何优化游戏中的数据请求以提高网络性能。我们专注于通过优化武器的发射来减少游戏中发出的请求次数。我们看到了如何创建一个可以改变武器发射状态的 RPC 方法,以及如何根据“射击”动作的按下或释放来切换发射状态,而不是在_process()回调的每一帧上触发发射。最后,我们看到了如何使用网络分析器来评估这些优化对游戏性能的影响。

之后,我们解决了小行星节点多玩家同步器同步的问题。当玩家加入游戏后,我们将MultiplayerSynchronizer设置为MultiplayerSynchronizer.update_visibility()方法来禁用自动同步。这减少了小行星节点的同步次数,并降低了总网络资源的使用量。我们还了解了如何使用调试器的网络分析器来衡量这些优化的有效性。

最后,我们了解了 Godot 引擎高级网络 API 的ENetConnection类,它提供了压缩方法以优化数据包大小。我们看到了如何使用ENetConnection.pop_statistic()方法通过使用Performance单例来创建自定义监控器,以跟踪 ENetConnection 的发送和接收数据。我们比较了COMPRESS_ZLIBCOMPRESS_NONE压缩模式的用法,并发现即使是非常小的数据包,我们也取得了巨大的收益,尤其是在服务器的发送数据指标上。

在下一章中,我们将更深入地探讨优化。我们将使用插值和预测来减少玩家飞船的MultiplayerSynchronizer同步次数,同时尝试在整个游戏网络实例中保持移动的一致性。那里见!

第十二章:实现延迟补偿

欢迎来到本书中最受期待的章节之一。在这里,我们将深入探讨在线多人游戏优化的核心。在在线游戏的世界里,来自全球的玩家聚集在一起开始史诗般的冒险,两个强大的对手潜伏在阴影中;他们是延迟延迟。这些敌人可以将激动人心的游戏体验转变为令人沮丧的考验。在本章中,我们将直面这些挑战,为你提供知识和工具来减轻它们的影响,并创建一个引人入胜的在线游戏环境。

在本章中,我们将使用Player节点的Spaceship节点来维护其位置和旋转,在整个游戏实例的网络中保持同步。为此,我们将了解与数据包丢失和延迟相关的核心问题,这在使用不可靠的数据包时很常见,就像我们使用 ENet 协议时那样。然后,我们将通过使用Timer节点来模拟一些延迟和数据包丢失,以便我们了解这些问题如何在实际游戏中显示。之后,我们将讨论一些常见的补偿技术来解决这些问题。

到本章结束时,你将了解我们如何模拟一些平滑的运动,即使游戏的MultiplayerSynchronizer未能将数据传递到对等游戏实例中。

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

  • 介绍延迟问题

  • 处理不可靠的数据包

  • 常见的补偿技术

技术要求

第十章调试和配置网络第三部分优化在线体验中所述,本书的第九章创建在线冒险原型中的项目最终版本是重点,因此阅读、练习和实现那里介绍的概念是基本的。你可以从以下链接获取开始本章所需的文件:github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0/tree/12.prediction-and-interpolation。它们包含了我们在第十一章优化 数据请求中进行的优化进展。

同时,你也需要阅读并理解了第十一章优化数据请求中介绍的概念和工具,这样我们才能继续假设你已经知道它们是什么以及如何正确使用它们。

介绍延迟问题

解决延迟和不稳定的包涉及三种技术——插值预测外推。这些技术使玩家动作更加平滑,保持响应性,并预测物体运动。Godot 引擎的物理模拟和 RPC 方法在这些技术中至关重要,有助于实现逼真的物体运动和数据同步,即使在网络问题的情况下。

延迟和延迟是任何在线多人游戏的宿敌。延迟,通常与延迟互换使用,指的是玩家动作与其在游戏中的相应效果之间的延迟。这是拉动扳机和看到敌人倒下的瞬间暂停。另一方面,延迟表示数据从玩家设备传输到游戏服务器并返回所需的时间。这两个因素可以破坏游戏流畅性,让玩家感到挫败,并使他们与虚拟世界脱节。

在在线多人游戏的世界里,数据的传输很少是一帆风顺的。不稳定的包,这些调皮的信息片段,可能会因为顺序混乱或完全消失而造成问题。当包顺序混乱时,玩家可能会看到对手神奇地穿越地图并返回,或者完成不可能的壮举。数据丢失导致关键的游戏更新无法到达其目标位置,使得角色和物体停滞在时间中。在本章中,我们的任务是解决这些问题,将混乱变为有序。

在在线多人游戏领域,延迟和延迟是困扰开发者和玩家的一个反复出现且经常令人沮丧的问题。在本节中,我们将讨论这两个在线游戏的基本方面,并揭示它们对玩家体验的深远影响。正如你在前面的章节中已经发现的,创建一个无缝和沉浸式的多人环境需要对这些概念有细微的理解。

现在,让我们来谈谈延迟和延迟对游戏体验的影响。当玩家遇到延迟时,它会打断游戏的流畅性,可能导致错失机会、挫败感,在竞技场景中,甚至可能导致不利的结局。想象一下,在在线射击游戏中开火,却发现子弹在几秒后才被记录下来,而此时目标早已安全撤离。

理解延迟和延迟的原因对于有效缓解至关重要。网络拥塞、硬件限制以及玩家和服务器之间的地理距离是常见的原因。网络拥塞发生在网络上的数据流量过高,导致数据包延迟或丢失。硬件限制,如缓慢的互联网连接或性能不足的计算机,也可能导致延迟。

缓解延迟和延迟是游戏开发者面临的一个持续挑战。一种策略是服务器优化,游戏服务器被精细调整以高效处理大量数据。另一种方法是客户端预测和插值,这些技术有助于在网络延迟的情况下平滑游戏体验;我们将在常见补偿技术部分中讨论这些内容。除此之外,选择合适的网络基础设施,例如内容分发网络CDNs),通过将游戏资产放置在玩家附近,可以显著降低延迟。

我们已经揭开了延迟和延迟的层,了解了这些因素如何影响在线多人游戏。我们看到了一些原因,并讨论了缓解策略,所有这些都有助于提升玩家的游戏体验。在下一节中,我们将讨论与不可靠数据包相关的问题,这是我们通常用于在线多人游戏中通过网络传输数据的方式。

处理不可靠的数据包

开发者在创建在线多人游戏时面临的一个主要担忧是数据包的可靠性。在本节中,我们将探讨围绕不可靠数据包的复杂性,揭示它们给在线多人游戏带来的问题。正如您从我们的讨论中已经了解到的,理解这些挑战是打造流畅和沉浸式多人游戏体验的核心。

如其名所示,不可靠的数据包是指在网络上发送的数据包,没有任何到达或顺序保证。它们就像随风飘散的信件,只有在条件有利的情况下才能到达目的地。这些数据包用于在线游戏中传输非关键数据,如角色位置,因为与具有内置交付保证的可靠数据包相比,它们提供了更低的延迟。

与不可靠数据包相关的一个主要问题是数据包丢失。这发生在从一位玩家的设备发送的数据包未能到达服务器或另一位玩家的设备时。这就像拼图碎片消失在空中,导致数据不完整和不一致。在快节奏的动作游戏中,数据包丢失可能表现为角色突然传送、消失的弹丸或玩家之间不可解释的同步错误。

另一个挑战是数据包的顺序到达。在一个理想的世界里,数据包会以发送时的相同顺序到达目的地。然而,网络路由的不确定性可能导致数据包顺序到达,导致游戏世界中的混乱。想象一下,你收到组装家具的指令,但步骤却顺序混乱;这是一场混乱和挫败的预兆。通常,在这些情况下,我们只使用最新的数据并忽略旧数据,因为只有最新的信息与游戏相关。

不可靠数据包对游戏的影响可能是灾难性的。数据包丢失和顺序错误到达可能导致玩家断开连接、角色位置不正确以及玩家之间的同步异常。例如,由于缺失数据包,玩家的角色可能会从一个位置跳跃到另一个位置。这不仅会破坏沉浸感,还会损害竞技游戏的公平性和完整性。

缓解不可靠数据包带来的问题需要多方面的方法。开发者通常会采用客户端预测等技术,客户端根据缺失的数据做出明智的猜测,以保持游戏状态的一致性。插值是另一个有价值的工具,通过在已知数据点之间平滑过渡,它平滑了由缺失数据包引起的抖动。

在本节中,我们了解到数据包丢失是一个常见问题,其中数据包未能到达目的地,导致数据不完整和不一致。我们还看到,一些数据包可能会顺序错误地到达,导致游戏世界中的混乱。这些问题可能导致玩家断开连接、角色位置不正确以及玩家之间的同步异常。在下一节中,我们将看到解决这些以及与延迟相关问题的最常见补偿技术。

常见的补偿技术

欢迎来到我们探索在线多人游戏开发领域的最期待部分。在前面的章节中,我们揭开了网络、同步以及处理不可靠数据包的复杂性的面纱。现在,我们站在一个关键的分岔路口,准备探索插值预测外推的迷人世界,这三项技术是创造无缝和响应式在线游戏体验的关键,或者至少让我们尽可能地接近这个圣杯。

想象一下——你正处于一场激烈的多玩家战斗的高潮,赌注无法更高。在在线游戏的世界里,每一秒都很重要,每一个动作都必须精确。但当网络延迟出现,导致玩家之间数据传输出现轻微延迟时,会发生什么呢?这就是插值、预测和外推发挥作用的地方。

实施插值、预测和外推的一个关键基石是将物理模拟与预测算法相结合。在Godot 引擎中,物理引擎在确定游戏世界中对象如何移动和交互方面发挥着至关重要的作用。通过将物理与预测算法相结合,你可以创建一个符合我们虚拟宇宙法则的逼真和响应式的游戏体验。

为了协调数据同步的交响乐,我们将移除Player节点的MultiplayerSynchronizer节点,并使用一些RPC方法。这些函数充当我们数据乐团的指挥,允许我们在需要时精确地向客户端或服务器发送必要的信息。通过 RPC,我们可以触发插值、预测或外推数据的传输,确保所有玩家保持一致。

在接下来的章节中,我们将深入探讨在我们的在线多人俯视冒险原型中实现插值、预测和外推。到那时,你将了解这些技术是如何共同工作以补偿网络延迟的。所以,系好安全带,因为我们即将导航在线多人游戏动态世界中的流畅和响应式游戏细节。

实现服务器端运动

为了更好地设置以了解延迟如何影响游戏体验,我们将对Player场景和脚本进行一些修改。我们不再允许在客户端发生移动并同步到服务器和其他对等节点,而是玩家将使用输入事件来改变服务器Spaceship实例的运动。这将使我们能够减少MultiplayerSynchronizer发送的同步数据量,因为现在我们将基于Spaceship的推力和旋转状态进行运动模拟。为此,让我们打开res://09.prototyping-space-adventure/Actors/Player/Player2D.tscn场景。然后,按照以下步骤进行:

  1. 选择MultiplayerSynchronizer节点,并在SpawnSync选项上同步Spaceship的位置和旋转:

图 12.1 – 玩家场景的 MultiplayerSynchronizer 复制菜单,Spaceship 位置和旋转属性已禁用

图 12.1 – 玩家场景的 MultiplayerSynchronizer 复制菜单,Spaceship 位置和旋转属性已禁用

  1. 然后,让我们打开res://09.prototyping-space-adventure/Actors/Player/Player2D.gd文件,并对setup_multiplayer()方法进行一些修改。在这里要做的第一件事是删除启用_physics_process()_process()回调的行,只留下_unhandled_input()。我们将在以下步骤中看到原因:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
         var self_id = multiplayer.get_unique_id()
         var is_player = self_id == player_id
         set_process_unhandled_input(is_player)
         camera.enabled = is_player
    
  2. 然后,我们将检查当前实例是否不是服务器;如果不是,我们将调用make_current()方法,从而在他们的游戏实例上有效启用此玩家的Camera2D节点:

    func setup_multiplayer(player_id):
         var self_id = multiplayer.get_unique_id()     var is_player = self_id == player_id      set_process_unhandled_input(is_player)      camera.enabled = is_player
         if not multiplayer.is_server():
    
  3. 接下来,我们将为新 Spaceship 节点的运动逻辑打下基础,移除 _physics_process() 回调,并在 _unhandled_input() 回调中工作。整个逻辑遵循 Weapon2D 的相同思路;Spaceship 将有 thrustingdirectionturning 变量,我们可以使用这些变量来改变其运动。基于我们在 _unhandled_input() 中获得的事件输入,我们将改变这些变量的状态。这里的秘密是我们将使用 rpc_id() 方法在服务器的 Spaceship 实例上改变这些状态。

    _unhandled_input() callback after adding this new logic:
    
    func _unhandled_input(event):
         if event.is_action_pressed("shoot"):
              weapon.rpc("set_firing", true)
         elif event.is_action_released("shoot"):
              weapon.rpc("set_firing", false)
    # Thrusting logic. The spaceship enables its thrust based on if the `thurst_acti
    on` was pressed or released
         if event.is_action_pressed(thrust_action):
              spaceship.rpc_id(1, "set_thrusting", true)
         elif event.is_action_released(thrust_action):
              spaceship.rpc_id(1, "set_thrusting", false)
    # Turning logic. If a turning key is just pressed or still pressed, the spaceshi
    p turns, it only stops turning if neither `turn_left_action` or `turn_right_action
    ` are pressed.
         if event.is_action_pressed(turn_left_action):
              spaceship.rpc_id(1, "set_direction", -1)
              spaceship.rpc_id(1, "set_turning", true)
         elif event.is_action_released(turn_left_action):
              if Input.is_action_pressed(turn_right_action):
    spaceship.rpc_id(1, "set_direction", 1)
    12 Implementing Lag Compensation 8
         else:
              spaceship.rpc_id(1, "set_turning", false)
              spaceship.rpc_id(1, "set_direction", 0)
         if event.is_action_pressed(turn_right_action):
              spaceship.rpc_id(1, "set_direction", 1)
              spaceship.rpc_id(1, "set_turning", true)
         elif event.is_action_released(turn_right_action):
         if Input.is_action_pressed(turn_left_action):
              spaceship.rpc_id(1, "set_direction", -1)
         else:
              spaceship.rpc_id(1, "set_turning", false)
              spaceship.rpc_id(1, "set_direction", 0)
    
  4. 现在,让我们转到 res://09.prototyping-space-adventure/Objects/Spaceship/Spaceship.gd 脚本,在那里我们将实现上述更改所需的变量和方法。首先,让我们声明属性及其设置方法:

    @export var thrusting = false : set = set_thrusting @export var turning = false : set = set_turning
    @export_range(-1, 1, 1) var direction = 0 : set = set_direction
    
  5. 然后,让我们声明这些方法;这里有个技巧——它们是 RPCs,任何对等节点都可以调用,并且它们将在本地被调用:

    @rpc("any_peer", "call_local")
    func set_thrusting(is_thrusting):
         thrusting = is_thrusting
    @rpc("any_peer", "call_local")
    func set_turning(is_turning):
         turning = is_turning
    @rpc("any_peer", "call_local")
    func set_direction(new_direction):
         direction = new_direction
    
  6. 然后,我们将对 thrust()turn() 方法进行修改。整个思路是它们现在将接收 delta 作为参数。turn() 方法不再需要接收方向参数,因为方向已经成为了一个 member 变量:

    func thrust(delta):
         linear_velocity += (acceleration * delta) * Vector2.RIGHT.rotated(rotation)
    func turn(delta):
         angular_velocity += (direction * turn_torque) * delta
    
  7. 最后,我们将使用 _physics_process() 回调根据推力和转向变量的状态调用 thrust()turn() 方法:

    func _physics_process(delta):
    if thrusting:
         thrust(delta)
    if turning:
         turn(delta)
    

    这样,我们就有了所有需要保持运动不变的东西,但现在服务器负责响应玩家的输入,而不是被动地对待玩家游戏实例中的 Spaceship 节点的行为。这很重要,因为延迟和延迟补偿的工作方式,我们需要一个游戏实例始终作为后备,以防我们需要更新可能已在网络上丢失的一些数据。此外,一些技术涉及服务器端最终处理差异。YouTube 上有一个名为 How to reduce Lag - A Tutorial on Lag Compensation Techniques for Online Games 的优秀视频,解释了连接每一方在延迟补偿技术中的作用。这个视频可以通过此链接访问,并且强烈推荐:www.youtube.com/watch?v=2kIgbvl7FRs

现在我们已经准备好了,我们可以开始实现实际的技术,这些技术将帮助我们处理这个问题。在下一节中,我们将设置我们的模拟延迟机制,这基本上是 twoTimers,并看看我们如何使用 Tween 节点在我们的游戏中实现 插值,以便我们可以创建基于稀疏的 Spaceship 节点位置和旋转更新的流畅运动。

通过插值弥合差距

插值是填补接收到的数据点之间空隙的艺术。当数据包由于网络延迟或数据包丢失而以不规则的时间间隔到达时,插值确保角色、对象和弹头的移动看起来平滑且连续。想象一下,它就像粘合碎片数据的魔法胶水,允许玩家见证不间断、流畅的运动。

在本节中,我们将了解如何使用Tween类来插值我们从玩家那里接收到的稀疏数据。Tween是一个专门用于在 Godot 引擎中插值值的类。我们还将使用 lerping 方法,lerp()lerp_angle(),来找到用于插值的正确值,特别是对于Spaceship的旋转角度。

为了模拟一些延迟,我们将使用Timer节点,这样我们就可以看到我们的插值在不同场景下的工作情况。然而,理想情况下,你会使用ENetPacketPeer.get_statistic()方法,传递ENetPacketPeer.PEER_ROUND_TRIP_TIME作为参数来获取对实际网络延迟的访问权限。我们可以通过使用multiplayer.multiplayer_peer.get_peer(1)来引用服务器的 peer 连接,从而在它上面调用get_statistic()方法。因此,要访问玩家的延迟到服务器,我们可以使用以下代码片段:

# Only clients should get statistics about their connection with the server, so we don't call that on the server itself.
if not multiplayer.is_server():
     var server_connection = multiplayer.multiplayer_peer.get_peer(1)
     var latency = server_connection.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIM
E))
     print(latency)

话虽如此,我们将对Player场景和脚本进行一些修改,以便我们可以实现插值逻辑并了解如何使用这项技术。打开res://09.prototyping-space-adventure/Actors/Player/Player2D.tscn场景,并按照以下步骤实现我们的插值逻辑:

  1. 由于我们不再使用MultiplayerSynchronizer节点来同步Spaceship节点的位置和旋转属性,我们将添加Timer节点来模拟一些延迟。因此,向场景中添加一个新的Timer节点,并将其命名为InterpolationTimer

图 12.2 – 带有新添加的 InterpolationTimer 的玩家场景节点层次结构

图 12.2 – 带有新添加的 InterpolationTimer 的玩家场景节点层次结构

  1. 然后,让我们设置0.1。在这个上下文中,0.1的等待时间相当于 100 毫秒的延迟,这已经足够高,以至于玩家开始注意到一些抖动和明显的交互延迟。

图 12.3 – InterpolationTimer 节点设置

图 12.3 – InterpolationTimer 节点设置

  1. 有了这个,我们的下一步是将timeout信号连接到Player节点的脚本;我们可以创建一个名为_on_interpolation_timer_timeout()的回调方法,如下所示:

图 12.4 – InterpolationTimer 超时信号连接

图 12.4 – InterpolationTimer 超时信号连接

  1. 然后,让我们继续到 res://09.prototyping-space-nadventure/Actors/Player/Player2D.gd 脚本。在这里,我们将创建两个新的变量来存储之前已知的 Spaceship 节点的位置和旋转。这将是在向前移动到最新值时进行插值所必需的:

    @onready var previous_position = spaceship.position
    @onready var previous_rotation = spaceship.rotation
    
  2. 现在,在 _on_interpolation_timer_timeout() 回调中,我们将进行两次 RPC 调用。一个是调用 "interpolate_position" 方法,另一个是调用 "interpolate_rotation" 方法。这些方法将请求两个参数——目标属性(例如,位置或旋转)和插值的持续时间。在这种情况下,我们将使用 InterpolationTimer.wait_time 属性作为持续时间,因为这是在此上下文中网络更新的时间间隔。我们将在以下步骤中设置这些方法:

    func _on_interpolation_timer_timeout():
         rpc("interpolate_position", spaceship.position, $InterpolationTimer.wait_time)
         rpc("interpolate_rotation", spaceship.rotation, $InterpolationTimer.wait_time)
    
  3. 现在,让我们声明这些方法,从 interpolate_position() 开始。只有服务器应该能够远程调用这些方法,因为服务器将更新这些属性,所以它们的 @rpc 注解应该使用 "authority""call_remote" 作为选项:

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):
    
  4. interpolate_position() 方法内部,我们将首先创建一个新的 Tween 实例并将其存储在一个变量中,使用 create_tween() 方法:

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):
         var tween = create_tween()
    
  5. 然后,我们将使用 lerp() 函数来确定在插值中我们将使用的最终值。对于 position 属性,这并不那么有用,但在旋转的情况下将会很有用。然而,让我们这样操作以保持这些函数之间的一致性:

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):
         var tween = create_tween()
    
  6. 由于我们正在处理一个将运行一些物理模拟的实体,所以在 tween 变量中使用 Tween.TWEEN_PROCESS_PHYSICS 模式会更安全,这样插值就会在物理处理期间发生。为此,我们使用 Tween.set_process_mode() 方法:

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):
         var tween = create_tween()
         var final_value = lerp(previous_position, target_position, 1.0)
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
    
  7. 然后,我们可以开始实际的插值;我们将将其存储到一个名为 tweener 的变量中,因为 Tween.tween_property 返回一个 PropertyTween 对象,我们可以在必要时使用它。在这个函数中,我们传递四个参数——对象、将要进行插值的属性、目标值和插值的持续时间(以秒为单位):

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):
         var tween = create_tween()
         var final_value = lerp(previous_position, target_position, 1.0)
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
         var tweener = tween.tween_property(spaceship, "position", final_value, duration_
    in_seconds)
    
  8. 为了确保插值将从之前已知的值和最新值发生,我们将使用 from() 方法更改 tweener 的起始值,并传递 previous_position 作为参数:

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):
         var tween = create_tween()
         var final_value = lerp(previous_position, target_position, 1.0)
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
         var tweener = tween.tween_property(spaceship, "position", final_value, duration_
    in_seconds)
         tweener.from(previous_position)
    
  9. 然后,我们将更新 previous_posistion 以匹配现在已知的最新值,即我们的 final_value

    @rpc("authority", "call_remote")
    func interpolate_position(target_position, duration_in_seconds):var tween = create_tween()
         var final_value = lerp(previous_position, target_position, 1.0)
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
         var tweener = tween.tween_property(spaceship, "position", final_value, duration_
    in_seconds)
         tweener.from(previous_position)
         previous_position = final_value
    
  10. 至于interpolate_rotation,我们将做同样的事情,但这次我们将使用lerp_angle()函数。这是因为插值角度有点复杂,因为我们需要知道起始角度和目标角度之间的最短路径。使用这个函数并以1.0的权重提供最终值是正确的,并且为我们节省了很多时间。整个interpolate_rotation()方法与interpolate_position()方法非常相似,但当然,传递的是previous_rotation变量而不是previous_position变量。它看起来像这样:

    @rpc("authority", "call_remote")
    func interpolate_rotation(target_rotation, duration_in_seconds):
         var tween = create_tween()
         var final_value = lerp_angle(previous_rotation, target_rotation, 1.0)
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
         var tweener = tween.tween_property(spaceship, "rotation", final_value, duration_
    in_seconds)
         tweener.from(previous_rotation)
         previous_rotation = final_value
    
  11. 现在,如果当前实例是连接的服务器,我们需要开始InterpolationTimer。为此,转到setup_multiplayer()方法并添加一个else语句;在它里面,启动定时器。别忘了删除设置新实例权限的行,因为从现在起,服务器本身将始终是Player的权限。setup_multiplayer()方法应该看起来像这样:

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
         var self_id = multiplayer.get_unique_id() var      is_player = self_id == player_id      set_process_unhandled_input(is_player)      camera.enabled = is_player
         if not multiplayer.is_server():
              camera.make_current()
         else:
    

    就这样,我们的插值逻辑已经准备好,只需从服务器获取稀疏更新,就可以平滑地移动和旋转我们的Spaceship节点。请注意,由于我们在模拟一些延迟,我们使用固定的插值持续时间。在更现实的场景中,你会使用ENetPacketPeer.PEER_ROUND_TRIP_TIME统计信息作为实际插值持续时间的参考。

在本节中,我们看到了如何使用Timer节点来模拟一些延迟,并通过使用Tween类,在Spaceship节点的位置和旋转之间进行插值。我们还看到了如何访问有关两个对等体之间连接的一些统计信息,特别是关于客户端和服务器之间的延迟。然而,当我们需要保持运动的一致性而服务器没有更新时会发生什么?这就是下一节我们将要讨论的内容!

提前预测

预测,与插值不同,完全是关于提前进入游戏——字面上的。它涉及到根据对象过去的行为做出关于其未来位置的有根据的猜测。当网络延迟导致数据更新滞后时,预测介入,确保你的角色动作保持响应和即时,即使在网络波动的情况下。

为了实现预测,我们将使用一些牛顿物理学来计算Spaceship节点的速度,并根据这个计算预测它下一个 tick 可能的位置,并使用它来外推其位置和旋转。这将帮助我们防止Spaceship节点空闲。

预测和外推的一个核心方面是它们旨在弥补插值的一些缺点。例如,有时我们需要重新同步实际 Spaceship 节点的位置,否则,由于插值持续时间和潜在的延迟,Spaceship 节点将始终落后,这可能会积累到游戏不再是实时进行的地步。此外,我们还将使用这个同步时间作为预测的参考。因此,打开 res://09.prototyping-space-adventure/Actors/Player/Player2D.tscn 场景,让我们开始实现必要的步骤:

  1. 首先,让我们添加一个新的 Timer 节点,并将其命名为 SynchronizationTimer。这个节点的速度需要大于 InterpolationTimer

图 12.5 – 带有同步定时器节点的玩家场景层次结构

图 12.5 – 带有同步定时器节点的玩家场景层次结构

  1. 然后,我们将 SynchronizationTimer 节点的 timeout 信号连接到 Player 节点的脚本中的回调,我们可以将其命名为 _on_synchronization_timer_timeout()

图 12.6 – 连接到玩家  方法的同步定时器超时信号

图 12.6 – 连接到玩家 _on_synchronization_timer_timeout() 方法的同步定时器超时信号

  1. 然后,让我们打开 res://09.prototyping-space-adventure/Actors/Player/Player2D.gd 脚本,并在 setup_multiplayer() 方法中,如果这个实例是服务器,我们也将启动 SynchronizationTimer

    @rpc("any_peer", "call_local")
    func setup_multiplayer(player_id):
         var self_id = multiplayer.get_unique_id()
         var is_player = self_id == player_id
         set_process_unhandled_input(is_player)
         camera.enabled = is_player
         if not multiplayer.is_server():
              camera.make_current()
         else:
              $InterpolationTimer.start()
              $SynchronizationTimer.start()
    
  2. 现在,在 _on_synchronization_timer_timeout() 回调中,我们将执行两个 RPC – 一个是调用 synchronize_position() 方法的,另一个是调用 synchronize_rotation() 方法的。我们将很快实现这些方法,但到目前为止,只需知道它们分别请求目标位置和旋转,以及一个同步节拍。对于同步节拍,我们将使用 SynchronizationTimer 节点的 wait_time 属性作为参考:

    func _on_synchronization_timer_timeout():
         rpc("synchronize_position", spaceship.position, $SynchronizationTimer.wait_time)
         rpc("synchronize_rotation", spaceship.rotation, $SynchronizationTimer.wait_time)
    
  3. 现在,让我们首先实现 synchronize_position() 方法。只有 Spaceship 节点,它应该只远程调用:

    @rpc("authority", "call_remote")
    func synchronize_position(new_position, synchronization_tic):
    
  4. 在这个方法内部,我们将停止所有当前正在处理的 Tween 实例;请注意,这种方法在我们的游戏中有效,因为我们只有 interpolate_*() 方法创建 Tween 实例。如果你在游戏中还有其他正在运行的 Tween 实例,我建议将它们存储在一个数组中,并遍历它们以停止活动的实例。我们这样做是为了停止插值继续,因为我们将会手动设置 Spaceship 节点的最终位置:

    @rpc("authority", "call_remote")
    func synchronize_position(new_position, synchronization_tic):
         for tween in get_tree().get_processed_tweens():
              tween.stop()
    
  5. 然后,我们将创建一个变量来存储基于我们即将做出的预测的未来位置,这个预测将基于我们刚刚收到的先前和新的位置。我们将在稍后处理预测方法,但现在,只需知道它将要求一个新位置以及你想要预测多少秒。我们将使用这个预测来在实现 用外推法展望未来 部分时外推运动:

    @rpc("authority", "call_remote")
    func synchronize_position(new_position,      synchronization_tic):
         for tween in get_tree().get_processed_tweens():
         tween.stop()
         var future_position = predict_position(new_position, synchronization_tic)
    
  6. 之后,我们可以将 Spaceship 节点的位置设置为新位置,并更新 previous_position 以匹配最新的值,这样在下一次滴答时,它就能保持对先前更新值的引用:

    @rpc("authority", "call_remote")
    func synchronize_position(new_position, synchronization_tic):
    for tween in get_tree().get_processed_tweens():
    tween.stop()
    var future_position = predict_position(new_position, synchronization_tic)
    spaceship.position = new_position
    previous_position = new_position
    
  7. 至于 predict_position() 方法,它将在客户端的本地发生,因此这里不需要进行 RPC。让我们声明函数的签名,看看我们如何通过一些物理知识来预测未来:

    func predict_position(new_position, seconds_ahead):
    

    predict_position() 方法内部,我们将计算从先前位置到新位置的距离。我们还将计算从先前位置到新位置的方向,这样我们就有 Vector2 来工作,预测运动的速度:

    func predict_position(new_position, seconds_ahead):
    var distance = previous_position.distance_to(new_position)
    var direction = previous_position.direction_to(new_position)
    
  8. 有了这个,我们将根据我们想要预测多少秒来计算运动的线性速度。然后我们将这个线性速度设置为 Spaceship.linear_velocity 属性,这样它就不会在更新之间闲置,因为 Spaceship 节点将开始使用这个新速度移动:

    func predict_position(new_position, seconds_ahead): var distance = previous_position.distance_to(new_position) var direction = previous_position.direction_to(new_position) var linear_velocity = (direction * distance) / seconds_ahead spaceship.linear_velocity = linear_velocity
    
  9. 最后,我们将线性速度加到新位置上,以预测下一个位置将会是什么。然后我们将返回这个新位置,这样我们就可以在我们决定外推 Spaceship 节点的运动时使用这个值:

    func predict_position(new_position, seconds_ahead):
    var distance = previous_position.distance_to(new_position)
    var direction = previous_position.direction_to(new_position)
    var linear_velocity = (direction * distance) / seconds_ahead
    spaceship.linear_velocity = linear_velocity
    var next_position = new_position + (linear_velocity * seconds_ahead)
    return next_position
    
  10. 预测旋转的逻辑将完全相同,但请注意,我们将使用 lerp_angle() 内置方法来确定外推的最接近角度。synchronize_rotation() 方法将看起来像这样:

    @rpc("authority", "call_remote")
    func synchronize_rotation(new_rotation, synchronization_tic):
    for tween in get_tree().get_processed_tweens():
    tween.stop()
    var future_rotation = predict_rotation(new_rotation, synchronization_tic)
    spaceship.rotation = new_rotation
    previous_rotation = new_rotation
    
  11. predict_rotation() 方法将看起来像这样:

    func predict_rotation(new_rotation, seconds_ahead):
    var angular_velocity = lerp_angle(previous_rotation, new_rotation, 1.0) / second
    s_ahead
    spaceship.angular_velocity = angular_velocity
    var next_rotation = spaceship.rotation + (angular_velocity * seconds_ahead)
    return next_rotation
    

    有了这个方法,我们可以开始基于 SynchronizationTimer 节点的滴答声,对 Spaceship 节点在不久的将来可能的位置做出假设。然而,请注意,这是服务器端一个非常重要的可用功能,因为有时我们可能想用它来减轻 Player 交互中的延迟并触发正确的游戏事件。例如,如果我们决定有一些 玩家对玩家PvP) 交互,我们可能需要预测当另一个玩家开枪时,给定玩家的 Spaceship 在哪里。这是因为,由于延迟,玩家可能已经做出猜测并击中目标。然而,是否击中实际上取决于服务器,考虑到延迟和其他方面。

在本节中,我们看到了处理在线多人游戏中滞后和延迟的两个重要技术——预测和同步。预测涉及根据物体的过去行为做出关于其未来位置和旋转的有根据的猜测。为了实现预测,使用牛顿物理学计算来计算 Spaceship 节点的速度,并预测其可能的位置和旋转。

我们还看到了如何通过停止正在进行的 Tween 实例并相应地更新 Spaceship 节点的位置和旋转来实现同步过程。

在下一节中,我们将使用预测的位置和旋转来外推 Spaceship 节点的移动,包括线性移动和角移动,这样如果我们错过了更新,我们至少可以模拟一个移动,并在必要时在同步中修复它。

通过外推展望未来

外推是滞后补偿三剑客中的先知,展望未来以预测物体将出现在哪里。通过分析游戏当前状态和物体的轨迹,外推超越了现有数据,提供对未来的洞察。这种技术在快节奏的游戏中特别有用,因为一秒钟的延迟可能意味着胜利和失败的区别。

外推的整体想法是它是对未来的插值。使用我们做出的预测,我们可以创建另一个基于一些假设的插值,即在我们等待其实际位置时玩家可能的位置。这将防止更新之间的中断和闲置。让我们实现我们的外推算法。打开 res://09.prototyping-space-adventure/Actors/Player/Player2D.gd 脚本,并按照以下步骤进行:

  1. 从函数签名开始,extrapolation_position() 方法将请求下一个位置和持续时间的秒数,即外推持续的时间。在这里,我们将使用与预测中类似的术语,例如 seconds_ahead,因为我们将会处理未来的时间:

    func extrapolate_position(next_position, seconds_ahead):
    
  2. 这个函数仅在客户端发生,因此不需要向其添加任何 RPC 注解。在这个函数内部,我们将使用一个新的 Tween 实例,从先前已知的位置到预测的下一个位置进行插值,使用 seconds_ahead 变量作为持续时间:

    func extrapolate_position(next_position, seconds_ahead):
         var tween = create_tween()
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
         var tweener = tween.tween_property(spaceship, "position", next_position, seconds_ahead)
         tweener.from(previous_position)
    
  3. 这基本上就是全部内容。我们将在更新当前和先前位置之前,在 synchronize_position() 方法中调用 extrapolate_position() 方法。此外,我们将使用 future_position 变量,它存储预测的位置,作为外推下一个位置的参数:

    @rpc("authority", "call_remote")
    func synchronize_position(new_position, synchronization_tic):
         for tween in get_tree().get_processed_tweens():
              tween.stop()
         var future_position = predict_position(new_position, synchronization_tic)
         extrapolate_position(future_position, synchronization_tic)
         spaceship.position = new_position
         previous_position = new_position
    
  4. 我们对 extrapolate_rotation() 方法也做同样的事情。它应该看起来像这样:

    func extrapolate_rotation(target_rotation, seconds_ahead):
         var tween = create_tween()
         tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
         var tweener = tween.tween_property(spaceship, "rotation", target_rotation, secon
    ds_ahead)
         tweener.from(previous_rotation)
    
  5. 在添加调用 extrapolate_rotation() 方法的行之后,synchronize_rotation() 方法应该看起来像这样,使用 future_rotation 变量作为参数:

    @rpc("authority", "call_remote")
    func synchronize_rotation(new_rotation, synchronization_tic):
         for tween in get_tree().get_processed_tweens():
              tween.stop()
         var future_rotation = predict_rotation(new_rotation, synchronization_tic)
         extrapolate_rotation(future_rotation, synchronization_tic)
         spaceship.rotation = new_rotation
         previous_rotation = new_rotation
    

在本节中,你学习了在线多人游戏开发中外推的概念。外推是一种展望未来以预测物体下一步位置的技巧。通过分析游戏当前状态和物体的轨迹,外推超越了现有数据,为未来可能发生的情况提供了一瞥。这在节奏快速的游戏中尤其有用,因为一秒钟的延迟可能会显著影响游戏体验。外推的实现涉及从先前的已知位置和旋转到预测的下一个位置和旋转的插值,使用Tween实例,持续时间设置为未来期望的时间。

摘要

在本章中,我们学习了由滞后、延迟和丢包引起的问题。然后,我们看到了如何通过实现滞后补偿技术来解决这个问题。我们探讨了插值、预测、同步和外推的概念,以确保即使在面对网络延迟的情况下,游戏也能保持平滑和响应。

首先,我们深入探讨了插值,这是关于滞后补偿的核心技术。插值通过在两个已知值之间进行动画,帮助解决延迟和稀疏数据更新的某些缺点,而实际更新尚未到达。这确保了Spaceship节点不会闲置,等待来自网络的新的更新。它将平滑地移动到新数据,而不是突然传送到它那里。

然后,我们讨论了预测,它涉及根据物体的过去行为做出关于其未来位置的明智猜测。通过使用牛顿物理学计算,我们能够计算出宇宙飞船的速度并预测其可能的位置和旋转。这有助于防止闲置动作并保持游戏响应。

然后,我们探讨了外推,它超越了现有数据以预测物体的下一步位置。通过从先前的已知位置和旋转到预测的下一个位置和旋转进行插值,我们能够创建平滑的运动,即使错过了更新。这种技术在节奏快速的游戏中特别有用,因为瞬间的延迟可能会显著影响游戏体验。

通过实现这些滞后补偿技术,我们可以在网络出现故障和延迟的情况下,为玩家提供无缝和沉浸式的多人游戏体验。

在下一章中,我们将看到如何将一些数据存储在客户端机器上,以减少我们游戏中使用的带宽,依赖于玩家已经在他们的机器上可用的数据。

第十三章:缓存数据以减少带宽

在游戏开发中,谈到减少带宽使用和优化网络使用,总会想到一种强大的技术:缓存。

缓存解决了这样一个问题:为什么我们可以在下载一次后,将其存储在某个地方,并在需要时重复使用,而无需反复下载相同的数据。在本章中,我们将深入研究缓存技术,并学习如何将它们应用于高效下载、存储和重复使用图像和其他相关数据。为此,我们将使用一个包含图像 URL 的数据库,我们将直接从互联网将其下载到玩家的机器中。

为了展示这些缓存技术的实现,我们将在我们的游戏项目中原型化一个新功能,玩家将能够上传自定义图像作为他们的飞船。为了节省时间并专注于这一功能的网络方面,我们将避免实现用户体验和用户界面方面,将这些任务留给我们想象中的独立工作室中的才华横溢的个体。作为开发者,您的角色将是处理这一功能的网络相关方面,并确保其无缝集成。

在下面的屏幕截图中,您可以见证这一功能在实际操作中的激动人心的结果。两位玩家正在使用从服务器下载的个性化飞船精灵进行游戏。这些精灵来自 Twemoji,这是一个由 Twitter 维护的 Creative Commons 许可的开源表情符号仓库。

图 13.1 – 为两位玩家从服务器下载的个性化飞船精灵

图 13.1 – 为两位玩家从服务器下载的个性化飞船精灵

本章涵盖的主题如下:

  • 理解缓存

  • 设置HTTPRequest节点

  • 实现纹理缓存

  • 实现数据库缓存

  • 缓存的进一步应用

技术要求

值得注意的是,本章建立在第十章中提出的概念之上,即调试和性能分析网络,以及第九章中开发的项目,即创建在线冒险原型。因此,熟悉那些章节中讨论的概念和技术对于完全理解这里提出的优化方法至关重要。我们还将基于第十二章的最终项目,即实现延迟补偿,您可以通过以下链接获取文件:

github.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0/tree/12.prediction-and-interpolation

此外,在本章中,了解如何在在线服务上上传和托管内容的基本方法是很重要的。例如,我们将直接从项目的 GitHub 仓库以及名为 ImgBB 的服务下载文件,ImgBB 是一个免费图像托管平台。如果不理解使用直接链接进行内容托管和检索的机制,你可能会在理解和实施我们即将探讨的过程时遇到困难。

为了丰富你的学习体验,我强烈推荐下载 Twemoji 仓库的最新版本,该版本可以从github.com/twitter/twemoji获取。通过探索这个仓库,你将深入了解如何管理和将图像和其他媒体内容纳入你的游戏开发项目中。

话虽如此,让我们来了解什么是缓存以及我们如何在我们的游戏项目中使用它。

理解缓存

在在线多人游戏中,每一秒都很宝贵。玩家期望无缝、实时的体验,没有中断。这就是缓存成为优化游戏性能的强大盟友的地方。那么,缓存究竟是什么,为什么它对在线多人游戏至关重要?

缓存是将频繁访问的数据或资源存储在本地设备或中间服务器上的过程。这些资源可以包括图像、声音文件、3D 模型,甚至是一些小的代码片段。游戏不需要每次需要这些资源时都从远程服务器获取,而是将它们存储在本地。当出现对这些资源的请求时,游戏会检查是否已经有一个本地副本。如果有,它将使用本地版本,这显著减少了加载时间并节省了宝贵的网络带宽。

缓存的原理简单而有效:如果你曾经使用过某个东西,那么你很可能还需要它。在我们多人游戏的环境中,这意味着玩家下载到他们设备上的图像、声音和资产可以被本地缓存。当另一个玩家进入视野时,游戏可以从本地缓存中检索这些资产,而不是重新下载。这创造了一个更流畅的体验,并减少了网络的压力。

缓存在线多人游戏中提供了许多优势。最突出的好处是提升了游戏体验。通过使用缓存的资源,玩家可以迅速与其他人互动,看到他们个性化的飞船设计,并参与战斗而不会出现显著的延迟。减少的加载时间意味着更流畅和沉浸式的游戏体验。此外,这个过程减轻了服务器的负担,使其能够同时处理更多的玩家。

缓存不仅仅是关于速度;它还关乎效率。重复下载相同的资源不仅浪费带宽,而且使游戏对环境不那么友好,对于数据计划有限的玩家来说可能还会产生额外成本。通过缓存常用资源,游戏变得更快、更环保、更经济。

在接下来的章节中,我们将探讨如何利用强大的HTTPRequest节点来实现自定义宇宙飞船图像的缓存。我们将深入探讨下载这些图像并确保它们在每个玩家的缓存中可用,从而增强在线多人游戏体验。敬请期待一步步指导如何在您的游戏中实现缓存。

设置 HTTPRequest 节点

如同章节引言中提到的,我们将实现一个功能,允许玩家在他们的宇宙飞船上使用自定义精灵。

为了原型化这个功能,我们将从提供免费图片托管服务的第三方图片托管服务下载图片。我们将通过使用超文本传输协议HTTP)请求从第三方服务器检索图片文件来实现这一点。让我们深入了解 HTTP 的工作原理,以全面理解其操作并掌握实现过程。

理解 HTTP 协议

HTTP是万维网通信的基础。它是一种定义客户端和服务器之间交互和数据交换的协议。由蒂姆·伯纳斯-李在 20 世纪 90 年代初发明,HTTP 最初是为了方便检索超文本文档,也就是我们常说的网页。随着时间的推移,它已经发展支持各种类型的内容,包括图片、视频和文件。

当客户端,比如我们的玩家,想要从服务器检索资源时,它会发起一个 HTTP 请求。这个请求包含一个方法,指定了对资源要执行的操作,以及GETPOSTPUTDELETE。在下载图片的上下文中,我们通常使用GET方法。

服务器在接收到 HTTP 请求后,会处理它并准备一个 HTTP 响应。这个响应包含请求的资源,并伴随 HTTP 状态码、内容类型和内容长度等元数据。

此外,服务器在响应中包含头部信息,以提供更多信息或指令给客户端。

要使用 HTTP 下载图片,客户端向服务器发送一个指定图片 URL 的GET请求。服务器处理这个请求,然后发送一个包含图片数据的 HTTP 响应。客户端接收这个响应,并将其解释为向用户显示图片。

HTTP 作为一个无状态协议运行,这意味着每个请求-响应周期都是独立的,并且不会保留任何关于之前交互的信息。

然而,可以使用诸如 cookies 和会话管理等机制来维护状态并启用更复杂的交互。

总结来说,HTTP 作为协议,促进了客户端和服务器之间的通信。它使我们能够通过向服务器发送 HTTP 请求并接收包含所需数据的 HTTP 响应来下载图像和其他资源。理解 HTTP 的工作原理对于实现如在我们游戏项目中下载图像等特性至关重要。

我们将要使用 HTTP 请求的原因是,我们想要缓存的文件类型相对于我们通常使用HTTPRequest节点和最小数据库(我们将匹配玩家的用户名和他们的自定义飞船精灵的 URL)传输的数据类型来说相当大。这个数据库打算放在服务器端,并在稍后缓存在玩家的机器上,正如我们将在实现数据库 缓存部分中看到的那样。

设置场景和数据库

让我们开始设置场景和数据库。

要创建我们的数据库,让我们打开res://09.prototyping-space-adventure/文件夹并创建一个新的文本文件。你可以通过在FileSystem窗口中右键单击文件夹来快速完成此操作。下面的截图显示了弹出的菜单。从那里,选择新建 | 文本文件...

图 13.2 – 通过 FileSystem 窗口直接创建新文本文件

图 13.2 – 通过 FileSystem 窗口直接创建新文本文件

然后,创建一个名为PlayerSpaceships.json的文件,如下面的截图所示:

图 13.3 – 创建一个名为 PlayerSpaceships.json 的新文本文件

图 13.3 – 创建一个名为 PlayerSpaceships.json 的新文本文件

现在,关于内容,我们将从PlayerSpaceships.json文件中维护的用户内容将如下所示:

{
     "user1": "https://i.ibb.co/KxqzJMp/rocket.png",
     "user2": "https://i.ibb.co/d7BR6hX/saucer.png"
}

注意,你可以尝试使用其他图像和托管服务。只要你有图像的直接链接,通常指向.png文件,你就没问题了。

现在,是时候设置HTTPRequest节点了。我们将从从服务器下载PlayerSpaceships.json文件开始。在我们的例子中,这个文件托管在 GitHub 上,但你可以在任何其他服务器上存储它,只要你有指向实际数据库文件的直接链接。在我们的例子中,你可以在这里找到它:

raw.githubusercontent.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0/13.caching-data/source/09.prototyping-space-adventure/PlayerSpaceships.json

PlayerSpaceships.json文件已上传到互联网上时,让我们看看如何通过以下步骤将其下载到玩家的机器上:

  1. 创建一个新的场景,并使用HTTPRequest节点作为根节点。

  2. 将其重命名为 SpaceshipsDatabaseDownloadHTTPRequest,因为这个节点将负责从互联网下载数据库。

  3. 将一个新的脚本附加到这个节点上,并保存场景和脚本。在这里,我直接将它们保存为 res://09.prototyping-space- adventure/SpaceshipsDatabaseDownloadHTTPRequest

  4. 现在,打开脚本,让我们做以下操作:

    1. 创建一个导出变量,用于存储我们将用于缓存的文件夹路径。在这里,使用 user:// 数据文件夹路径非常重要,这样 Godot 引擎可以正确地根据游戏运行的平台调整路径:
    extends HTTPRequest
    @export_global_dir var cache_directory = "user://.cache/"
    
    1. 然后,创建一个新的导出变量,它应该指向数据库文件将要保存的位置。让我们保持其默认文件名,并将其放入 cache 文件夹:
    extends HTTPRequest
    @export_global_dir var cache_directory = "user://.cache/"
    @export_global_file var spaceships_database_path = "user://.  cache/PlayerSpaces
    hips.json"
    
    1. 然后,我们可以导出另一个变量。但现在我们需要存储 PlayerSpaceships.json 文件下载位置的链接:
    extends HTTPRequest
    @export_global_dir var cache_directory = "user://.cache/"
    @export_global_file var spaceships_database_path = "user://.cache/PlayerSpaces
    hips.json"
    @export var spaceships_database_link = "https://raw.githubusercontent.com/PacktPublishing/The-Essential-Guide-to-Creating-Multiplayer-Games-with-Godot-4.0/13.caching-data/source/09.prototyping-space-adventure/PlayerSpaceships.json"
    
    1. 有了这些,我们可以继续到实际的下载方法。创建一个新的方法,命名为 download_spaceships_database(),然后开始它的实现。

    2. 我们在这里要做的第一件事是检查是否已经存在缓存目录。如果没有,我们将创建它:

    func download_spaceships_database():
         var directory_access = DirAccess.open(cache_directory)
         if not directory_access:
              DirAccess.make_dir_absolute(cache_directory)
    
    1. 然后,我们将检查 PlayerSpaceships.json 文件是否存在。如果不存在,我们将开始实际的下载。开始下载的第一件事是在 download_file 成员变量中设置文件路径:
    func download_spaceships_database():
         var directory_access = DirAccess.open(cache_directory)
         if not directory_access:
              DirAccess.make_dir_absolute(cache_directory)
         var file_access = FileAccess.open(spaceships_database_path, FileAccess.READ)
         if not file_access:
              download_file = spaceships_database_path
    
    1. 在设置好 download_file 之后,我们可以向文件发出请求。为此,我们将使用 request() 方法,该方法请求一个 URL。这个方法默认使用 GET 方法来发出请求,这正是我们想要的。但如果你想在传递一些自定义头信息后更改这个设置,你可以在第三个参数中更改它。在我们的情况下,我们不需要传递除了 URL 之外的其他任何内容:
    func download_spaceships_database():
         var directory_access = DirAccess.open(cache_directory)
         if not directory_access:
              DirAccess.make_dir_absolute(cache_directory)
         var file_access = FileAccess.open(spaceships_database_path, FileAccess.READ)
         if not file_access:
              download_file = spaceships_database_path
              request(spaceships_database_link)
    
    1. 然后,我们需要等待请求完成。记住,由于这是一个异步过程,游戏需要在继续到任何依赖于这个文件的逻辑之前等待它完成:
    func download_spaceships_database():
         var directory_access = DirAccess.open(cache_directory)
         if not directory_access:
              DirAccess.make_dir_absolute(cache_directory)
         var file_access = FileAccess.open(spaceships_database_path, FileAccess.READ)
         if not file_access:
              download_file = spaceships_database_path
              request(spaceships_database_link)
              await request_completed
    

在遵循前面的步骤之后,我们应该让 SpaceshipsDatabaseDownloadHTTPRequest 开始工作。如果你想测试它,可以在 _ready() 回调中调用 download_spaceship_database() 方法并运行场景。之后,如果你打开用户数据文件夹,你会看到 .cache/ 文件夹,如果你进入这个文件夹,你应该在那里找到 PlayerSpaceships.json 文件。注意,在这种情况下,.cache/ 是一个隐藏文件夹,所以请确保你可以在文件管理器中看到隐藏文件夹。要快速打开用户数据文件夹,你可以转到 项目 | 打开用户数据文件夹,如图下所示:

图 13.4 - 从编辑器的项目标签页打开用户数据文件夹

图 13.4 - 从编辑器的项目标签页打开用户数据文件夹

现在,让我们创建一个名为TextureDownloadHTTPRequest的场景,这样我们就可以使用PlayerSpaceships.json中的数据来有效地下载玩家的自定义飞船精灵。为此,请按照以下步骤操作:

  1. 创建一个新的场景,并使用一个HTTPRequest节点作为根节点。

  2. 将其重命名为TextureDownloadHTTPRequest,因为这个才是真正负责从互联网下载纹理的:

  3. 将场景保存为res://09.prototyping-space- adventure/TextureDownloadHTTPRequest.tscn,并将其附加到一个脚本上,然后在脚本中执行以下操作:

    1. 导出一个变量,该变量应指向玩家机器上的PlayerSpaceships.json文件,因此此路径应使用user://文件路径:
    extends HTTPRequest
    @export_global_file var spaceships_database_file = "user://.cache/PlayerSpaces hips.json"
    
    1. 创建一个名为download_spaceship()的新方法。此方法应接收两个参数,一个用于用户,另一个用于精灵将被保存的文件路径:
    extends HTTPRequest
    @export_global_file var spaceships_database_file = "user://.cache/PlayerSpaces hips.json"
    func download_spaceship(user, sprite_file):
    
    1. 然后,在这个方法内部,我们将创建一个新的名为players_spaceshipsDictionary,它将开始为空,但很快将存储来自PlayerSpaceships.json文件的内容:
    func download_spaceship(user, sprite_file):
         var players_spaceships = {}
    
    1. 现在,我们将检查由spaceships_database_file路径提供的文件是否存在。如果存在,我们将打开它,使用FileAccess.get_as_text()将其转换为字符串,并从 JSON 格式解析为Dictionary对象格式,将其存储在players_spaceships变量中:
    func download_spaceship(user, sprite_file):
         var players_spaceships = {}     if FileAccess.file_exists(spaceships_database_file):
              var file = FileAccess.open(spaceships_database_file, FileAccess.READ)
              players_spaceships = JSON.parse_string(file.get_as_text())
    
    1. 之后,我们可以根据PlayerSpaceships.json数据库提供的 URL 下载user精灵,并将其存储在sprite_file参数提供的文件路径中。为此,我们将使用HTTPRequest.download_fileHTTPRequest.request()方法来下载。请注意,如果请求有任何问题,此方法会返回错误。让我们将其存储在error变量中,以便其他类可以验证请求是否成功:
    func download_spaceship(user, sprite_file):
         var players_spaceships = {}
         if FileAccess.file_exists(spaceships_database_file):
              var file = FileAccess.open(spaceships_database_file, FileAccess.READ)
              players_spaceships = JSON.parse_string(file.get_as_text())
         if user in players_spaceships:
              download_file = sprite_file
              var error = request(players_spaceships[user])
    
    1. 由于 HTTP 请求可能需要一些时间才能完成内容的下载,我们需要在结束函数并返回错误之前等待HTTPRequest.request_completed信号。请注意,如果函数没有达到这些条件语句中的任何一个,我们应该返回FAILED以告知其他类spaceships_database_file不存在或数据库中不存在user
    func download_spaceship(user, sprite_file):
         var players_spaceships = {}
         if FileAccess.file_exists(spaceships_database_file):
              var file = FileAccess.open(spaceships_database_file, FileAccess.READ)
              players_spaceships = JSON.parse_string(file.get_as_text())
         if user in players_spaceships:
              download_file = sprite_file
              var error = request(players_spaceships[user])
              await request_completed
              return error
         return FAILED
    

好的,这就结束了我们设置 HTTPRequests 的旅程。我们创建了数据库和两个负责处理其数据的节点,将数据库下载到玩家的机器上,以及下载数据库中的内容,在这种情况下,是玩家的飞船自定义精灵。您也可以通过使用"user1""user://.cache/user1_spaceship.png"作为参数调用download_spaceship()方法来测试场景。只需确保您已经运行了SpaceshipDatabaseDownloadHTTPRequest场景,这样PlayersSpaceships.json文件就存在于user://.cache/文件夹中。有了这个,您应该会在用户数据文件夹中看到一个新的图像被下载!以下截图显示了我的user://.cache/文件夹的样子:

图 13.5 – 包含 PlayerSpaceships.json 文件和用户 1 自定义飞船精灵的用户数据文件夹

图 13.5 – 包含 PlayerSpaceships.json 文件和用户 1 自定义飞船精灵的用户数据文件夹

在本节中,我们学习了如何使用 HTTP 请求在用户数据文件夹中缓存数据和下载图像。我们看到了 UDP 和 HTTP 在传输大型文件(如图像)时的区别,以及如何使用HTTPRequest节点在 Godot 中使用 HTTP 协议并将数据从互联网传输到我们的机器。

在下一节中,我们将讨论在游戏中实现纹理缓存的方法,允许实时更改玩家的飞船。这个功能将通过使用我们创建的HTTPRequest节点和我们的最小数据库来获取玩家自定义飞船精灵的 URL 来实现。

实现纹理缓存

在上一节中,我们介绍了HTTPRequest节点,这是 Godot 引擎提供的内置解决方案,用于进行 HTTP 请求。然后我们创建了TextureDownloadHTTPRequest,这是一个专门用于从我们的PlayersSpaceship.json数据库下载图像的自定义节点。现在,让我们深入了解将此节点集成到我们的Player2D类中,以便我们可以在原型中实际使用它。

在本节中,我们将创建一个方法,使服务器能够动态地更改玩家的飞船精灵。但我们将不会加载任何精灵;我们将从我们在设置场景和数据库部分设置的user://.cache/文件夹中获取正确的文件。这种方法将增强游戏中的定制和交互,允许服务器实时向玩家发送自定义精灵。

为了实现这一点,我们将创建一个名为load_spaceship()的方法。这个方法将在我们的实现中扮演基本角色。它将是一个 RPC 函数,服务器可以远程触发以针对特定玩家。好吧,让我们开始吧!打开res://09.prototyping-space-adventure/Actors/Player/Player2D.tscn场景,并按照以下步骤进行操作:

  1. 让我们先添加一个TextureDownloadHTTPRequest实例,直接作为Player节点的子节点。

图 13.6 – 在玩家场景中实例化的 TextureDownloadHTTPRequest

图 13.6 – 在玩家场景中实例化的 TextureDownloadHTTPRequest

  1. 然后,打开res://09.prototyping-space-adventure/Actors/Player/Player2D.gd脚本。

  2. 让我们存储对TextureDownloadHTTPRequest节点的引用。我们可以简称为http_request

    @onready var http_request = $TextureDownloadHTTPRequest
    
  3. 然后,在setup_multiplayer()方法下面,让我们创建load_spaceship()方法。它将是一个 RPC 方法,只能由服务器远程调用,并且也应该在本地调用,因此服务器也会更新图像。此方法接收用户,加载飞船:

    @rpc("authority", "call_local")
    func load_spaceship(user):
    
  4. 现在,在这个方法内部,我们将创建玩家机器中飞船纹理文件的文件路径并将其存储在一个名为 spaceship_file 的变量中。这个文件路径由 "user://.cache/" 字符串组成,后面跟着我们作为参数获取的 user 值,然后我们将追加 "_spaceship.png" 以创建带有图像扩展名的正确文件路径:

    @rpc("authority", "call_local")
    func load_spaceship(user):
         var spaceship_file = "user://.cache/" + user + "_spaceship.png"
    
  5. 在有了合适的文件路径后,下一步是查看该文件是否已经存在,因为缓存数据的整个想法是,如果它在玩家的机器中已经存在,我们可以加载它而不是重新下载它,从而减少资源消耗。以下代码检查文件是否存在。如果存在,我们将传递 spaceship_file 作为参数调用 update_sprite() 方法。我们将在稍后创建 update_sprite() 方法:

    @rpc("authority", "call_local")
    func load_spaceship(user):
         var spaceship_file = "user://.cache/" + user + "_spaceship.png"
         if FileAccess.file_exists(spaceship_file):
              update_sprite(spaceship_file)
    
  6. 现在,TextureDownloadHTTPRequest 将发挥其作用。如果文件不在缓存文件夹中,我们将调用 TextureDownloadHTTPRequest.download_texture() 方法并等待其完成;记住,这是一个异步方法。如果这个方法返回一个 OK 错误,意味着实际上没有错误,我们也会调用 update_sprite() 方法,以便游戏执行更新飞船精灵的程序。以下代码检查 update_sprite() 方法是否返回除了 OK 以外的任何内容。如果 update_sprite() 方法不返回 OK,这意味着不会加载任何精灵。由于自定义精灵不会影响玩家的核心体验,我们可以假设我们可以保留默认精灵并让游戏运行:

    @rpc("authority", "call_local")
    func load_spaceship(user):
         var spaceship_file = "user://.cache/" + user + "_spaceship.png"
         if FileAccess.file_exists(spaceship_file):
              update_sprite(spaceship_file)
         else:
              if await http_request.download_spaceship(user, spaceship_file) == OK:
                   update_sprite(spaceship_file)
    
  7. 现在,让我们创建 update_sprite() 方法。这是一个有效更改 Sprite 节点的 Texture 属性的方法。它将使用 Image.load_from_file() 方法从文件路径加载图像,并使用 ImageTexture.create_from_image() 方法将其转换为 ImageTexture

    func update_sprite(spaceship_file):
         var image = Image.load_from_file(spaceship_file)
         var texture = ImageTexture.create_from_image(image)
         $Spaceship/Sprite2D.texture = texture
    

在本节中,我们看到了如何使用 TextureDownloadHTTPRequest 节点从我们的数据库下载和缓存图像。我们还看到了如何使用缓存的图像动态更改玩家的飞船精灵,将它们作为图像资源加载,并将它们转换为飞船的 Sprite2D 节点可以使用的实际 ImageTexture

现在,这个过程的缺失部分是…我们实际上是如何获取 PlayersSpaceship.json 数据库以及我们调用 load_spaceship() 方法的。嗯,这正是我们将在下一节要做的事情!

实现数据库缓存

一切就绪后,是时候再进一步,着手于将一切粘合在一起的 World 场景,并运行适当的程序以确保所有玩家都能看到相同的自定义精灵。我们在这个节点中这样做,因为这是一个负责设置与世界同步相关的一切的类,包括玩家的飞船自定义精灵。我们需要在一些核心方法中进行一些更改以实现这一点,但这会更好。

由于我们正在处理原型,所以我们不用担心更改函数签名和类的其他核心方面。但请注意,如果这是生产就绪的,我们会称这个类为“封闭”的,并避免做出像我们即将做出的核心更改。这将保持我们的游戏代码库一致,并避免错误。尽管我们即将做出的更改将主要扩展类的功能,但我们将在 World.create_spaceship() 方法中添加一个参数,由于该方法之前没有要求任何参数,这会破坏函数的契约。但,正如所说的,这是一个原型,我们有自由按自己的意愿调整事物。所以,让我们打开 res://09.prototyping-space-adventure/Levels/World.tscn 场景,通过以下步骤实现改进:

  1. 首先,让我们将 SpaceshipsDatabaseDownloadHTTPRequest 节点的一个实例作为 World 节点的直接子节点添加。

图 13.7 – 在世界场景中实例化的 SpaceshipsDatabaseDownloadHTTPRequest

图 13.7 – 在世界场景中实例化的 SpaceshipsDatabaseDownloadHTTPRequest

  1. 然后,让我们打开 res://09.prototyping-space-adventure/Levels/World.gd 脚本,通过添加对 SpaceshipsDatabaseDownloadHTTPRequest 节点的引用来开始编写代码。在这里,我们也可以简单地将其称为 http_request

    @onready var http_request = $SpaceshipsDatabaseDownloadHTTPRequest
    
  2. 我们还将创建一个新的变量来存储 player_ids 和它们的 user。这将允许我们查看此变量以找到与每个玩家的 peer ID 相关的正确用户名,这样我们就可以在数据库中轻松地将它们映射起来:

    var player_users = {}
    
  3. 现在,在 _ready() 回调中,在等待 0.1SceneTree 的计时器超时后,我们将等待 http_request 下载飞船数据库。记住,这只有在运行游戏的实例不是服务器的情况下才会发生:

    func _ready():
         if not multiplayer.is_server():
              await(get_tree().create_timer(0.1).timeout)
              await http_request.download_spaceships_database()
    
  4. 仍然在这个 if 语句中,我们将进行一个小改动。当调用服务器的 create_spaceship() 方法进行 RPC 时,我们也将传递玩家的用户信息。为此,我们将使用长时间未被使用的 AuthenticationCredentials 单例!

    func _ready():
         if not multiplayer.is_server():
              await(get_tree().create_timer(0.1).timeout)
              await http_request.download_spaceships_database()
              rpc_id(1, "sync_world")
              rpc_id(1, "create_spaceship", AuthenticationCredentials.user)
    
  5. 接下来,让我们进入 create_spaceship() 方法,当然,我们需要更改其函数签名以支持 user 参数:

    @rpc("any_peer", "call_remote")
    func create_spaceship(user):
    
  6. 然后,在将 spaceship 作为 Players 节点的子节点之前,我们将使用其名称(这实际上是玩家 peer ID 的字符串版本)作为 player_users 字典中的键,并将此键的值设置为 user 参数。这样,我们就有效地将玩家的 ID 与他们的用户名配对:

    @rpc("any_peer", "call_remote")
    func create_spaceship(user):
         var player_id = multiplayer.get_remote_sender_id()
         var spaceship = preload("res://09.prototyping-space-adventure/Actors/Player/Play er2D.tscn").instantiate()
         spaceship.name = str(player_id)
         player_users[spaceship.name] = user
    
  7. 在对新生成的玩家飞船的 setup_multiplayer() 方法进行 RPC 后,我们将通过也对该 load_spaceship() 方法进行 RPC 来实现魔法,这将触发我们在 实现纹理缓存 部分中制作的程序。经过这些更改后,create_spaceship() 方法的更新版本将如下所示:

    @rpc("any_peer", "call_remote")
    func create_spaceship(user):
         var player_id = multiplayer.get_remote_sender_id()
         var spaceship = preload("res://09.prototyping-space-adventure/Actors/Player/Play er2D.tscn").instantiate()
         spaceship.name = str(player_id)
         player_users[spaceship.name] = user
         $Players.add_child(spaceship)
         await(get_tree().create_timer(0.1).timeout)
         spaceship.rpc("setup_multiplayer", player_id)
         spaceship.rpc("load_spaceship", user)
    

太好了!有了这个,每当玩家跳入游戏时,他们的宇宙飞船将改变其精灵为玩家上传的定制精灵(如果有的话)。现在,我们还需要让在玩家加入游戏时已经在游戏世界中的宇宙飞船也发生同样的变化。为此,我们需要在 _on_players_multiplayer_spawner_spawned() 回调中进行一些更改。仍然在 res://09.prototyping- space-adventure/Levels/World.gd 脚本中,让我们通过转到 _on_players_multiplayer_spawner_spawned() 方法并执行以下步骤来实现这些更改:

  1. 让我们重新设计这个回调函数的整体逻辑,并从头开始。我们将首先创建一个变量来存储玩家的对等节点 ID,我们可以通过将最近生成的节点的名称转换为整数来获取它。节点名称是 StringNames,这意味着它们不是纯字符串,因此我们需要在将它们转换为整数之前将它们转换为默认字符串:

    func _on_players_multiplayer_spawner_spawned(node):
         var player_id = int(str(node.name))
    
  2. 之后,我们将对 setup_multiplayer() 方法进行全局 RPC,就像我们之前做的那样,但现在我们传递 player_id 作为参数:

    func _on_players_multiplayer_spawner_spawned(node):
         var player_id = int(str(node.name))
         node.rpc("setup_multiplayer", player_id)
    
  3. 然后是有趣的部分。在执行这个 RPC 之后,如果生成的节点,换句话说,生成的玩家,恰好位于另一个玩家的游戏实例中,而不是服务器的实例中,我们将直接对服务器进行 RPC,要求它使用一个方法(我们很快就会创建)来同步这个宇宙飞船,该方法接收一个玩家 ID 作为其参数:

    func _on_players_multiplayer_spawner_spawned(node):
         var player_id = int(str(node.name))
         node.rpc("setup_multiplayer", player_id)
         if not multiplayer.is_server():
              rpc_id(1, "sync_spaceship", player_id)
    
  4. 现在让我们继续到 sync_spaceship() 方法。让我们使用 RPC 注解来创建这个方法,这样任何对等节点都可以远程调用它,同时它也会在服务器本地被调用。记住,这个方法接收 player_id 作为其参数:

    @rpc("any_peer", "call_local")
    func sync_spaceship(player_id):
    
  5. sync_spaceship() 方法内部,我们首先存储对调用此函数的人的 ID 的引用。这将允许我们直接调用刚刚加入游戏的玩家,而不是每次有玩家加入游戏时都调用每个玩家:

    @rpc("any_peer", "call_local")
    func sync_spaceship(player_id):
         var requester = multiplayer.get_remote_sender_id()
    
  6. 然后,我们将通过使用 get_node() 方法并附加 player_idPlayers 节点的子节点中找到正确的玩家。记住,由于 player_id 也是节点的名称,这就是我们如何在所有 Players 子节点中轻松找到它的方法:

    @rpc("any_peer", "call_local")
    func sync_spaceship(player_id):
         var requester = multiplayer.get_remote_sender_id()
         var node = get_node("Players/%s" % player_id)
    
  7. 之后,我们将找到玩家的用户名。为此,我们将使用 player_id 作为 player_users 的键,这将返回正确的用户名:

    @rpc("any_peer", "call_local")
    func sync_spaceship(player_id):
         var requester = multiplayer.get_remote_sender_id()
         var node = get_node("Players/%s" % player_id)
         var user = player_users[node.name]
    
  8. 最后,在我们手中有了用户之后,我们可以对这个请求者的 node 实例上的 load_spaceship() 方法进行 RPC,该 node 代表在他们的游戏实例上生成的宇宙飞船:

    @rpc("any_peer", "call_local")
    func sync_spaceship(player_id):
         var requester = multiplayer.get_remote_sender_id()
         var node = get_node("Players/%s" % player_id)
         var user = player_users[node.name]
         node.rpc_id(requester, "load_spaceship", user)
    

我们做到了!有了这个,游戏现在能够加载、存储和同步玩家的自定义飞船精灵。如果你现在运行游戏,你可以看到游戏正在缓存数据以减少带宽,并实时下载和更新玩家的飞船精灵。以下截图显示了实现此功能前后的对比:

图 13.8 – 实现自定义精灵功能前后的对比

图 13.8 – 实现自定义精灵功能前后的对比

如果你想要测量缓存带来的差异,一个有趣的方法是删除检查 user://.cache/ 文件夹中文件是否存在的语句,并添加一个监控器来监控 TextureDownloadHTTPRequestSpaceshipsDatabaseDownloadHTTPRequest 下载的字节数。为此,你可以使用 HTTPRequest.get_downloaded_bytes() 方法。例如,以下代码片段显示了如何在 TextureDownloadHTTPRequest 节点中创建一个监控器:

func _ready():
     var callable = Callable(self, "get_texture_downloaded_bytes")
     Performance.add_custom_monitor("Network/Texture Download Bytes", callable)
func get_texture_downloaded_bytes():
     return get_downloaded_bytes()

有了这个,我们可以多次测试我们的游戏来模拟玩家登录和退出游戏,并查看缓存对每个阶段的影响。你会注意到,在图像被缓存后,当玩家再次登录游戏时,它们几乎是瞬间更新的。以下截图显示了在玩家机器中缓存图像前后对网络使用(以字节为单位)的实际影响:

图 13.9 – 比较玩家首次下载纹理和缓存纹理后的网络消耗

图 13.9 – 比较玩家首次下载纹理和缓存纹理后的网络消耗

在本节中,我们看到了如何在我们的游戏中实现数据库缓存自定义飞船精灵。World 场景负责同步游戏世界,包括玩家的飞船精灵。因此,我们对它进行了一些修改,以实现我们在前几节中实现的缓存。我们看到了如何使用 SpaceshipsDatabaseDownloadHTTPRequest 节点下载飞船数据库,并通过 RPC 更新玩家的飞船精灵并同步飞船精灵,当玩家加入游戏时。在本节的最后,我们使用了一个监控器和 HTTPRequest.get_downloaded_bytes() 方法来查看缓存如何减少带宽使用并提高网络效率。

注意在 图 13.9 中仅缓存两个纹理时节省的字节数!想象一下,从长远来看,这将对每天多次登录和退出游戏的数百甚至数千名玩家产生怎样的影响。而我们在这里只谈论纹理。我们还能缓存什么,并有效地节省数十万网络字节?这正是我们将在下一节中要探讨的内容。

进一步探讨缓存

好吧,正如我们所见,图像并不是我们通过 HTTPRequest 节点从互联网下载的唯一数据类型;例如,我们还下载了 PlayersSpaceship.json 文件,这是一个文本文件。但我们可以使用此协议下载几乎所有内容,前提是它存储在 HTTP 页面上。但有时,一些文件并未存储,也没有在任何人都可以访问的 HTTP 页面上公开。通常在这种情况下,后端工程师会创建一个 REST API,我们可以用它直接从存储这些文件的数据库中检索这些文件。

这种功能需要物理基础设施和自定义 REST API 的开发,以便我们可以与之交互。不幸的是,这远远超出了本书的范围。但整个想法是,你可以使用自定义头和类似 RPC 的自定义 URL 来执行 HTTP 请求。因此,在 URL 本身中,你会添加一些参数,REST API 会将其解释为方法调用和参数。这与我们在 第二章**, 发送和接收数据* 中所做的是非常相似的,而且 REST API 通常使用 JSON 格式的字符串作为主要的数据结构。

例如,你可以在我的 YouTube 频道上查看一系列视频,这些视频大量使用 REST API 将第三方服务集成到我的游戏 Moon Cheeser 中。REST API 由 LootLocker 提供,在某个时刻,我从他们的服务器下载了一个完整的 PacketScene,并在玩家的机器上缓存它,以便他们始终拥有他们购买的皮肤的副本。你可以在以下链接中查看这个特定的视频:

youtu.be/w0qz-pJMIBo?si=WF2KH9-FRyO8glVq

好吧,我这里也要用 LootLocker 的公共 API 作为例子。这是我用来自取玩家购买皮肤相关文件的 HTTP 请求:

curl -X GET "https://api.lootlocker.io/game/v1/assets/list?count=10&filter=purchasabl e" \-H "x-session-token: your_token_here"

这可以翻译成以下 GDScript 代码,使用 HTTPRequest 节点:

var url = "https://api.lootlocker.io/game/v1/assets/list?count=10&filter=purchasable"
var header = ["Content-Type: application/json", "x-session-token: %s" % LootLocker.tok
en]
request(url, header)

现在是我们要探索的有趣部分。当这个请求完成并且服务器向客户端发送响应,包括一些非常有趣的数据时,HTTPRequest.request_completed 信号会被触发,这些数据通常包括响应体,它通常是一个包含我们想要文件 URL 的 JSON 文件。因此,你可以将这个信号连接到一个信号回调,并访问响应体以获取服务器为你提供的关于请求的信息。这可以从文件本身的内容到一个包含更多信息的 JSON 文件,包括下载你想要文件的 URL。

为了避免跑题,我强烈建议你观看视频,了解我们如何下载资源密集型文件。在这种情况下,我下载了一个自定义 PackedScene 实例及其依赖项,例如一个必要的图像,用于正确显示购买的皮肤,并将其缓存在玩家的设备中。

拥有这个工具,你就可以实现各种缓存功能,并大幅节省资源使用,无论是对于你的玩家还是服务器,因为它不会反复向相同的客户端发送相同的文件。

摘要

好吧,是时候结束这一章了!在本章中,我们讨论了缓存是什么以及它是如何帮助我们节省带宽的。我们学习了如何在游戏中为自定义飞船精灵实现缓存。为此,我们看到了如何通过发起 HTTP 请求使用HTTPRequest节点从互联网下载文件。我们还实现了一个自定义监控器,通过缓存纹理来查看在整个游戏会话中我们节省了多少数据。最后,我们看到了缓存如何超越图像和文本文件,以及使用 REST API 通过 HTTP 下载各种文件的可能性。

有了这些,我的 Godot 引擎开发者们,我们的旅程就到此结束了!

我们是从不知道如何向另一台计算机发送简单消息的人开始的,现在我们已经成为了完全合格的网络工程师,准备创造我们梦想中的游戏,并允许玩家拥有共享的体验,享受彼此的时光,并在世界各地建立社区。祝贺您完成这段旅程。您现在能够做到任何人类能够做到的最令人惊叹的事情之一:将人们连接起来,共同追求一个目标。

这是我的告别。我为我们所经历的一切感到非常自豪,并希望您能善用这份力量。您绝对可以期待我未来会有更多的作品。但就目前而言,这就是全部了。

非常感谢您的阅读。继续开发,直到下次再见!

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