【来点技术】Godot中实现简单的单机联机通用的客户端
本文主要作为学习笔记。
以下内容主要根据Godot的相关文档和我的理解与实践编写,可能并非最佳实践,也可能存在错误,但是应该基本能用。
本文基于4.3版本。
Godot中的网络通讯基础
Godot中提供了一些较底层的实现,但是因为我用不上,所以就不讲了。
Godot提供的高级api是用的UDP,支持IPv6。
网络初始化
每个节点都有一个multiplayer
属性,它是对场景树为其配置的MultiplayerAPI
实例的引用。可以对节点单独设置MultiplayerAPI
,同时覆盖所有子节点。因此,可以实现在一个Godot示例中运行多个服务端和客户端。
可以通过如下方法创建客户端和服务端。
# 创建客户端
var peer = ENetMultiplayerPeer.new()
peer.create_client(IP_ADDRESS, PORT)
multiplayer.multiplayer_peer = peer
# 创建服务端
var peer = ENetMultiplayerPeer.new()
peer.create_server(PORT, MAX_CLIENTS)
multiplayer.multiplayer_peer = peer
通过如下方法结束联网。
multiplayer.multiplayer_peer = null
导出Android需要勾选INTERNET权限,否则不能联网。
管理连接
这里要用到的一个概念是对等体,可以直接理解为一个没有性质的物体。
系统会给每个对等体都分配一个唯一ID(UID),服务端的ID永远为1,客户端的ID则会被分配给一个随机的正整数。
可以通过MultiplayerAPI
信号来检测连接建立和断开:
· peer_connected(id: int)
此信号在每个其他对等体上与新连接的对等体ID一起发出,并在新对等点上多次发出,其中一次与每个其他对等点ID一起发出。
· peer_disconnected(id:int)
当一个对等体断开连接时,剩余的每个对等体都会发出此信号。
以下信号仅在客户端上发送:
· connected_to_server()
· connection_failed()
· server_disconnected()
通过multiplayer.get_unique_id()
获取关联的UID。
通过multiplayer.is_server()
获取对等体是服务端还是客户端。
远程过程调用(RPC)
远程过程调用是指在其他对等体上调用的函数。在函数定义前加上@rpc
则将该函数创建为RPC。
若要调用 RPC,请在每个对等体中通过 Callable 的 rpc() 方法调用之,或使用 rpc_id() 在特定对等方中调用之。
func _ready():
if multiplayer.is_server():
print_once_per_client.rpc()
@rpc
func print_once_per_client():
print("每个连接的客户端都会将我打印到控制台一次。")
要实现RPC,要求发送方和接收方的节点有相同的NodePath
,包括节点名称。同时要求@rpc
同时存在于服务端和客户端的对应函数,且函数的声明和返回类型等要相同。若要对预期使用RPC的节点调用add_child()
时,请将参数force_readable_name
设置为true
。
@rpc
可以有多个参数,在没有设置时,采用如下预设:
@rpc("authority", "call_remote", "unreliable", 0)
其参数及可选值如下:
mode
:
· "authority"
:只有多人游戏权限端(服务端)才能远程调用该函数。
· "any_peer"
:也允许客户端进行远程调用该函数,用于传输用户输入。
sync
:
· "call_remote"
:让该函数不会在本地对等体上调用。
· "call_local”
:让该函数也可以在本地对等体上调用,在服务端也是玩家时非常有用。
transfer_mode
:
· "unreliable"
:数据包不被确认,可能丢失,并且可以按任意顺序到达接收方。
· "unreliable_ordered"
:数据包按照发送的顺序接收,透过忽略迟达的数据包(如果已经收到在这些数据包之后发送的另一个数据包)来实现的。使用不当可能会导致丢包。
· "reliable"
:发送重新传送尝试,直到数据包被确认为止,且这些数据包的顺序会被保留。具有明显的性能损失。
transfer_channel
是信道索引。
前3个参数在注解中的顺序任意,但transfer_channel
参数必须始终位于注解中的最后。
在RPC所调用的函数中,可用函数multiplayer.get_remote_sender_id()
来获取RPC发送方对等体的UID。
func _on_some_input(): # 连接到某些输入。
transfer_some_input.rpc_id(1) # 只对服务端发送输入。
# 当服务端也是一个玩家时,需要设置为call_local。
@rpc("any_peer", "call_local", "reliable")
func transfer_some_input():
# 获得发送发UID。
var sender_id = multiplayer.get_remote_sender_id()
信道
可以设置多个信道以实现互不干扰的通信,尤其是在需要的可靠度不同的时候可以提高效率。
索引为0的信道实际是3个不同的传输模式的3个信道。(但我不是很理解这句)
官方示例
下面为一个示例大厅,可以处理对等体的加入和离开,通过信号来通知UI场景,并在所有客户端加载游戏场景后启动游戏。
extends Node
# Autoload named Lobby
# These signals can be connected to by a UI lobby scene or the game scene.
signal player_connected(peer_id, player_info)
signal player_disconnected(peer_id)
signal server_disconnected
const PORT = 7000
const DEFAULT_SERVER_IP = "127.0.0.1" # IPv4 localhost
const MAX_CONNECTIONS = 20
# This will contain player info for every player,
# with the keys being each player's unique IDs.
var players = {}
# This is the local player info. This should be modified locally
# before the connection is made. It will be passed to every other peer.
# For example, the value of "name" can be set to something the player
# entered in a UI scene.
var player_info = {"name": "Name"}
var players_loaded = 0
func _ready():
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
multiplayer.connected_to_server.connect(_on_connected_ok)
multiplayer.connection_failed.connect(_on_connected_fail)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func join_game(address = ""):
if address.is_empty():
address = DEFAULT_SERVER_IP
var peer = ENetMultiplayerPeer.new()
var error = peer.create_client(address, PORT)
if error:
return error
multiplayer.multiplayer_peer = peer
func create_game():
var peer = ENetMultiplayerPeer.new()
var error = peer.create_server(PORT, MAX_CONNECTIONS)
if error:
return error
multiplayer.multiplayer_peer = peer
players[1] = player_info
player_connected.emit(1, player_info)
func remove_multiplayer_peer():
multiplayer.multiplayer_peer = null
# When the server decides to start the game from a UI scene,
# do Lobby.load_game.rpc(filepath)
@rpc("call_local", "reliable")
func load_game(game_scene_path):
get_tree().change_scene_to_file(game_scene_path)
# Every peer will call this when they have loaded the game scene.
@rpc("any_peer", "call_local", "reliable")
func player_loaded():
if multiplayer.is_server():
players_loaded += 1
if players_loaded == players.size():
$/root/Game.start_game()
players_loaded = 0
# When a peer connects, send them my player info.
# This allows transfer of all desired data for each player, not only the unique ID.
func _on_player_connected(id):
_register_player.rpc_id(id, player_info)
@rpc("any_peer", "reliable")
func _register_player(new_player_info):
var new_player_id = multiplayer.get_remote_sender_id()
players[new_player_id] = new_player_info
player_connected.emit(new_player_id, new_player_info)
func _on_player_disconnected(id):
players.erase(id)
player_disconnected.emit(id)
func _on_connected_ok():
var peer_id = multiplayer.get_unique_id()
players[peer_id] = player_info
player_connected.emit(peer_id, player_info)
func _on_connected_fail():
multiplayer.multiplayer_peer = null
func _on_server_disconnected():
multiplayer.multiplayer_peer = null
players.clear()
server_disconnected.emit()
游戏场景的根节点应命名为 Game,在其所附加的脚本中:
extends Node3D # Or Node2D.
func _ready():
# Preconfigure game.
Lobby.player_loaded.rpc_id(1) # Tell the server that this peer has loaded.
# Called only on the server.
func start_game():
# All peers are ready to receive RPCs in this scene.
自动同步
MultiplayerSynchronizer
节点可以实现多端自动同步。
添加节点后,下方选择复制
->添加同步属性
,然后选择要同步的节点和属性。
例如可以将多个Sprite2D
的Transform
同步,这样就可以实现所有端上的Sprite2D
的位置同步了。
实现一个可以单机或联机的客户端
实际上是用官方示例改的。我只是加入了一个让单机与联机能用相同逻辑的脚本以及我自己的注解。
总体节点架构
主场景
Control_Main
|- LineEdit_InputName
|- Button_Solo
|- Button_HostMutiplayer
|- Button_JoinMutiplayer
实际游戏
Control_Game
|- Button_GetScore
|- Button_End
|- Label_Players
|-MultiplayerSynchronizer_MultiplayerSynchronizer
主持多人游戏
Control_HostMutiplayer
|- Label_Players
|- Button_Start
|- Button_End
加入多人游戏
Control_JoinMutiplayer
|- Label-Players
|- LineEdit_InputIP
|- Button_Start
|- Button_End
编写脚本
server.gd
(需要自动加载)
extends Node
# 自定义信号,方便别的地方获取信息
signal player_connected(peer_id, player_info)
signal player_disconnected(peer_id, player_info)
signal server_disconnected
signal player_getscore
# 默认服务端配置
var PORT = 7000
var DEFAULT_SERVER_IP = "127.0.0.1"
const MAX_CONNECTIONS = 20
# 以UID为索引
@export var players = {}
var player_info = {"name": "MasoFod", "score": 0} # 该端的玩家信息,需要在建立连接前进行更改
func _ready():
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
multiplayer.connected_to_server.connect(_on_connected_ok)
multiplayer.connection_failed.connect(_on_connected_fail)
multiplayer.server_disconnected.connect(_on_server_disconnected)
# 连接到指定IP的服务端
func join_game(address = ""):
if address.is_empty():
address = DEFAULT_SERVER_IP
var peer = ENetMultiplayerPeer.new()
var error = peer.create_client(address, PORT)
if error:
return error
multiplayer.multiplayer_peer = peer
# 创建服务端
func create_game():
var peer = ENetMultiplayerPeer.new()
var error = peer.create_server(PORT, MAX_CONNECTIONS)
if error:
return error
multiplayer.multiplayer_peer = peer
# 将默认玩家设置为服务端玩家
players[1] = player_info
player_connected.emit(1, player_info)
# 停止联网
func remove_multiplayer_peer():
multiplayer.multiplayer_peer = null
# 可执行Server.load_game.rpc(filepath)重启游戏,仅服务端可调用
@rpc("call_local", "reliable")
func load_game(game_scene_path):
get_tree().change_scene_to_file(game_scene_path)
# 客户端连接到服务端时,发送玩家信息
func _on_player_connected(id):
_register_player.rpc_id(id, player_info)
# 所有客户端注册玩家信息
@rpc("any_peer", "reliable")
func _register_player(new_player_info):
var new_player_id = multiplayer.get_remote_sender_id()
players[new_player_id] = new_player_info
player_connected.emit(new_player_id, new_player_info)
# 客户端断开连接后服务端处理
func _on_player_disconnected(id):
players.erase(id)
player_disconnected.emit(id, player_info)
# 客户端建立连接后,在本地注册自己
func _on_connected_ok():
var peer_id = multiplayer.get_unique_id()
players[peer_id] = player_info
player_connected.emit(peer_id, player_info)
# 客户端连接失败后断网
func _on_connected_fail():
multiplayer.multiplayer_peer = null
# 客户端断开连接客户端处理
func _on_server_disconnected():
multiplayer.multiplayer_peer = null
players.clear()
player_info.score = 0
server_disconnected.emit()
get_tree().change_scene_to_file("res://main.tscn")
# 当有玩家的分数变化
@rpc("any_peer", "call_local", "reliable")
func _on_player_get_score(id):
players[id].score += 1
player_getscore.emit()
# 所有端开始游戏
@rpc("authority","call_local","reliable")
func start_game() -> void:
get_tree().change_scene_to_file("res://game.tscn")
# 结束本地游戏
func end_game() -> void:
multiplayer.multiplayer_peer = null
players.clear()
player_info.score = 0
get_tree().change_scene_to_file("res://main.tscn")
main.gd
(main的脚本,需要连接信号)
extends Control
@onready var input_name: LineEdit = $InputName
func _ready() -> void:
input_name.text = str(Server.player_info.name)
func _on_solo_pressed() -> void:
Server.player_info.name = input_name.text
Server.players[1] = Server.player_info
Server.create_game()
get_tree().change_scene_to_file("res://game.tscn")
func _on_host_mutiplayer_pressed() -> void:
Server.player_info.name = input_name.text
get_tree().change_scene_to_file("res://host_mutiplayer.tscn")
func _on_join_mutiplayer_pressed() -> void:
Server.player_info.name = input_name.text
Server.players[1] = Server.player_info
get_tree().change_scene_to_file("res://join_mutiplayer.tscn")
game.gd
(game的脚本,需要连接信号)
extends Control
@onready var players: Label = $Players
func _ready() -> void:
Server.player_getscore.connect(refresh_text)
Server.player_disconnected.connect(refresh_text)
for p in Server.players:
players.text += Server.players[p].name + ' ' + ("%d"%Server.players[p].score) + '分\n'
func _on_end_pressed() -> void:
Server.end_game()
func _on_get_score_pressed() -> void:
# 对当前玩家+1分
Server._on_player_get_score.rpc(Server.multiplayer.get_unique_id())
# 刷新积分榜
func refresh_text(_id = 0, _player_info = "") -> void:
players.text = ""
for p in Server.players:
players.text += Server.players[p].name + ' ' + ("%d"%Server.players[p].score) + '分\n'
host_mutiplayer.gd
(HostMutiplayer的脚本,需要连接信号)
extends Control
@onready var players: Label = $Players
func _ready() -> void:
Server.player_connected.connect(update_players)
Server.player_disconnected.connect(update_players)
Server.create_game()
func update_players(_id:int, _player_info) -> void:
players.text = "玩家有:\n"
for p in Server.players:
players.text += Server.players[p].name + '\n'
func _on_start_pressed() -> void:
Server.start_game.rpc()
func _on_end_pressed() -> void:
Server.end_game()
join_mutiplayer.gd
(JoinMutiplayer的脚本,需要连接信号)
extends Control
@onready var start: Button = $Start
@onready var players: Label = $Players
@onready var input_ip: LineEdit = $InputIP
func _ready() -> void:
Server.player_connected.connect(update_players)
Server.player_disconnected.connect(update_players)
func update_players(id:int, player_info) -> void:
players.text = "玩家有:\n"
for p in Server.players:
players.text += Server.players[p].name + '\n'
func _on_start_pressed() -> void:
var ip = str(input_ip.text)
var error = Server.join_game(ip)
if error:
players.text = "找不到服务器"
else:
start.queue_free()
func _on_end_pressed() -> void:
Server.remove_multiplayer_peer()
get_tree().change_scene_to_file("res://main.tscn")
小提示
左上调试
->自定义运行实例...
可以设置运行的时候打开几个实例,方便测试网络。
总结
这篇所讲到的内容基本上用处不大,只适合做点小游戏联机。如果想做大量联机,还是得做专用服务器。
如果看完了这篇文章还有不懂的,推荐看看这个视频,虽然语速很快,但是全是干货,如果无法理解,可以再看看文档,两个结合过后就很好懂了。
只不过看起来我还是用得很不好,执着于造轮子思维,总想着自己实现,没想到有现成的东西可以用。
因此我的代码中肯定有很多不“优美”的地方,还望大家谅解。