囿里有条小咸鱼 发表于 2024-5-24 10:31:38

【Godot多人联机】简单讲一下godot4的rpc以及多人节点?

RT,论文答辩完后我就在准备这个东西了,这两天也试了试水,发现godot4的多人联机还是有不少坑的,这里现给各位社区的朋友做个简单的介绍,并用较为通俗易懂的话语解释rpc机制以及godot4中关于多人联机的一些注意事项

囿里有条小咸鱼 发表于 2024-5-24 11:03:16

一、何谓联机?
“联机”这一我们并不陌生,玩过一些游戏的都有见过这一词。其实,联机是多人游戏的大前提。字面上看,“联机”就是把两台及更多台电脑连接在一起,让他们通过网络构成一体,但彼此又保持各自的独特性。


二、联机中的两个重要概念
在涉及多人游戏的时候,我们必须要了解两个基本概念:服务端(Server)和客户端(Client),亦或者主控端(Host/Authority)和受控端(Guest)

[*]服务端:即向客户端提供加入本地游戏渠道的那一方,一般也叫做主控端,因为它是主动发起远程多人游戏的一方,同时也负责远程多人游戏的正常运行,多人游戏权限最高。
[*]客户端:即加入服务端所提供的本地游戏的一方,一般也可以叫做受控端,因为它是加入主控端游戏的客体,是远程多人游戏重要的组成部分,但多人游戏权限较低,受主控端的监督。

这么一看感觉还是很抽象对吧,那就举个最生动的例子:创建服务端就好比你向别人发出的来你家做客的邀请函,然后别人就可以顺着你给的邀请函上面提供的地址进入你家来做客。假设你是家主,那么来你家的客人就不能像在他们自己家那样为所欲为,其行为需要受到你的监督,而你也可以选择拒绝某人的邀请,亦或是把某人请出你家对吧,你具有这些权限。

上面的例子也说明:要想进行多人联机,就必须先创建一个服务端,然后其他电脑在加入前要准备好一个客户端,这样才能达成服务端与客户端之间的通信协议。


三、如何在Godot 4里创建客户端和服务端?
在Godot 4中,我们可以直接新建ENetMultiplayerPeer实例,并将其引用传递给一个节点或场景树的multiplayer属性即可:
服务端:
# Create server.
var peer = ENetMultiplayerPeer.new()
peer.create_server(端口号, 最大承受人数) # 这个人数包括服务端在内
multiplayer.multiplayer_peer = peer客户端:
# Create client.
var peer = ENetMultiplayerPeer.new()
peer.create_client(服务端设备的网络IP地址, 服务端创建时所用到的端口号) # 由服务端向客户端提供
multiplayer.multiplayer_peer = peer
需要注意:只有服务端和客户端处于同一IP地址或映射IP地址及端口号时,才算联机成功
注意,上面我还提到了一个“映射IP地址”,这是因为我们联机一般都是局域网联机,比如你家里若干台电脑的联机或者你寝室里若干台电脑联机,这种联机因为没有过局部网关,所以可以直接用本机IP进行连接,而像这种在家中或者在寝室内若干台电脑连接构成的通信网络就是所谓的“内网”,而玩过Minecraft我的世界并且尝试过在单机模式里搞局域网联机的都知道:想要把自己的远程游戏入口发给不在同一内网的伙伴,需要用到一个叫做“内网穿透”的东西,或者再直白点,就是所谓的“内网映射”,它允许非同一局域网的外网设备通过特定的穿透/映射ip访问到发起映射端的内容,外网设备只有通过这一ip才能访问到本地内网,而与此同时,发起映射的内网也被认为处在这一穿透ip内。
此外,如果在开发过程中需要在一台设备上测试联机效果的话,可以尝试godot的运行多实例功能,允许同时运行多个彼此分离的程序,此时其中一个程序在创建客户端后可以通过一个特殊ip:127.0.0.1,或者“localhost”来访问另一个程序的服务端,非常方便。不过有一个缺点:因为是在同一设备上运行的程序,所以联机效率非常高,以至于你都无法测试远程连接的一些情况,比如远程连接速率,以及丢包等等。当然这都是后话了,如果真不行,你可以把你的程序(甚至工程源文件)发给一个靠谱的伙伴帮你一起测试(记得先开内网穿透,不然对方是连不到你的网络上的)

