Godot 4 GDScript 教程 / 21 - 多人网络
21 - 多人网络
Godot 4 对网络系统进行了全面重写,采用基于 MultiplayerAPI 的新架构,支持 ENet、WebSocket、WebRTC 等多种传输层。本文将从零开始构建一个可运行的多人联机框架。
核心概念
| 概念 | 说明 |
|---|---|
| Peer | 网络连接端(客户端或服务端) |
| MultiplayerAPI | 高层网络抽象,管理 RPC 与状态同步 |
| ENetMultiplayer | 基于 UDP 的可靠传输层,适合局域网/低延迟场景 |
| Authority | 节点的权威方(默认是服务端) |
| RPC | 远程过程调用,在不同端执行函数 |
| MultiplayerSpawner | 自动同步场景实例化 |
| MultiplayerSynchronizer | 自动同步节点属性 |
💡 Godot 4 的网络架构采用 权威服务器 模式:服务端拥有游戏状态的最终决定权,客户端发送输入请求。
ENetMultiplayer 基础
创建服务器
# server.gd
extends Node
const PORT = 9999
const MAX_CLIENTS = 8
var peer: ENetMultiplayerPeer
func _ready():
start_server()
func start_server():
peer = ENetMultiplayerPeer.new()
var error = peer.create_server(PORT, MAX_CLIENTS)
if error != OK:
push_error("无法创建服务器: " + error_string(error))
return
multiplayer.multiplayer_peer = peer
# 监听连接事件
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
print("服务器已启动,端口: ", PORT)
func _on_peer_connected(id: int):
print("玩家已连接: ", id)
# 这里可以发送初始游戏状态给新玩家
func _on_peer_disconnected(id: int):
print("玩家已断开: ", id)
# 清理该玩家的游戏对象
客户端连接
# client.gd
extends Node
const SERVER_IP = "127.0.0.1"
const PORT = 9999
var peer: ENetMultiplayerPeer
func _ready():
connect_to_server()
func connect_to_server():
peer = ENetMultiplayerPeer.new()
var error = peer.create_client(SERVER_IP, PORT)
if error != OK:
push_error("无法连接服务器: " + error_string(error))
return
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func _on_connected():
print("已连接到服务器,我的 ID: ", multiplayer.get_unique_id())
func _on_connection_failed():
print("连接失败")
func _on_server_disconnected():
print("与服务器断开连接")
⚠️ 注意:create_server() 和 create_client() 的返回值必须检查,常见错误是端口被占用。
RPC 新语法(@rpc 注解)
Godot 4 使用 @rpc 注解替代了旧版的 rpc() / rpc_id() 调用方式。
@rpc 注解参数
| 参数 | 可选值 | 说明 |
|---|---|---|
authority | any, server | 谁可以调用此 RPC |
mode | call_remote, call_local | 是否本地也执行 |
sync | sync, no_sync | 是否同步到新加入的玩家 |
transfer_mode | reliable, unreliable, unreliable_ordered | 传输可靠性 |
基本用法
# player.gd
extends CharacterBody3D
@export var speed: float = 5.0
var health: int = 100
# 任何端都可以调用,远程执行,可靠传输
@rpc("any_peer", "call_remote", "reliable")
func take_damage(amount: int):
health -= amount
print("玩家 ", name, " 受到 ", amount, " 伤害,剩余生命: ", health)
if health <= 0:
die()
# 只有服务端可以调用,所有端执行(包括调用者)
@rpc("authority", "call_local", "sync")
func sync_position(pos: Vector3):
global_position = pos
# 客户端发送输入到服务端
@rpc("any_peer", "call_remote", "reliable")
func request_move(direction: Vector3):
# 只在服务端执行
if not is_multiplayer_authority():
return
# 服务端验证并执行移动
velocity = direction * speed
move_and_slide()
# 广播新位置给所有客户端
sync_position.rpc(global_position)
发送 RPC
# 从客户端调用服务端函数
func _physics_process(_delta):
if not is_multiplayer_authority():
var direction = Input.get_vector("left", "right", "forward", "back")
if direction != Vector3.ZERO:
request_move.rpc_id(1, direction) # 发送给 ID=1(服务端)
💡
rpc_id(1, ...)表示只发送给服务端(权威端 ID 始终为 1)。rpc(...)则广播给所有端。
MultiplayerSpawner —— 场景同步
MultiplayerSpawner 自动处理多人场景中的节点实例化同步。
# main_scene.gd
extends Node3D
@onready var spawner: MultiplayerSpawner = $MultiplayerSpawner
@onready var spawn_point: Node3D = $SpawnPoint
var player_scene = preload("res://player.tscn")
func _ready():
# 配置 Spawner 的生成路径
spawner.spawn_path = self.get_path()
# 添加可生成的场景
spawner.add_spawnable_scene("res://player.tscn")
if multiplayer.is_server():
multiplayer.peer_connected.connect(_add_player)
multiplayer.peer_disconnected.connect(_remove_player)
# 为已连接的玩家生成角色
for id in multiplayer.get_peers():
_add_player(id)
# 为自己(服务端玩家)也生成
_add_player(1)
func _add_player(id: int):
var player = player_scene.instantiate()
player.name = str(id)
player.position = spawn_point.global_position
add_child(player)
func _remove_player(id: int):
var player = get_node_or_null(str(id))
if player:
player.queue_free()
MultiplayerSynchronizer —— 属性同步
用于自动同步节点属性(位置、旋转、动画状态等)。
# 在编辑器中为 Player 场景添加 MultiplayerSynchronizer 节点
# 或通过代码配置:
# player_setup.gd
extends CharacterBody3D
@onready var synchronizer: MultiplayerSynchronizer = $MultiplayerSynchronizer
func _ready():
# 配置同步属性
var config = SceneReplicationConfig.new()
# 同步位置
config.add_property(self, "global_position")
config.property_set_sync_mode(
config.get_properties().size() - 1,
SceneReplicationConfig.SYNC_MODE_ALWAYS
)
# 同步旋转
config.add_property(self, "global_rotation")
config.property_set_sync_mode(
config.get_properties().size() - 1,
SceneReplicationConfig.SYNC_MODE_ALWAYS
)
# 同步动画状态
config.add_property(self, "animation_state")
config.property_set_sync_mode(
config.get_properties().size() - 1,
SceneReplicationConfig.SYNC_MODE_ON_CHANGE
)
synchronizer.replication_config = config
⚠️ 注意:MultiplayerSynchronizer 只同步它所在的节点及其子节点的属性。确保 Authority 设置正确(通常是服务端)。
房间管理系统
# room_manager.gd
extends Node
signal room_created(room_id: String)
signal player_joined(player_id: int, room_id: String)
signal player_left(player_id: int, room_id: String)
class Room:
var id: String
var host_id: int
var players: Array[int] = []
var max_players: int = 4
var is_started: bool = false
func is_full() -> bool:
return players.size() >= max_players
var rooms: Dictionary = {} # room_id -> Room
@rpc("any_peer", "call_remote", "reliable")
func create_room(player_id: int, max_players: int = 4) -> String:
if not multiplayer.is_server():
return ""
var room_id = _generate_room_id()
var room = Room.new()
room.id = room_id
room.host_id = player_id
room.max_players = max_players
room.players.append(player_id)
rooms[room_id] = room
room_created.emit(room_id)
print("房间已创建: ", room_id, " 主持人: ", player_id)
return room_id
@rpc("any_peer", "call_remote", "reliable")
func join_room(player_id: int, room_id: String) -> bool:
if not multiplayer.is_server():
return false
if not rooms.has(room_id):
return false
var room = rooms[room_id]
if room.is_full():
return false
room.players.append(player_id)
player_joined.emit(player_id, room_id)
return true
@rpc("any_peer", "call_remote", "reliable")
func leave_room(player_id: int, room_id: String):
if not rooms.has(room_id):
return
var room = rooms[room_id]
room.players.erase(player_id)
if room.players.is_empty():
rooms.erase(room_id)
elif room.host_id == player_id:
room.host_id = room.players[0]
player_left.emit(player_id, room_id)
func _generate_room_id() -> String:
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var id = ""
for i in 6:
id += chars[randi() % chars.length()]
return id
💡 实际项目中,房间管理通常放在专用的 Lobby 服务器上,与游戏服务器分离。
客户端预测与延迟补偿
在快节奏游戏中,客户端预测是降低延迟感的关键技术。
# predicted_player.gd
extends CharacterBody3D
var input_buffer: Array[Dictionary] = [] # {tick, input, position}
var server_state: Dictionary = {} # 服务端最新状态
var current_tick: int = 0
const BUFFER_SIZE = 60 # 保留 1 秒的历史(60 tick/s)
@rpc("any_peer", "call_remote", "unreliable_ordered")
func send_input(tick: int, input_dir: Vector2):
if not is_multiplayer_authority():
return
# 服务端处理输入并回传结果
var new_pos = _process_movement(global_position, input_dir)
sync_state.rpc(tick, new_pos)
@rpc("authority", "call_remote", "unreliable_ordered")
func sync_state(tick: int, server_pos: Vector3):
# 客户端收到服务端状态
server_state = {"tick": tick, "position": server_pos}
# 检查预测是否准确
var predicted = null
for entry in input_buffer:
if entry.tick == tick:
predicted = entry.position
break
if predicted and predicted.distance_to(server_pos) > 0.01:
# 预测偏差过大,进行修正
_reconcile(server_pos, tick)
func _physics_process(delta):
current_tick += 1
if not is_multiplayer_authority():
# 客户端预测:立即执行移动
var input_dir = Input.get_vector("left", "right", "forward", "back")
var predicted_pos = _process_movement(global_position, input_dir)
# 缓存输入和预测位置
input_buffer.append({
"tick": current_tick,
"input": input_dir,
"position": predicted_pos
})
if input_buffer.size() > BUFFER_SIZE:
input_buffer.pop_front()
# 立即应用预测结果
global_position = predicted_pos
# 发送输入给服务端
send_input.rpc_id(1, current_tick, input_dir)
else:
# 服务端直接处理
pass
func _process_movement(pos: Vector3, input_dir: Vector2) -> Vector3:
var movement = Vector3(input_dir.x, 0, input_dir.y) * 5.0 * get_physics_process_delta_time()
return pos + movement
func _reconcile(server_pos: Vector3, from_tick: int):
global_position = server_pos
# 从服务端状态之后的所有输入重新模拟
var new_buffer: Array[Dictionary] = []
for entry in input_buffer:
if entry.tick > from_tick:
var corrected = _process_movement(server_pos, entry.input)
server_pos = corrected
entry.position = corrected
new_buffer.append(entry)
input_buffer = new_buffer
global_position = server_pos
⚠️ 注意:客户端预测只对本地玩家有效。其他玩家使用插值平滑显示。
聊天系统实战
# chat_system.gd
extends Control
@onready var chat_log: RichTextLabel = %ChatLog
@onready var chat_input: LineEdit = %ChatInput
const MAX_MESSAGES = 100
@rpc("any_peer", "call_local", "reliable")
func send_message(sender_id: int, message: String):
# 过滤和验证消息
message = message.strip_edges()
if message.is_empty() or message.length() > 200:
return
# 转义 HTML 防止注入
message = message.xml_escape()
var sender_name = "玩家" + str(sender_id)
if sender_id == 1:
sender_name = "[服务器]"
var color = _get_player_color(sender_id)
chat_log.append_text("[color=%s]%s[/color]: %s\n" % [color, sender_name, message])
# 限制消息数量
while chat_log.get_line_count() > MAX_MESSAGES:
chat_log.remove_line(0)
func _on_chat_input_text_submitted(text: String):
if text.is_empty():
return
var my_id = multiplayer.get_unique_id()
send_message.rpc(my_id, text)
chat_input.clear()
func _get_player_color(id: int) -> String:
var colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD"]
return colors[id % colors.length()]
游戏开发场景
多人 FPS 联机对战框架
# fps_game_manager.gd
extends Node
enum GameState { LOBBY, PLAYING, ROUND_END }
var current_state: GameState = GameState.LOBBY
var scores: Dictionary = {} # player_id -> kills
var round_time: float = 0.0
const ROUND_DURATION = 180.0 # 3 分钟
@rpc("authority", "call_local", "sync")
func start_round():
current_state = GameState.PLAYING
round_time = 0.0
# 重置所有玩家分数
for id in multiplayer.get_peers():
scores[id] = 0
# 传送玩家到出生点
_respawn_all_players()
@rpc("authority", "call_local", "reliable")
func register_kill(killer_id: int, victim_id: int):
if scores.has(killer_id):
scores[killer_id] += 1
# 延迟后重生受害者
get_tree().create_timer(3.0).timeout.connect(
func(): _respawn_player(victim_id)
)
func _process(delta):
if current_state == GameState.PLAYING:
round_time += delta
if round_time >= ROUND_DURATION:
_end_round()
func _end_round():
current_state = GameState.ROUND_END
# 找出获胜者
var winner_id = scores.keys().reduce(
func(a, b): return a if scores[a] > scores[b] else b
)
print("回合结束!获胜者: ", winner_id)
网络安全基础
| 安全措施 | 说明 |
|---|---|
| 输入验证 | 服务端验证所有客户端输入 |
| 速率限制 | 限制 RPC 调用频率 |
| 权限检查 | 使用 @rpc 注解限制调用权限 |
| 数据校验 | 验证所有网络数据的范围和类型 |
# security.gd
extends Node
var rpc_timestamps: Dictionary = {} # peer_id -> [timestamps]
const MAX_RPC_PER_SECOND = 30
func validate_rpc(peer_id: int) -> bool:
if not rpc_timestamps.has(peer_id):
rpc_timestamps[peer_id] = []
var now = Time.get_ticks_msec()
var timestamps = rpc_timestamps[peer_id]
# 清理过期记录
timestamps = timestamps.filter(func(t): return now - t < 1000)
rpc_timestamps[peer_id] = timestamps
if timestamps.size() >= MAX_RPC_PER_SECOND:
print("玩家 ", peer_id, " RPC 频率超限,可能作弊")
return false
timestamps.append(now)
return true
func validate_position(peer_id: int, pos: Vector3, max_speed: float) -> bool:
# 检查位置是否在合理范围内
if pos.length() > 10000:
return false
# 检查移动速度是否超限(需要记录上一帧位置)
# ... 省略具体实现
return true
⚠️ 注意:永远不要信任客户端数据。所有游戏逻辑的最终决定权应该在服务端。