5. 2.5D网络同步
2026/4/14大约 5 分钟
2.5D 网络同步
网络多人游戏的核心挑战是:如何让不同设备上的游戏状态保持一致。本章介绍两种主流同步方案,以及针对棋牌类游戏的实践。
5.1 两种同步方案对比
| 方案 | 原理 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 状态同步 | 服务器定期广播游戏状态 | 棋牌、回合制 | 实现简单,防作弊 | 流量大,延迟高 |
| 帧同步 | 同步玩家输入,各端自行计算 | 动作、格斗 | 流量小,一致性强 | 实现复杂,需确定性 |
5.2 Godot 4 网络基础
Godot 4 内置了基于 ENet 的高性能网络层,通过 MultiplayerAPI 提供 RPC(远程过程调用)机制。
建立连接
C#
using Godot;
using System.Collections.Generic;
public partial class NetworkManager : Node
{
private const int DEFAULT_PORT = 7777;
private const int MAX_PLAYERS = 4;
[Signal] public delegate void PlayerConnectedEventHandler(long id);
[Signal] public delegate void PlayerDisconnectedEventHandler(long id);
[Signal] public delegate void GameReadyEventHandler();
private Dictionary<long, string> _players = new(); // id -> 玩家名
public override void _Ready()
{
// 监听网络事件
Multiplayer.PeerConnected += OnPeerConnected;
Multiplayer.PeerDisconnected += OnPeerDisconnected;
Multiplayer.ConnectedToServer += OnConnectedToServer;
Multiplayer.ConnectionFailed += OnConnectionFailed;
}
// 创建服务器(房主调用)
public Error CreateServer(int port = DEFAULT_PORT)
{
var peer = new ENetMultiplayerPeer();
var error = peer.CreateServer(port, MAX_PLAYERS);
if (error != Error.Ok) return error;
Multiplayer.MultiplayerPeer = peer;
// 服务器自己也是一个玩家(ID = 1)
_players[1] = "Host";
GD.Print($"[Network] Server started on port {port}");
return Error.Ok;
}
// 加入服务器(客户端调用)
public Error JoinServer(string address, int port = DEFAULT_PORT)
{
var peer = new ENetMultiplayerPeer();
var error = peer.CreateClient(address, port);
if (error != Error.Ok) return error;
Multiplayer.MultiplayerPeer = peer;
GD.Print($"[Network] Connecting to {address}:{port}");
return Error.Ok;
}
private void OnPeerConnected(long id)
{
GD.Print($"[Network] Player {id} connected");
EmitSignal(SignalName.PlayerConnected, id);
}
private void OnPeerDisconnected(long id)
{
_players.Remove(id);
GD.Print($"[Network] Player {id} disconnected");
EmitSignal(SignalName.PlayerDisconnected, id);
}
private void OnConnectedToServer()
{
GD.Print($"[Network] Connected! My ID: {Multiplayer.GetUniqueId()}");
}
private void OnConnectionFailed()
{
GD.PrintErr("[Network] Connection failed!");
}
// RPC:向所有玩家广播玩家信息
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = true)]
public void RegisterPlayer(string playerName)
{
long senderId = Multiplayer.GetRemoteSenderId();
_players[senderId] = playerName;
GD.Print($"[Network] Registered: {playerName} (ID: {senderId})");
if (Multiplayer.IsServer() && _players.Count == MAX_PLAYERS)
EmitSignal(SignalName.GameReady);
}
}GDScript
extends Node
const DEFAULT_PORT := 7777
const MAX_PLAYERS := 4
signal player_connected(id: int)
signal player_disconnected(id: int)
signal game_ready
var _players: Dictionary = {} # id -> 玩家名
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
func create_server(port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_server(port, MAX_PLAYERS)
if error != OK:
return error
multiplayer.multiplayer_peer = peer
_players[1] = "Host"
print("[Network] Server started on port %d" % port)
return OK
func join_server(address: String, port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_client(address, port)
if error != OK:
return error
multiplayer.multiplayer_peer = peer
return OK
func _on_peer_connected(id: int) -> void:
print("[Network] Player %d connected" % id)
player_connected.emit(id)
func _on_peer_disconnected(id: int) -> void:
_players.erase(id)
player_disconnected.emit(id)
func _on_connected_to_server() -> void:
print("[Network] Connected! My ID: %d" % multiplayer.get_unique_id())
func _on_connection_failed() -> void:
push_error("[Network] Connection failed!")
@rpc("any_peer", "call_local")
func register_player(player_name: String) -> void:
var sender_id := multiplayer.get_remote_sender_id()
_players[sender_id] = player_name
if multiplayer.is_server() and _players.size() == MAX_PLAYERS:
game_ready.emit()5.3 状态同步实现
状态同步适合棋牌类游戏。服务器是权威方,客户端只负责显示。
C#
using Godot;
using System.Collections.Generic;
// 游戏状态(可序列化的纯数据)
public class GameState
{
public int CurrentPlayer;
public int TurnNumber;
public Dictionary<string, int> PlayerScores = new();
public List<int> BoardState = new(); // 棋盘数据
}
public partial class StateSyncGame : Node
{
private GameState _state = new();
// 服务器:处理玩家操作,更新状态,广播给所有人
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
public void SubmitAction(int actionData)
{
if (!Multiplayer.IsServer()) return;
long playerId = Multiplayer.GetRemoteSenderId();
// 验证操作合法性
if (!IsValidAction(playerId, actionData))
{
GD.PrintErr($"[Server] Invalid action from player {playerId}");
return;
}
// 更新游戏状态
ApplyAction(playerId, actionData);
// 广播新状态给所有客户端
string stateJson = SerializeState(_state);
Rpc(nameof(ReceiveState), stateJson);
}
// 客户端:接收并应用服务器状态
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = true)]
public void ReceiveState(string stateJson)
{
_state = DeserializeState(stateJson);
UpdateUI(_state);
}
private bool IsValidAction(long playerId, int action)
{
// 检查是否轮到该玩家
return _state.CurrentPlayer == (int)playerId;
}
private void ApplyAction(long playerId, int action)
{
// 应用游戏逻辑(具体实现依游戏而定)
_state.TurnNumber++;
_state.CurrentPlayer = GetNextPlayer();
}
private int GetNextPlayer() => (_state.CurrentPlayer % 4) + 1;
private string SerializeState(GameState state)
=> Json.Stringify(new Godot.Collections.Dictionary
{
["current_player"] = state.CurrentPlayer,
["turn_number"] = state.TurnNumber,
});
private GameState DeserializeState(string json)
{
var data = Json.ParseString(json).AsGodotDictionary();
return new GameState
{
CurrentPlayer = data["current_player"].AsInt32(),
TurnNumber = data["turn_number"].AsInt32(),
};
}
private void UpdateUI(GameState state)
{
// 更新界面显示
GD.Print($"Turn {state.TurnNumber}, Player {state.CurrentPlayer}'s turn");
}
}GDScript
extends Node
var _state := {
"current_player": 1,
"turn_number": 0,
"player_scores": {},
"board_state": [],
}
@rpc("any_peer")
func submit_action(action_data: int) -> void:
if not multiplayer.is_server():
return
var player_id := multiplayer.get_remote_sender_id()
if not _is_valid_action(player_id, action_data):
push_error("[Server] Invalid action from player %d" % player_id)
return
_apply_action(player_id, action_data)
rpc("receive_state", JSON.stringify(_state))
@rpc("authority", "call_local")
func receive_state(state_json: String) -> void:
_state = JSON.parse_string(state_json)
_update_ui()
func _is_valid_action(player_id: int, _action: int) -> bool:
return _state.current_player == player_id
func _apply_action(player_id: int, _action: int) -> void:
_state.turn_number += 1
_state.current_player = (_state.current_player % 4) + 1
func _update_ui() -> void:
print("Turn %d, Player %d's turn" % [_state.turn_number, _state.current_player])5.4 插值与预测(减少延迟感)
网络延迟不可避免,但可以通过插值让移动看起来更流畅。
C#
using Godot;
// 网络角色:本地预测 + 服务器校正
public partial class NetworkPlayer : CharacterBody3D
{
private Vector3 _serverPosition;
private Vector3 _serverVelocity;
private float _interpolationSpeed = 10f;
// 接收服务器位置更新
[Rpc(MultiplayerApi.RpcMode.Authority)]
public void UpdateServerState(Vector3 position, Vector3 velocity)
{
_serverPosition = position;
_serverVelocity = velocity;
}
public override void _PhysicsProcess(double delta)
{
if (IsMultiplayerAuthority())
{
// 本地玩家:直接控制,定期同步给服务器
HandleLocalInput(delta);
if (Engine.GetPhysicsFrames() % 3 == 0) // 每3帧同步一次
Rpc(nameof(UpdateServerState), GlobalPosition, Velocity);
}
else
{
// 远程玩家:插值到服务器位置
GlobalPosition = GlobalPosition.Lerp(_serverPosition, (float)delta * _interpolationSpeed);
Velocity = Velocity.Lerp(_serverVelocity, (float)delta * _interpolationSpeed);
}
}
private void HandleLocalInput(double delta)
{
var direction = Vector3.Zero;
if (Input.IsActionPressed("move_right")) direction.X += 1;
if (Input.IsActionPressed("move_left")) direction.X -= 1;
Velocity = direction * 5f;
MoveAndSlide();
}
}GDScript
extends CharacterBody3D
var _server_position := Vector3.ZERO
var _server_velocity := Vector3.ZERO
var _interpolation_speed := 10.0
@rpc("authority")
func update_server_state(position: Vector3, velocity: Vector3) -> void:
_server_position = position
_server_velocity = velocity
func _physics_process(delta: float) -> void:
if is_multiplayer_authority():
_handle_local_input()
if Engine.get_physics_frames() % 3 == 0:
rpc("update_server_state", global_position, velocity)
else:
global_position = global_position.lerp(_server_position, delta * _interpolation_speed)
velocity = velocity.lerp(_server_velocity, delta * _interpolation_speed)
func _handle_local_input() -> void:
var direction := Vector3.ZERO
if Input.is_action_pressed("move_right"): direction.x += 1
if Input.is_action_pressed("move_left"): direction.x -= 1
velocity = direction * 5.0
move_and_slide()5.5 棋牌类网络对战完整流程
棋牌游戏的网络流程相对简单,因为操作频率低,对延迟不敏感。
玩家A出牌
↓
客户端A: submit_action(card_id) → RPC → 服务器
↓
服务器: 验证合法性 → 更新状态 → broadcast receive_state()
↓
所有客户端: 接收新状态 → 更新UI → 播放动画断线重连
棋牌游戏必须支持断线重连。实现方式:服务器保存完整游戏状态,玩家重连后服务器发送当前状态快照,客户端恢复到最新状态。
防作弊
所有游戏逻辑必须在服务器端执行和验证。客户端只能提交"意图"(如"我想出这张牌"),服务器决定是否允许。永远不要信任客户端发来的数据。