囿里有条小咸鱼 发表于 2024-5-24 11:27:27

本帖最后由 囿里有条小咸鱼 于 2024-6-26 17:18 编辑

四、对等体
godot中涉及到了一个对等体(peer)的概念。在godot中,在多个端上具有相同名称、且在节点树中层级相同的节点就互为对等体。假设我们有一个服务端A和一个客户端B,A中有三个节点,a,b,c,其中b是a的子节点,c是b的子节点;在客户端B中也有三个节点:a,b,d,其中b,d均为a的子节点。此时我们会发现:在A和B这两个端中,只有a和b是名称相同且节点树层级相同的节点,这时我们就称a和b在A和B这两个端上互为对等体。在Godot中,只有互为对等体的节点才能进行远程通信操作。


五、机端UID
除此之外,在进行联机时,我们还需要知道每个端联机时的身份编号,就好比你邀请别人来你家做客时可能会标记一个客人的编号一样,这个编号就叫做机端UID,可以通过两种方式获取:

[*]通过节点或节点树的multiplayer.get_unique_id()方法(如果是客户端,则需要创建好客户端之后才能调用)
[*]通过一些信号连接的回调函数所提供的id参数(如multiplayer.peer_connected(id: int)这个信号)
在Godot中,服务端或未联机端的机端UID永远为1,客户端的机端UID则为一个随机值。这个UID非常重要,涉及到后面的远程调用等操作。


六、远程操作以及RPC
联机并不是说一个端上进行了某个操作,另一个端就可以立即同步该操作的,它是一个人为的过程。换言之,需要通过远程操作的手段对对方端的内容进行相应的处理,才能实现双端内容的同步,从而达到联机同步游戏的效果。这里就涉及到一个问题:如何在一个端中调用另一个端内程序的内容呢?对于函数/方法,godot提供了一套机制:rpc
RPC,全称Remote Procedure Call,即远程过程调用,在Godot中,RPC采用的是UDP通信协议(感兴趣的可以百度自行学习相关内容)。通过RPC机制,一个端就可以调用另一个端上的内容了。
对于调用远程函数,可以给一个函数套上@rpc注解,这样这个函数就变成了可被远程调用的函数,即其他端都有可能调用的函数。
@rpc
func my_func():
...
# 此时该函数就向其他端开放了需要注意:必须是在不同端中的同名函数/方法才能被远程调用。
要想远程调用其他方法,需要先引用该方法的方法名,然后加上.rpc()才能实现远程调用
# 在另一个端上:
my_func.rpc()rpc()方法也支持传入参数,传入的参数视被rpc的函数情况而定
# 声明远程方法
@rpc
func param(p1, p2):
...

# 调用远程方法
param.rpc(3, 5) # 参数视被rpc函数的情况而定需要注意的是:在Godot中,由于每个程序中的对象uid彼此独立且随机,而对象在内存中地址也会因各种原因而千变万化,并且rpc调用时只会发送本机数据,不会变通,因此,即便一个方法声明了@rpc,且明面上允许传入对象引用,实际上也无法在该方法声明中传入对象引用,包括节点引用和资源引用,否则在进行rpc时会触发“找不到对象”的报错。对于节点,如果确实需要在远程端进行引用的,可以传递该节点的节点路径,然后在远端通过get_node()进行获取即可。

@rpc还支持传入参数,下列是其参数列表:
@rpc(param1, param2, param3, channel)其中param1,param2,param3的内容分别为:
param1:

[*]authority:表示该函数只能由主控端(一般就是服务端)调用,其他端进行rpc时会直接忽略调用该端上的该函数
[*]any_peer:表示不管是服务端还是客户端,都可以调用该端上的该函数
param2:

[*]call_remote:表示在进行rpc时,并不会在该端上调用该函数
[*]call_local:表示在进行rpc时,本地端和远程端都会调用该同名函数
param3:

