这里讨论的是“真正的”多人游戏——它至少不是两个玩家用键盘的不同部分坐在同一个屏幕前玩同一个游戏。当然,我们先不想那么多。毕竟根据我们的游戏经验可以知道的是,很多时候参与到同一个多人游戏中的玩家使用的客户端一般都是一样的。所以先从做一个简单的游戏开始。
如何设计一个多人游戏显然不是这篇文章的重点,所以我们用一个足够简单的游戏来学习如何实现多人游戏功能。Pong的玩法可以说是足够简单了,有了前面的铺垫做起来也是真的非常简单。所以我就不逐字介绍如何实现了,只介绍要点。
按照原版Pong的设计,上下两面墙是实的,球碰到会反弹,且玩家不能穿过。因此直接用Area2D表示即可。CollisionShape选个segment或者box都行。
左右两面“墙”是虚的,球可以穿过,然后计算谁得分了。所以这两面墙用Area2D表示。不过因为要处理碰撞和更新得分的逻辑,所以左右的墙我还是做了一个场景。由于要区分左右,所以这里简单给它一个属性来表示墙的左右,以便于判断应该哪个玩家得分。
虽然玩家很简单,但是为了实现各种碰撞效果,我们还是要弄个CharacterBody2D比较方便。还需要一个Sprite2D用来显示代表玩家的长条。长条的纹理你可以网上找一个图片或者自己画一个,也可以直接新建一个 GradientTexture1D 。这个东西本身是用来产生渐变色纹理的,但是!我们把调整颜色的把手删掉只剩一个的话,我们就得到了一个单色纹理:
球也根本不用去模拟真实物理运动,因为它的运动方式就是碰到其它东西之后反弹。但是由于我们要手动控制其速度,因此还是直接上CharacterBody2D。
比较值得注意的还是球的脚本。我们之前在使用CharacterBody2D的时候,实现其移动除了设置velocity属性之外就是简单地调用 move_and_slide 。不过,这里的球用这个方法就不太合适。因为我们希望它碰到其他东西的时候直接反弹,而 move_and_slide 会让它沿碰撞面滑动。
这里需要搬出另一个名字相似的方法 move_and_collide 。顾名思义,这个方法是让CharacterBody2D移动并发生碰撞而不是滑动。默认情况下发生碰撞后角色会直接停下。此方法有一个必填参数motion,实际上就是一小段位移,也就是velocity * delta,相当于我们学物理时的vΔt(有没有想起被物理和微积分支配的恐惧)。
还没完。为了知道碰撞的发生,你当然可以给涉及碰撞的所有东西都套一个Area2D,然后连上信号。但是这里既然已经用到了 move_and_collide ,我们实际上就可以直接当场处理。 move_and_collide 有一个返回值,其类型为 KinematicCollision2D ,就是一个包含碰撞数据的对象。由于我们需要在 _physics_process 中不断调用 move_and_collide ,我们检查一下它的返回值就知道是否发生了碰撞然后进行处理。
发生碰撞时,我们希望球反弹——也就是修改其速度(主要是方向)。我们希望球像光线射到反射面上那样反射出去。好消息是Vector2内置了这样一个方法。但是需要注意的是此处要使用bounce而不是reflect,正如Godot文档所说,Godot的bounce相当于其他引擎常见的reflect,而reflect相当于其他引擎的bounce。总之,Godot的reflect的参数是接触面的方向,bounce的参数是碰撞面的法线方向(垂直于此面的方向)。 move_and_collide 返回的对象中正好包含了碰撞面的法线方向( get_normal ,返回值是经过归一化的),因此只需要将velocity设置为 velocity.bounce(collision.get_normal()) 即可实现反弹效果:
func _physics_process(delta: float) -> void:
var collision = move_and_collide(velocity * delta)
if collision:
velocity = velocity.bounce(collision.get_normal())
UI虽然不是重点,但是至少应该有创建游戏和加入游戏的按钮,然后一个显示分数的Label。具体的实现代码是后面要介绍的内容。
一个注册为全局变量(autoload)的场景和脚本。简单地用它来管理游戏状态。我们至少要有一个数组用来保存双方的分数。
# game_manager.gd
var scores = [0, 0]:
set(value):
scores = value
# 切实收到同步过来的分数之后再发射信号
scores_updated.emit()
get:
return scores
# 分数已变化的信号,只在本地触发方便UI更新
signal scores_updated()
网络(networking)是一个非常复杂的课题,你永远可以用更低级(low-level)的通信协议进行网络通信。但是既然都在用游戏引擎了,如果没有那么复杂的需求,我们自然可以选择引擎提供的更高级(high-level)更简单的API来实现基本的多人游戏功能。
Godot中的所有节点(所有派生自 Node 的节点类型)都有一个类型为 MultiplayerAPI 的 multiplayer 属性,通过这个属性可以访问Godot的高级多人游戏API。默认情况下,所有节点的 multiplayer 属性都和场景树的 get_multiplayer 返回的对象是同一个。所以只要你没有进行额外的操作,那么在同一个游戏里任何时候访问 multiplayer 属性都是得到的同一个 MultiplayerAPI 实例。
玩网络游戏时我们经常会骂糟糕的服务器,又或者是骂糟糕的主机(host)。无论是网络游戏还是其他网络通信,在某些层次上我们会把参与通信的设备分为服务器和客户端。顾名思义,服务器会给客户端们提供一些服务,其中最重要的就是管理多人游戏的进行。而客户端需要连上服务器来获得这些服务并参与游戏。我们先编写两个方法用以创建服务器和客户端。
我们希望在点击Host按钮之后就可以成为一个服务器等待其他玩家的加入。
Godot使用ENet库来处理网络通信(基于UDP的、可以可靠的)。要成为一个服务器,需要先用new创建一个 ENetMultiplayerPeer 实例。Peer是P2P网络中的一个节点的名称。P2P是所谓的对等网络,也就是说网络中的每个节点都可以是服务器也可以是客户端。
创建好peer之后,调用其 create_server 方法就可以将peer配置为服务器。此方法有一个必填参数port,也就是端口号。你可能大概知道IP地址就对应着一个设备在网络中的地址,当你看到 192.168.1.11:21 这样的URL的时候,冒号前面的是IP而后面的就是端口号。端口号存在的目的就是让网络数据可以传递给具体的应用。毕竟同一个设备上可能会有很多个应用可以处理网络请求,我们需要一种机制来进行区分。
func _on_host_pressed() -> void:
var peer = ENetMultiplayerPeer.new()
peer.create_server(8899, 2)
multiplayer.multiplayer_peer = peer
端口号的取值范围为0至65535。但是熟知端口(well-know ports,比如80)和一些应用的默认端口(比如数据库默认的1052、3306)最好是不要去用。当然你在1024和49151之间选一个就好了,只要你不是运气太差。
完成服务器创建后,需要把peer交给multiplayer属性的 multiplayer_peer 。
要成为客户端,就需要和服务器连接。流程和创建服务器是类似的,只不过这次要调用peer的 create_client 方法。此方法有两个必填参数,就是服务器的IP和端口。在调用 create_server 时不要求填IP,因为这个服务器就是建立在本机上的。但是客户端则很有可能不是运行在本机上的,因此必须要指定服务器的IP和端口号。如果你的客户端和服务器都是运行在本机上,那么这个IP应该填入 127.0.0.1 。这个特殊地址就代表本机, localhost 在很多情况下也是和这串数字等价。最终你的服务器和客户端可能运行在局域网中的不同设备上,那么你至少需要知道服务器的IP。
端口号就是在调用 create_server 时传入的端口号。当然你可能需要做一个可以自己填写IP和端口号的UI,毕竟你的设备的IP很有可能会发生变化。
func _on_join_pressed() -> void:
var peer = ENetMultiplayerPeer.new()
peer.create_client("127.0.0.1", 8899)
multiplayer.multiplayer_peer = peer
最终你肯定希望你的朋友能够和你一起玩你的游戏,但是在开发过程中我们肯定还是会需要在本机同时调试客户端和服务器。
Godot自然也可以同时启动多个实例来调试。在Debug菜单的最下面的Customize Run Instances(自定义运行实例)选项便是用来配置这个操作。勾选Enable Multiple Instances(启用多个实例)之后,调整下方的数字。现在启动调试的时候就可以看到弹出了多个游戏窗口。这样就方便我们调试服务器和客户端了。
现在我们做好了准备,可以创建服务器和作为客户端连接到服务器。但是目前就算客户端已经和服务器连上了也不会有任何事情发生。我们首先要关注 MultiplayerAPI 提供的几个信号。
此信号表示有节点连接到了服务器,其参数为节点的id。但是!要注意的是,此信号会在网络中的 所有节点 上触发。也就是说每当一个客户端连上服务器时,所有客户端 和 服务器上的实例都会收到这个信号。另外在客户端连上服务器时这个信号也会在 客户端上 触发。客户端连上服务器时,响应此信号的方法的实参值为1。
顾名思义是连上服务器时在 客户端 上触发的信号。此信号无参数。
当然我们有必要区分客户端和服务器,因为有些事情只能让服务器来处理。一方面是因为服务器涉及一些“特权”,比如开始游戏、踢人之类的操作只能服务器来进行。另一方面,也是为了防止客户端进行一些非法操作,将一些异常数据交给服务器,有些操作只会由服务器执行,并在必要时验证传来的数据。
MultiplayerAPI 的 is_server 方法可以判断当前的 MultiplayerAPI 实例是否是一个服务器。由此我们就可以进行区别对待。
在我们这个Pong中,实际上除了服务器只需要一个客户端即可开始,所以本质上一旦服务器收到 peer_connected 信号就可以准备开始游戏。如果你的游戏涉及更多的玩家需要检查玩家数量,可以调用 multiplayer 的 get_peers 方法。此方法会返回所有 与此设备相连的节点 (的ID)。也就是说这个数组中是不包含自己的。
首先我们要向场景中添加玩家的实例。我这里配置了一个 GameManager 脚本作为AutoLoad来管理游戏,你也可以采取自己喜欢的方法。收到 peer_connected 信号之后,服务器就会适时调用 start 方法。内容很简单,就是生成玩家和球:
func spawn():
var viewport_size = get_viewport().get_visible_rect().size
var server_player = player_scene.instantiate()
# 根据视口大小设置位置
server_player.position = Vector2(viewport_size.x * 0.2, viewport_size.y * 0.5)
get_node("/root/Main").add_child(server_player, true) # 为什么要填个true下面解释
var client_player = player_scene.instantiate()
client_player.position = Vector2(viewport_size.x * 0.8, viewport_size.y * 0.5)
get_node("/root/Main").add_child(client_player, true)
# 生成球的部分略去
我这里默认左侧的是服务器玩家控制的角色,右侧的是客户端玩家控制的角色。
如果此时启动调试,你会发现客户端连接之后,只有服务器上生成了玩家。这是自然,因为所有代码都只会在本机上执行。
RPC(Remote Procedure Call,远程过程调用)在多人游戏框架中很常见。顾名思义就是调用另外某个地方的某个函数。我们直接来看怎么用:
@rpc("authority", "call_local", "reliable")
func spawn():
# ...
这里在前面生成玩家的方法上加上了一个 @rpc 标记并指定了几个参数。接下来你需要把调用spawn的地方改成这个样子:
spawn.rpc()
如果你像我一样使用了 add_child ,那么 一定要 记得设置其第二个参数为true。发起RPC调用时,Godot会根据节点的路径来确定要在哪个节点上远程调用。如果不设置 add_child 的第二个参数 force_readable_name (强制设置可读名称)为true,就不是可读性差那么简单。如果不设置此参数,Godot会自动为实例化的节点取名为类型名加对象ID。而这个ID在不同的游戏实例上是不同的,执行RPC时Godot就会在远端找不到本地节点对应的节点。
标记上了 @rpc 的函数就有了能远程调用的能力。其第一个参数要指定authority,也就是说“谁说了算”。它的取值目前只有两个,一个是 authority ,一个是 any_peer 。authority默认情况下就是服务器,也就是只能服务器调用标记为 @rpc("authority") 的函数。设置为 any_peer 的时候,任何实例都可以调用这个函数。对于一些比较重要、比较敏感的操作我们自然想只能由服务器去调用。而其他不那么重要的,但是需要所有客户端都一起执行的操作(比如一个玩家的角色发出了什么声音,可能所有的客户端都得发出声音),就可以设置为 any_peer 。
第二个参数指定要在哪里调用,取值为 call_remote 和 call_local 。remote就是只会在自己以外的客户端上调用,local则是自己也会同时调用。由于我们这里的服务器实际上也是一个客户端,也有玩家要参与游戏所以选择 call_local 。
第三个参数选择传输模式。主要的两个取值为reliable(可靠)和unreliable(不可靠)。可靠模式会尽可能尝试重发确保包被收到,且保证顺序——当然这些操作肯定是有消耗的,因此只有比较重要的操作才建议选择可靠模式。相应地,不可靠模式不会确保包被收到,亦不保证顺序。最后一种 unreliable_ordered 则是不可靠但保证顺序的模式。
@rpc 标记配置好后,如果需要远程调用就 必须 用 f.rpc() 的形式来调用而不是直接调用。如果有参数直接把参数填到 rpc 的括号里就行了。
正如上面的图片所示,虽然所有玩家已经在所有客户端上生成,但是移动时所有玩家都会响应输入。首先要解决的问题是,如何让本地客户端上只有自己的玩家节点受输入控制。这个问题简单,我们只需要给玩家加一个属性记录它的peer id就可以判断了。比如我把这个属性叫做 owning_peer_id (owner是节点的一个内置属性,避免重名):
if multiplayer.is_server():
client_player.owner_peer_id = multiplayer.get_peers()[0]
else:
client_player.owner_peer_id = multiplayer.get_unique_id()
同样的代码会在服务器和客户端上执行。服务器玩家的ID始终是1,所以无论在客户端还是服务器上都直接设置为1。不过在设置客户端玩家角色的ID的时候,在服务器和客户端上进行的时候就需要分别处理。
接下来在需要的时候就可以通过这样的代码来判断这个东西是不是应该归我们控制的了:
if owner_peer_id == multiplayer.get_unique_id():
pass
这个问题解决了,但是玩家的移动并没有反映到另一个玩家的游戏实例上。
我们要记得,说是多人游戏,实际上还是“很多不同的设备在各自运行同一个游戏的不同副本”。我们只是试图通过网络不断同步各种数据,让大家产生在同一个世界的幻觉。
因此为了实现(看似)同步的运动,我们也需要RPC。需要注意的是,我们现在就不能在RPC函数中去判断玩家的按键了。因为RPC会在其他客户端执行,一个玩家在本机按下了按键,另一个客户端是不知道的。因此,我们转而选择将玩家的输入作为参数传递给RPC调用:
func _process(delta: float) -> void:
if owner_peer_id == multiplayer.get_unique_id():
if Input.is_key_pressed(KEY_W):
client_move.rpc(KEY_W)
return
if Input.is_key_pressed(KEY_S):
client_move.rpc(KEY_S)
return
client_move.rpc(-1)
@rpc("any_peer","call_local")
func client_move(key: int):
if key == KEY_W:
velocity = Vector2(0, -speed)
return
if key == KEY_S:
velocity = Vector2(0, speed)
return
velocity = Vector2.ZERO
当实际被本地玩家操控的节点接收到输入时,我们向所有客户端发起RPC。这样所有客户端上和这个节点对应的副本都会执行对应的操作。
当然,这样做是有安全隐患的。 any_peer 意味着任意客户端都可以要求所有客户端执行此操作。假设某个客户端要求所有客户端直接宣布自己胜利那就不好了。你可以选择这样做:
func _process(delta: float) -> void:
if owner_peer_id == multiplayer.get_unique_id():
if Input.is_key_pressed(KEY_W):
server_move.rpc_id(1,KEY_W)
return
# ...
@rpc("any_peer","call_local")
func server_move(key: int):
# 进行一些验证blah blah
server_do_move.rpc(key)
@rpc("authority","call_local")
func server_do_move(key: int):
# 同前面的client_move
Callable的 rpc_id 可以指定在谁身上进行远程调用。这里在客户端身上收到输入之后,我们可以要求服务器来处理。服务器进行必要的操作之后,再广播给所有客户端。
接下来,你还需要为任何需要在所有客户端之间同步的数据做上述工作。我知道你在想这是不是疯了。
有良好习惯的朋友肯定已经看过文档了,但是文档中涉及高级多人游戏API的部分就一篇文章。虽然Godot从4.0开始就提供了开发多人游戏所需要的各种基础设施,但是很奇妙的是文档中似乎没有提到。上面内容中无论是在多个客户端上生成节点,还是实现状态同步其实都有内置节点。
我们可以利用 MultiplayerSpanwer 来自动在所有客户端上生成节点。
此节点需要配置两个参数。首先要配置Auto Spawn List。它的值是要自动生成的场景的路径。这个路径指的是它在资源文件系统中的路径。
第二个参数是Spawn Path,也就是要生成到场景树中的哪个位置。意思就是说,当配置到了Auto Spawn List中的场景实例化并作为子节点加入到了Spawn Path之下时,spawner就会自动在所有客户端中同步生成此节点。
使用这个节点,我们就可以删除 spawn 的RPC标记和相应的RPC调用。
如果你直接改造之前的代码会发现客户端虽然跟着服务器生成了玩家,但是位置不对。先别急。因为spawner只负责同步生成,不负责其他逻辑。由于我们现在没有RPC,因此设置位置的代码只在服务器上执行,所以位置就不对了。
现在要用到另一个神奇道具那就是 MultiplayerSynchronizer 。它可以在网络中复制(replicate,或者说再现)属性的变化并保持同步。synchronizer的粒度可以细一点,我们在需要同步的场景中加入它。例如,我们需要同步玩家的位置,那么我们就在玩家场景中加入这个节点。
加入synchronizer之后选中它,然后往下看,下面出现了一个Replication面板,我们将在这里配置要同步此场景中的哪些属性。对于玩家来说,我们同步一下它的位置(position)和前面的 owner_peer_id :
你可以点击加号按钮选择节点和属性,也可以直接在右边直接输入属性的路径。
Spawn属性指定是否要在节点生成时进行同步。Replicate属性指定同步时机,如果不需要总是去同步,可以选择 OnChange 仅在值发生变化时进行同步。
类似地,我们还需要对UI中的分数进行同步。得分这种关键性的数据自然应该由服务器来管理。当左右的边界发现和球发生碰撞时,如果自己是服务器就修改得分:
func _on_body_entered(body: Node2D) -> void:
if multiplayer.is_server():
if left:
GameManager.scores = [GameManager.scores[0], GameManager.scores[1] + 1]
else:
GameManager.scores = [GameManager.scores[0] + 1, GameManager.scores[1]]
这里为什么要大费周章地这样写呢?对数组 scores 进行同步时,实际上直接修改数组元素的值 也会 触发同步。我们也可以选择修改得分后通过RPC让服务器通知所有客户端发出 scores_updated 信号。但是客户端收到RPC发出信号时并 不能保证 客户端已经收到了新的 scores 值。当然也可以选择把新的分数作为参数通过RPC传递。前面 GameManager 的示例代码中,为 scores 手动编写了setter就是为了在设置新值时同时发射信号。客户端通过网络收到的值实际上总是一个新的数组所以setter会被触发,但是客户端只是单纯地设置了一个元素的值的话,setter不会被触发。因此这里简单地直接让服务器也直接更新整个数组。
聪明的你可能会问,使用 MultiplayerSynchronizer 不需要考虑客户端恶意修改值的问题吗?默认情况下,synchronizer以服务器(准确地说是authority)的数据为准,所以我们可以不考虑这一点。文档明确说了是from the multiplayer authority to the remote peers。
总之, MultiplayerSpawner 和 MultiplayerSynchronizer 可以帮我们完成很多工作,再结合RPC的使用,就可以很不错地实现一些多人游戏特性了。
如果想切实在另一台设备上调试、游玩你刚做的多人游戏,但是又不想在每台设备上安装Godot、然后复制粘贴项目文件夹,那么就需要把它导出为真正的可以分发的游戏。
首次尝试导出时应该是会提示没有导出模板。前往 官网下载页 ,下载下方的All Downloads部分最下面的Export Templates选项。下载完成后回到编辑器,打开Editor菜单中的Manage Export Templates(管理导出模板)窗口,选择你下载的模板即可。 最后在Project菜单中找到Export,弹出的窗口中需要编辑导出配置。默认情况下配置是空的,点击Add添加需要导出到的平台。参数这里保持默认即可,点击Export按钮即可导出你的游戏。
当然要注意的是你需要和朋友在同一个局域网内。如果没有做输入IP的UI,那么需要对IP部分进行相应的修改。
评论区
共 条评论热门最新