8. 网络对战
2026/4/13大约 7 分钟
8. 网络对战:和朋友在线下棋
8.1 网络对战的基本原理
想象两个人打电话下棋——一个人报出"我走车到这个位置",另一个人在自己棋盘上摆好,然后轮到他说。
网络对战就是这个原理:
玩家 A(红方) 服务器 玩家 B(黑方)
│ │ │
├── 走了一步棋 ──────────────→ │ │
│ ├── 转发走法 ────────────────→│
│ │ 摆好棋子
│ │ │
│ 摆好棋子 │
│ │←──────────────── 走了一步棋 ─┤
│←──────────────── 转发走法 ──┤ │
摆好棋子 │ │为什么需要服务器?
如果两个玩家直接连接,需要知道对方的 IP 地址,还要处理防火墙穿透问题。通过一个中间服务器转发,双方只需要连接服务器就行了。
8.2 Godot 的网络方案
Godot 4 提供了 ENetMultiplayerPeer,基于 ENet 库实现网络通信:
| 概念 | 说明 | 比喻 |
|---|---|---|
| 服务器 (Server) | 负责管理游戏状态 | 裁判 |
| 客户端 (Client) | 玩家连接到服务器 | 选手 |
| 服务器权威制 | 所有操作由服务器验证后才执行 | 裁判确认才算数 |
| RPC | 远程过程调用,让其他人的代码执行函数 | 传纸条告诉对方做什么 |
8.3 创建网络管理器
8.3.1 场景结构
NetworkManager (Node)
├── ENetMultiplayerPeer # 网络连接
└── 信号/方法 # 管理连接和同步8.3.2 代码实现
C#
using Godot;
using System;
/// <summary>
/// 网络管理器 —— 处理创建房间、加入房间、走法同步
/// </summary>
public partial class NetworkManager : Node
{
// 默认服务器端口
private const int DefaultPort = 8910;
// 最大连接数(2人对战,1个服务器 + 1个客户端)
private const int MaxClients = 2;
/// <summary>
/// 创建房间(作为服务器+客户端)
/// </summary>
public void CreateRoom()
{
var peer = new ENetMultiplayerPeer();
var result = peer.CreateServer(DefaultPort, MaxClients);
if (result != Error.Ok)
{
GD.PrintErr($"创建服务器失败: {result}");
return;
}
Multiplayer.MultiplayerPeer = peer;
GD.Print($"房间已创建,端口号: {DefaultPort}");
// 注册信号
Multiplayer.PeerConnected += OnPeerConnected;
Multiplayer.PeerDisconnected += OnPeerDisconnected;
}
/// <summary>
/// 加入房间(作为客户端)
/// </summary>
public void JoinRoom(string ipAddress)
{
var peer = new ENetMultiplayerPeer();
var result = peer.CreateClient(ipAddress, DefaultPort);
if (result != Error.Ok)
{
GD.PrintErr($"连接服务器失败: {result}");
return;
}
Multiplayer.MultiplayerPeer = peer;
GD.Print($"正在连接 {ipAddress}:{DefaultPort}...");
}
/// <summary>
/// 有人连接到服务器
/// </summary>
private void OnPeerConnected(long id)
{
GD.Print($"玩家 {id} 已连接");
// 通知所有客户端开始游戏
Rpc(MethodName.StartGame);
}
/// <summary>
/// 有人断开连接
/// </summary>
private void OnPeerDisconnected(long id)
{
GD.Print($"玩家 {id} 已断开");
Rpc(MethodName.PlayerDisconnected, id);
}
/// <summary>
/// 通知所有客户端开始游戏(RPC = 远程调用)
/// </summary>
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = true)]
private void StartGame()
{
GD.Print("游戏开始!");
GetTree().ChangeSceneToFile("res://scenes/Game.tscn");
}
/// <summary>
/// 通知所有客户端有玩家断开
/// </summary>
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
private void PlayerDisconnected(long id)
{
GD.Print($"玩家 {id} 断开连接,游戏暂停");
// 显示断线提示 UI
}
/// <summary>
/// 同步走法给对手
/// </summary>
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
public void SyncMove(int fromCol, int fromRow, int toCol, int toRow)
{
GD.Print($"收到走法: ({fromCol},{fromRow}) → ({toCol},{toRow})");
// 在本地执行这个走法
EmitSignal(SignalName.MoveReceived, fromCol, fromRow, toCol, toRow);
}
/// <summary>
/// 发送自己的走法给对手
/// </summary>
public void SendMove(int fromCol, int fromRow, int toCol, int toRow)
{
Rpc(MethodName.SyncMove, fromCol, fromRow, toCol, toRow);
}
}GDScript
extends Node
## 默认服务器端口
const DEFAULT_PORT := 8910
## 最大连接数
const MAX_CLIENTS := 2
## 创建房间(作为服务器+客户端)
func create_room() -> void:
var peer := ENetMultiplayerPeer.new()
var result := peer.create_server(DEFAULT_PORT, MAX_CLIENTS)
if result != OK:
push_error("创建服务器失败: %s" % result)
return
multiplayer.multiplayer_peer = peer
print("房间已创建,端口号: %d" % DEFAULT_PORT)
# 注册信号
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
## 加入房间(作为客户端)
func join_room(ip_address: String) -> void:
var peer := ENetMultiplayerPeer.new()
var result := peer.create_client(ip_address, DEFAULT_PORT)
if result != OK:
push_error("连接服务器失败: %s" % result)
return
multiplayer.multiplayer_peer = peer
print("正在连接 %s:%d..." % [ip_address, DEFAULT_PORT])
## 有人连接到服务器
func _on_peer_connected(id: int) -> void:
print("玩家 %d 已连接" % id)
# 通知所有客户端开始游戏
start_game.rpc()
## 有人断开连接
func _on_peer_disconnected(id: int) -> void:
print("玩家 %d 已断开" % id)
player_disconnected.rpc(id)
## 通知所有客户端开始游戏
@rpc(any_peer, call_local)
func start_game() -> void:
print("游戏开始!")
get_tree().change_scene_to_file("res://scenes/Game.tscn")
## 通知所有客户端有玩家断开
@rpc(any_peer)
func player_disconnected(id: int) -> void:
print("玩家 %d 断开连接,游戏暂停" % id)
## 同步走法给对手
@rpc(any_peer)
func sync_move(from_col: int, from_row: int, to_col: int, to_row: int) -> void:
print("收到走法: (%d,%d) → (%d,%d)" % [from_col, from_row, to_col, to_row])
move_received.emit(from_col, from_row, to_col, to_row)
## 发送自己的走法给对手
func send_move(from_col: int, from_row: int, to_col: int, to_row: int) -> void:
sync_move.rpc(from_col, from_row, to_col, to_row)8.4 手牌隐私:不让对手看到你的思考
在网络对战中,每个玩家只能看到自己的手牌(虽然象棋是公开的,但这个模式对其他棋牌游戏很重要)。
8.4.1 服务器权威制
客户端 A 发送走法 → 服务器验证合法性 → 服务器广播给客户端 B服务器验证的好处:
- 防止作弊(客户端不能随便改棋盘状态)
- 防止非法走法(比如马蹩腿的棋也走出去了)
C#
/// <summary>
/// 服务器端验证走法是否合法
/// </summary>
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
private void RequestMove(int fromCol, int fromRow, int toCol, int toRow)
{
// 只有服务器才执行验证
if (!Multiplayer.IsServer()) return;
var senderId = Multiplayer.GetRemoteSenderId();
// 验证:是否轮到这个玩家走棋
if (currentTurnPlayer != senderId)
{
GD.PrintErr($"玩家 {senderId} 不在回合中");
return;
}
// 验证:走法是否合法
if (!IsLegalMove(fromCol, fromRow, toCol, toRow))
{
GD.PrintErr("非法走法");
return;
}
// 验证通过,广播给所有客户端
Rpc(MethodName.ExecuteMoveOnAllClients, fromCol, fromRow, toCol, toRow);
// 切换回合
SwitchTurn();
}
/// <summary>
/// 所有客户端执行这个走法(包括发起者)
/// </summary>
[Rpc(MultiplayerApi.RpcMode.Authority)]
private void ExecuteMoveOnAllClients(int fromCol, int fromRow, int toCol, int toRow)
{
// 在本地棋盘上执行走法
ExecuteMove(fromCol, fromRow, toCol, toRow);
}GDScript
## 服务器端验证走法是否合法
@rpc(any_peer)
func request_move(from_col: int, from_row: int, to_col: int, to_row: int) -> void:
# 只有服务器才执行验证
if not multiplayer.is_server():
return
var sender_id := multiplayer.get_remote_sender_id()
# 验证:是否轮到这个玩家走棋
if current_turn_player != sender_id:
push_error("玩家 %d 不在回合中" % sender_id)
return
# 验证:走法是否合法
if not _is_legal_move(from_col, from_row, to_col, to_row):
push_error("非法走法")
return
# 验证通过,广播给所有客户端
execute_move_on_all_clients.rpc(from_col, from_row, to_col, to_row)
# 切换回合
_switch_turn()
## 所有客户端执行这个走法
@rpc(authority)
func execute_move_on_all_clients(from_col: int, from_row: int, to_col: int, to_row: int) -> void:
# 在本地棋盘上执行走法
execute_move(from_col, from_row, to_col, to_row)8.5 断线重连
网络不稳定时,玩家可能突然断线。断线重连让玩家可以回来继续下。
断线重连流程:
1. 检测到断线
2. 自动尝试重新连接(最多重试3次)
3. 连接成功后,服务器发送当前棋盘状态
4. 客户端恢复棋盘,继续游戏C#
/// <summary>
/// 断线重连管理器
/// </summary>
public partial class ReconnectManager : Node
{
private string _serverIp;
private int _maxRetries = 3;
private int _retryCount = 0;
private Timer _retryTimer;
public override void _Ready()
{
_retryTimer = new Timer();
_retryTimer.WaitTime = 3.0; // 3秒后重试
_retryTimer.Timeout += OnRetryTimeout;
AddChild(_retryTimer);
Multiplayer.ServerDisconnected += OnServerDisconnected;
Multiplayer.PeerDisconnected += OnPeerDisconnected;
}
private void OnServerDisconnected()
{
GD.Print("与服务器断开连接,尝试重连...");
_retryTimer.Start();
}
private void OnPeerDisconnected(long id)
{
if (Multiplayer.IsServer())
{
GD.Print($"玩家 {id} 断开,等待重连...");
// 服务器端等待玩家重连(保留30秒)
}
}
private void OnRetryTimeout()
{
if (_retryCount >= _maxRetries)
{
GD.Print("重连失败,返回主菜单");
GetTree().ChangeSceneToFile("res://scenes/MainMenu.tscn");
return;
}
_retryCount++;
GD.Print($"第 {_retryCount} 次尝试重连...");
var peer = new ENetMultiplayerPeer();
var result = peer.CreateClient(_serverIp, 8910);
if (result == Error.Ok)
{
Multiplayer.MultiplayerPeer = peer;
GD.Print("重连成功!");
_retryTimer.Stop();
_retryCount = 0;
}
}
}GDScript
extends Node
var _server_ip: String
var _max_retries: int = 3
var _retry_count: int = 0
var _retry_timer: Timer
func _ready() -> void:
_retry_timer = Timer.new()
_retry_timer.wait_time = 3.0
_retry_timer.timeout.connect(_on_retry_timeout)
add_child(_retry_timer)
multiplayer.server_disconnected.connect(_on_server_disconnected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
func _on_server_disconnected() -> void:
print("与服务器断开连接,尝试重连...")
_retry_timer.start()
func _on_peer_disconnected(id: int) -> void:
if multiplayer.is_server():
print("玩家 %d 断开,等待重连..." % id)
func _on_retry_timeout() -> void:
if _retry_count >= _max_retries:
print("重连失败,返回主菜单")
get_tree().change_scene_to_file("res://scenes/MainMenu.tscn")
return
_retry_count += 1
print("第 %d 次尝试重连..." % _retry_count)
var peer := ENetMultiplayerPeer.new()
var result := peer.create_client(_server_ip, 8910)
if result == OK:
multiplayer.multiplayer_peer = peer
print("重连成功!")
_retry_timer.stop()
_retry_count = 08.6 房间管理与匹配
8.6.1 简单的房间列表
| 功能 | 说明 |
|---|---|
| 创建房间 | 作为服务器创建,生成房间号 |
| 加入房间 | 输入 IP 地址或房间号连接 |
| 房间列表 | 显示可用的房间(需要中央服务器) |
本地网络 vs 公网
- 本地网络(局域网):同一 WiFi 下直接连接,不需要公网服务器
- 公网对战:需要一台公网服务器做中转,或者使用 NAT 穿透技术
8.7 小结
| 概念 | 说明 |
|---|---|
| ENetMultiplayerPeer | Godot 内置的网络通信方案 |
| 服务器权威制 | 所有操作由服务器验证,防止作弊 |
| RPC | 远程调用,让其他玩家的代码执行函数 |
| 断线重连 | 自动重试连接,恢复游戏状态 |
下一步
网络对战是象棋游戏的"终极形态"。实现完网络功能后,你的象棋游戏就已经是一个完整的作品了。接下来我们来做最后的 UI 美化。
→ 9. UI 美化