[*]reliable:表示在进行rpc时采用完全信任模式,这种模式会在远程游戏丢包时尝试重发,会导致游戏掉帧。一般建议在发送重要数据时选上
[*]unreliable_ordered:表示在进行rpc时采用有序不信任模式,这种模式会将包进行有序处理,然后逐一发送给远端,并丢弃发送严重延迟的包,导致游戏掉帧的频率适中,一般建议在发送次重要数据时调用
[*]unreliable:表示在进行rpc时采用不信任模式,这种模式不会处理丢包,但几乎不会导致游戏掉帧,但信息丢失严重。一般建议在发送一般数据时使用。
channel:发送频道,以便在传输数据时不发生包冲突,如果为0则表示采用param3中那三个模式所分别构成的三个频道
其中,param1~3在@rpc中的顺序可以任意,唯独channel只能放在最后。且param1~3中只能选一种参数填入,不允许出现如@rpc("authority", "any_peer")这种情况。@rpc的参数允许开发者能更好的处理@rpc的调用权限、范围和模式。

当然,有些情况下我们只希望命令特定的一个端调用rpc方法,这个时候我们就可以引用函数名然后调用rpc_id()方法来实现。rpc_id()需要传入的参数如下:
rpc_id(uid: int, ...params)其中uid就是之前提到过的机端UID,params同rpc()内的参数
这样,我们就可以对具有该uid的端进行远程调用
my_func.rpc_id(1, 3, 5) # uid为1,此时就表示在服务端上调用该函数
my_func.rpc_id(456098734, 3, 5) # 表示在uid为456098734的端上调用该函数rpc_id功能非常适合做针对某端的操作,如拒绝某端加入游戏,或者将某端踢出等操作。

囿里有条小咸鱼 发表于 2024-5-24 14:20:06

本帖最后由 囿里有条小咸鱼 于 2024-5-27 10:46 编辑

七、同步生成节点:MultiplayerSpawner
有了@rpc,我们就可以实现在某个玩家进入游戏以后在其他端同步生成相同玩家的操作了:
@rpc("any_peer", "call_local")
func spawn_player(peer_id: int) -> void:
...<操作>但实际上,Godot 4提供了一个针对多人游戏进行物件生成的节点:MultiplayerSpawner。
https://i.postimg.cc/Zq7s29Fr/image.png
其中,Spawn Path为你要生成的节点需要加在哪个节点下,Spawn Limit为最大可生成的数量,0表示无限制。下面的Auto Spawn List为需要生成的物件。如果需要生成玩家,只需要点击添加元素,然后把玩家这个场景拖到对应的空栏里即可
如果一端通过一些手段将该物件生成出来的话,那么其他端上就会同步生成一模一样的物件。
同时,MultiplayerSpawner还支持自定义生成方法,即将spawn_function赋给一个函数的引用,此时需要手动调用spawn()进行自定义生成,spawn里可以传入一个用于初始化对象的参数,但与此同时,被赋给spawn_function属性的函数也要在定义时传入一个参数以用于传入由spawn()传入的初始化参数,同时还要设置返回值类型Node。
值得注意的是,如果你初始化的对象是一个2D或者3D节点,那么请务必通过这种方法生成,否则生成后的节点只会被生成在游戏坐标原点处,即便你在spawn外定义了其出生位置也不行,这种只能在spawn_function所指向的出生函数中,通过传入的参数进行设置。此外,MultiplayerSpawner最好设置为只能在服务端调用spawn,因为在服务端生成物体后,MultiplayerSpawner也会对其他端的对等体同样调用spawn(),如果不限制只让服务端调用,则可能会因客户端重复生成对象而报错。


八、同步对等体属性节点:MultiplayerSynchornizer
除了同步出生,同步参数也非常重要,一般诸如变换、精灵动画等信息都需要进行同步。Godot 4也提供了一个非常方便的节点:MultiplayerSynchornizer:
https://i.postimg.cc/tJZchFfr/image.png
加入该节点后,编辑器下方就会出现一个“复制”选项框,这时候可以点击同步属性,选择一个节点,然后选择一个属性进行同步,就会在窗口中添加对这种节点的属性同步:
https://i.postimg.cc/sx555XjF/image.png
其中,“出生”表示是否在节点被MultiplayerSpawner生成时立即将属性进行同步,“复制”表示其复制模式,分三种:

[*]Always:每帧都会进行同步
[*]Never:不自动同步,需要手动进行同步
[*]On Change:只有在属性发生变化时才会进行同步
对于玩家这个物件,我们一般只需要同步其变换和精灵属性即可保证多人游戏的可玩性了。

与此同时,右侧检查器也会变成如下界面:
https://i.postimg.cc/jd57sZN0/image.png
其中我们只需要关注Root Path就行了,一般表示从哪个节点开始追踪你要同步的属性,因为属性同步是通过节点路径来确定的,所以更改了Root Path也就更改了属性同步的路径,但“复制”中的属性是不会进行更改的,因此一旦更改了Root Path,可能会导致对等体属性同步失败。


九、授予客户端玩家控制权限
但这个时候,如果你去尝试在客户端上操作玩家,你会发现客户端的玩家实例根本动不了一点,而服务端却可以操作所有玩家,这是因为我们还没有把玩家的控制权限交给客户端处理。
解决方法很简单:在玩家加入节点树时把控制权限从服务端移交给客户端即可:
# 玩家的_enter_tree()
func _enter_tree():
    set_multiplayer_authority(<本机机端uid,一般存在节点名称里>)移交控制权限后,你会发现服务端还是会控制所有玩家移动,这个时候我们需要在玩家移动前检查当前端是否为受权控制端,如果不是,则直接退出执行移动代码:
if not is_multiplayer_authority():
    return

十、一些踩坑记录
如果通过这两个节点去制作多人游戏的生成与属性同步,那么这里有些坑你需要注意:

[*]如果你自定义了MultiplayerSpawner的spawn_function,那么在其他玩家加入加入时不会自动生成对应的物件,此时可以通过以下方法来实现:
func _ready():
    multiplayer.peer_connected.connect(<你自定义生成函数的名字>)但这样还不够,因为客户端并不会在加入游戏那一瞬间接收到这一方法,此时客户端的场景中只有一个本机玩家实例,如果需要把包括主控端玩家在内的其他玩家都加进来,你还需要在_ready()以后生成一个peer_id为1的主控端玩家节点和其他端已经存在的节点:
# 在_ready()内
...
_spawn(1)
for i in multiplayer.get_peers(): # 把其他玩家也生成一遍
    _spawn(i)

func _spawn(peer_id: int) -> void:
    spawn({
         peer_id = peer_id
    })

func _custom_spawn(data: Dictionary) -> Node:
    var p = player_scene.instantiate()
    p.name = data.peer_id # 建议把玩家名称改为peer_id,方便设置主控权限这样就能保证不会有玩家缺失了。
[*]如果你要支持在游戏途中让其他人加入游戏,那么就需要储存一下当前场景的相对文件路径,然后rpc调用get_tree().change_scene_to_file()来将该路径传递给对方端以让该玩家传送进当前场景,但在创建客户端并连接上服务端的一瞬间,其实就已经出发了peer_connected()信号,会导致其他端会先生成一个玩家,然后处理该玩家的加入请求,如果被拒绝就直接让该玩家所在的端终止停止连接。停止连接的方法如下:
multiplayer.multiplayer_peer = null这时其他端会收到peer_disconnected()信号,将其连接在一个销毁对应玩家实例的方法上即可。
但如果此时服务端接受了玩家的加入,那么就会出现一个很神奇的事情:服务端和其他端都有这个玩家实例,唯独加入游戏的那个端没有任何玩家实例生成。目前暂时没有比较好的方法去解决这个问题,因此建议在做多人联机的时候,可以先做一个大厅,然后等待玩家加入,由服务端决定是否开启游戏。在开启游戏后,如果还有玩家加入,则直接拒接其加入并断开其连接即可。
[*]如果在multiplayer.peer.disconnected()信号发出时把退出的玩家实例销毁掉,那么就会导致报错,目前暂无比较好的解决方法

页: [1]
查看完整版本: 【Godot多人联机】简单讲一下godot4的rpc以及多人节点?