7. 网络对战
2026/4/13大约 11 分钟
网络对战
本地AI虽然能玩,但和朋友一起联机打牌才是最有趣的。本章我们将使用 Godot 4 的网络系统实现多人在线对战。
生活化比喻
网络对战就像"远程打牌"——你和朋友们各自坐在家里,通过网络连接到同一个"虚拟牌桌"。每个人只能看到自己的手牌,打出的牌通过网络实时同步给所有人。
网络架构选择
两种网络模式
| 模式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 服务器权威制 | 服务器做裁判,客户端只负责显示 | 防作弊、状态一致 | 需要服务器 |
| P2P制 | 玩家之间直接通信 | 不需要服务器 | 难防作弊、状态可能不同步 |
推荐使用服务器权威制
棋牌游戏最重要的就是"公平"——不能让某个玩家看到别人的手牌。服务器权威制下,每个玩家的手牌只有服务器知道,客户端只收到"你应该显示什么牌"的指令。这就像真实的牌局中有一个公正的裁判。
Godot 4 网络系统
Godot 4 使用 ENet 作为网络底层,提供了 ENetMultiplayerPeer 类来管理网络连接:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 客户端A │←──→│ 服务器 │←──→│ 客户端B │←──→│ 客户端C │
│ (玩家1) │ │ (裁判) │ │ (玩家2) │ │ (玩家3) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘创建网络管理器
基础网络设置
C
using Godot;
/// <summary>
/// 网络管理器——处理所有网络通信
/// </summary>
public partial class NetworkManager : Node
{
/// <summary>
/// 默认端口号
/// </summary>
private const int DefaultPort = 8910;
/// <summary>
/// 最大连接数(1个服务器 + 3个客户端 = 4人)
/// </summary>
private const int MaxClients = 3;
/// <summary>
/// 网络是否已初始化
/// </summary>
public bool IsInitialized { get; private set; }
/// <summary>
/// 当前是否为服务器
/// </summary>
public bool IsServer { get; private set; }
/// <summary>
/// 当前是否为客户端
/// </summary>
public bool IsClient { get; private set; }
/// <summary>
/// 当前的唯一ID
/// </summary>
public int PeerId { get; private set; }
/// <summary>
/// 已连接的玩家列表
/// </summary>
public Godot.Collections.Array<int> ConnectedPlayers { get; private set; }
= new();
public override void _Ready()
{
// 监听连接/断开事件
Multiplayer.PeerConnected += OnPeerConnected;
Multiplayer.PeerDisconnected += OnPeerDisconnected;
Multiplayer.ConnectedToServer += OnConnectedToServer;
Multiplayer.ConnectionFailed += OnConnectionFailed;
Multiplayer.ServerDisconnected += OnServerDisconnected;
}
/// <summary>
/// 创建服务器(房主模式)
/// </summary>
public Error CreateServer(int port = DefaultPort)
{
var peer = new ENetMultiplayerPeer();
var result = peer.CreateServer(port, MaxClients);
if (result == Error.Ok)
{
Multiplayer.MultiplayerPeer = peer;
IsInitialized = true;
IsServer = true;
PeerId = Multiplayer.GetUniqueId();
GD.Print($"服务器创建成功!端口: {port}");
ConnectedPlayers.Add(1); // 服务器自己
}
else
{
GD.PrintErr($"服务器创建失败: {result}");
}
return result;
}
/// <summary>
/// 连接到服务器
/// </summary>
public Error ConnectToServer(string ipAddress, int port = DefaultPort)
{
var peer = new ENetMultiplayerPeer();
var result = peer.CreateClient(ipAddress, port);
if (result == Error.Ok)
{
Multiplayer.MultiplayerPeer = peer;
IsInitialized = true;
IsClient = true;
GD.Print($"正在连接服务器: {ipAddress}:{port}...");
}
else
{
GD.PrintErr($"连接失败: {result}");
}
return result;
}
/// <summary>
/// 断开连接
/// </summary>
public void Disconnect()
{
if (Multiplayer.MultiplayerPeer != null)
{
Multiplayer.MultiplayerPeer.Close();
Multiplayer.MultiplayerPeer = null;
}
IsInitialized = false;
IsServer = false;
IsClient = false;
ConnectedPlayers.Clear();
}
/// <summary>
/// 有新玩家连接
/// </summary>
private void OnPeerConnected(long id)
{
int peerId = (int)id;
GD.Print($"玩家 {peerId} 已连接");
ConnectedPlayers.Add(peerId);
}
/// <summary>
/// 有玩家断开
/// </summary>
private void OnPeerDisconnected(long id)
{
int peerId = (int)id;
GD.Print($"玩家 {peerId} 已断开");
ConnectedPlayers.Remove(peerId);
}
/// <summary>
/// 成功连接到服务器
/// </summary>
private void OnConnectedToServer()
{
PeerId = Multiplayer.GetUniqueId();
GD.Print($"已连接到服务器,我的ID: {PeerId}");
}
/// <summary>
/// 连接失败
/// </summary>
private void OnConnectionFailed()
{
GD.PrintErr("连接服务器失败!");
IsInitialized = false;
}
/// <summary>
/// 服务器断开
/// </summary>
private void OnServerDisconnected()
{
GD.Print("服务器已断开连接!");
IsInitialized = false;
IsServer = false;
}
}GDScript
extends Node
## 默认端口号
const DEFAULT_PORT := 8910
## 最大连接数
const MAX_CLIENTS := 3
## 网络是否已初始化
var is_initialized: bool = false
## 是否为服务器
var is_server: bool = false
## 是否为客户端
var is_client: bool = false
## 当前ID
var peer_id: int
## 已连接的玩家
var connected_players: Array[int] = []
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)
multiplayer.server_disconnected.connect(_on_server_disconnected)
## 创建服务器
func create_server(port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var result := peer.create_server(port, MAX_CLIENTS)
if result == OK:
multiplayer.multiplayer_peer = peer
is_initialized = true
is_server = true
peer_id = multiplayer.get_unique_id()
print("服务器创建成功!端口: %d" % port)
connected_players.append(1)
return result
## 连接到服务器
func connect_to_server(ip_address: String, port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var result := peer.create_client(ip_address, port)
if result == OK:
multiplayer.multiplayer_peer = peer
is_initialized = true
is_client = true
print("正在连接服务器: %s:%d..." % [ip_address, port])
return result
## 断开连接
func disconnect() -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
is_initialized = false
is_server = false
is_client = false
connected_players.clear()
func _on_peer_connected(id: int) -> void:
print("玩家 %d 已连接" % id)
connected_players.append(id)
func _on_peer_disconnected(id: int) -> void:
print("玩家 %d 已断开" % id)
connected_players.erase(id)
func _on_connected_to_server() -> void:
peer_id = multiplayer.get_unique_id()
print("已连接到服务器,我的ID: %d" % peer_id)
func _on_connection_failed() -> void:
push_error("连接服务器失败!")
is_initialized = false
func _on_server_disconnected() -> void:
print("服务器已断开连接!")
is_initialized = false
is_server = false牌局同步
核心问题:手牌保密
在网络对战中,最关键的问题是:每个玩家只能看到自己的手牌。
| 数据类型 | 谁能看到 | 发送方式 |
|---|---|---|
| 自己的手牌 | 只有自己 | 服务器单独发给该玩家 |
| 对手的牌背 | 所有人 | 广播给所有人(只显示牌背) |
| 弃牌 | 所有人 | 广播给所有人 |
| 吃碰杠的组合 | 所有人 | 广播给所有人 |
| 胡牌结果 | 所有人 | 广播给所有人 |
绝对不能把所有手牌发给所有客户端
如果把手牌信息发给每个客户端,即使UI上不显示,懂技术的玩家也能通过网络抓包看到别人的手牌。这就像把所有人的底牌都亮在桌上——完全不公平。
RPC 远程调用
Godot 的 RPC(Remote Procedure Call,远程过程调用)是网络通信的核心。你可以把它理解为"打电话"——客户端A"打电话"给服务器,让服务器执行某个函数。
| RPC 属性 | 说明 | 使用场景 |
|---|---|---|
@rpc | 任何模式 | 通用 |
@rpc("any_peer") | 任何对等节点可调用 | 客户端调用服务器 |
@rpc("authority") | 只有权限节点可调用 | 服务器调用客户端 |
@rpc("call_remote") | 在远端执行 | 广播 |
@rpc("call_local") | 在本地也执行 | 本地+远端都执行 |
牌局同步代码
C
using Godot;
/// <summary>
/// 网络牌局管理器——处理游戏状态的同步
/// </summary>
public partial class NetworkGameManager : Node
{
/// <summary>
/// 游戏状态(服务器端维护)
/// </summary>
private GameState _gameState;
/// <summary>
/// 网络管理器
/// </summary>
private NetworkManager _networkManager;
/// <summary>
/// 开始一局游戏(服务器调用)
/// </summary>
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = true)]
public void RpcStartGame()
{
if (!Multiplayer.IsServer()) return;
// 服务器端初始化牌库
var deck = new TileDeck();
deck.InitMahjongDeck();
deck.Shuffle();
// 初始化游戏状态
_gameState = new GameState();
_gameState.Initialize(deck.GetAllTiles());
// 为每个玩家分配座位号
var playerIds = _networkManager.ConnectedPlayers;
for (int i = 0; i < playerIds.Count; i++)
{
int peerId = playerIds[i];
// 只发送该玩家的手牌
var handTiles = _gameState.Hands[i];
RpcId(peerId, nameof(RpcReceiveHand), handTiles.Select(
t => new object[] { (int)t.Suit, t.Value, t.TileId }).ToArray());
}
// 广播游戏开始(不包含手牌信息)
Rpc(nameof(RpcGameStarted), _gameState.CurrentPlayer);
}
/// <summary>
/// 客户端接收手牌
/// </summary>
[Rpc]
private void RpcReceiveHand(object[][] tileData)
{
// 将数据转换为牌对象
var tiles = new List<MahjongTile>();
foreach (var data in tileData)
{
var tile = new MahjongTile(
(SuitType)(int)data[0],
(int)data[1],
(int)data[2]
);
tiles.Add(tile);
}
// 通知UI显示手牌
GD.Print($"收到手牌: {tiles.Count}张");
EmitSignal(SignalName.HandReceived, tiles);
}
/// <summary>
/// 广播游戏开始
/// </summary>
[Rpc]
private void RpcGameStarted(int currentPlayer)
{
GD.Print($"游戏开始!当前轮到玩家 {currentPlayer}");
EmitSignal(SignalName.GameStarted, currentPlayer);
}
/// <summary>
/// 玩家出牌(客户端调用服务器)
/// </summary>
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
public void RpcDiscardTile(int suit, int value, int tileId)
{
if (!Multiplayer.IsServer()) return;
int senderId = Multiplayer.GetRemoteSenderId();
int playerIndex = GetPlayerIndex(senderId);
if (playerIndex < 0) return;
// 在服务器端验证出牌合法性
var tile = _gameState.Hands[playerIndex]
.FirstOrDefault(t => t.TileId == tileId);
if (tile == null)
{
GD.PrintErr($"玩家 {senderId} 尝试打出不存在的牌");
return;
}
// 执行出牌
bool success = _gameState.PlayerDiscard(playerIndex, tile);
if (success)
{
// 广播出牌信息给所有客户端
Rpc(nameof(RpcTileDiscarded), playerIndex, suit, value, tileId);
}
}
/// <summary>
/// 广播出牌
/// </summary>
[Rpc]
private void RpcTileDiscarded(int playerIndex, int suit, int value, int tileId)
{
GD.Print($"玩家 {playerIndex} 打出了一张牌");
EmitSignal(SignalName.TileDiscarded, playerIndex, suit, value, tileId);
}
/// <summary>
/// 获取玩家索引
/// </summary>
private int GetPlayerIndex(int peerId)
{
var playerIds = _networkManager.ConnectedPlayers;
return playerIds.IndexOf(peerId);
}
/// <summary>
/// 信号定义
/// </summary>
[Signal] public delegate void GameStartedEventHandler(int currentPlayer);
[Signal] public delegate void HandReceivedEventHandler(
Godot.Collections.Array tiles);
[Signal] public delegate void TileDiscardedEventHandler(
int playerIndex, int suit, int value, int tileId);
}GDScript
extends Node
## 游戏状态
var _game_state: GameState
## 网络管理器
var _network_manager: NetworkManager
## 信号
signal game_started(current_player: int)
signal hand_received(tiles: Array)
signal tile_discarded(player_index: int, suit: int, value: int, tile_id: int)
## 开始一局游戏(服务器调用)
@rpc("authority", "call_local")
func rpc_start_game() -> void:
if not multiplayer.is_server():
return
# 初始化牌库
var deck = TileDeck.new()
deck.init_mahjong_deck()
deck.shuffle()
# 初始化游戏状态
_game_state = GameState.new()
_game_state.initialize(deck.get_all_tiles())
# 为每个玩家发送手牌
var player_ids = _network_manager.connected_players
for i in range(player_ids.size()):
var peer_id = player_ids[i]
var hand_tiles = _game_state.hands[i]
var tile_data := []
for t in hand_tiles:
tile_data.append([t.suit, t.value, t.tile_id])
rpc_id(peer_id, &"rpc_receive_hand", tile_data)
# 广播游戏开始
rpc(&"rpc_game_started", _game_state.current_player)
## 客户端接收手牌
@rpc
func rpc_receive_hand(tile_data: Array) -> void:
var tiles := []
for data in tile_data:
var tile = MahjongTile.new(data[0], data[1], data[2])
tiles.append(tile)
print("收到手牌: %d张" % tiles.size())
hand_received.emit(tiles)
## 广播游戏开始
@rpc
func rpc_game_started(current_player: int) -> void:
print("游戏开始!当前轮到玩家 %d" % current_player)
game_started.emit(current_player)
## 玩家出牌
@rpc("any_peer")
func rpc_discard_tile(suit: int, value: int, tile_id: int) -> void:
if not multiplayer.is_server():
return
var sender_id = multiplayer.get_remote_sender_id()
var player_index = get_player_index(sender_id)
if player_index < 0:
return
# 验证出牌
var tile = null
for t in _game_state.hands[player_index]:
if t.tile_id == tile_id:
tile = t
break
if tile == null:
push_error("玩家 %d 尝试打出不存在的牌" % sender_id)
return
# 执行出牌
var success = _game_state.player_discard(player_index, tile)
if success:
rpc(&"rpc_tile_discarded", player_index, suit, value, tile_id)
## 广播出牌
@rpc
func rpc_tile_discarded(player_index: int, suit: int, value: int, tile_id: int) -> void:
print("玩家 %d 打出了一张牌" % player_index)
tile_discarded.emit(player_index, suit, value, tile_id)
## 获取玩家索引
func get_player_index(peer_id: int) -> int:
return _network_manager.connected_players.find(peer_id)断线重连
重连流程
玩家断线
↓
服务器标记该玩家为"掉线"
↓
游戏继续(掉线玩家自动跳过回合)
↓
玩家重新连接
↓
服务器验证玩家身份
↓
服务器重新发送该玩家的手牌
↓
玩家恢复到断线前的状态断线处理策略
- 短暂断线(< 30秒):自动跳过该玩家回合,等重连后恢复
- 长时间断线(> 5分钟):该玩家自动弃权,由AI接管
- 故意断线:记录断线次数,多次断线给予惩罚
房间管理
房间系统设计
| 功能 | 说明 |
|---|---|
| 创建房间 | 房主创建,生成房间号 |
| 加入房间 | 其他玩家输入房间号加入 |
| 房间列表 | 显示可用的房间 |
| 准备状态 | 所有玩家准备好后开始游戏 |
| 踢人 | 房主可以踢出玩家 |
房间管理代码
C
/// <summary>
/// 房间管理器
/// </summary>
public partial class RoomManager : Node
{
/// <summary>
/// 房间信息
/// </summary>
public class RoomInfo
{
public string RoomId { get; set; }
public string RoomName { get; set; }
public int HostId { get; set; }
public Godot.Collections.Array<int> PlayerIds { get; set; }
= new();
public Godot.Collections.Dictionary<int, bool> ReadyStates { get; set; }
= new();
public int MaxPlayers { get; set; } = 4;
public bool IsPlaying { get; set; }
public bool IsFull => PlayerIds.Count >= MaxPlayers;
public bool AllReady => PlayerIds.Count > 0 &&
ReadyStates.Values.All(r => r);
}
/// <summary>
/// 当前房间
/// </summary>
public RoomInfo CurrentRoom { get; private set; }
/// <summary>
/// 创建房间
/// </summary>
public string CreateRoom(string roomName)
{
CurrentRoom = new RoomInfo
{
RoomId = GenerateRoomId(),
RoomName = roomName,
HostId = Multiplayer.GetUniqueId()
};
CurrentRoom.PlayerIds.Add(Multiplayer.GetUniqueId());
CurrentRoom.ReadyStates[Multiplayer.GetUniqueId()] = false;
GD.Print($"房间已创建: {CurrentRoom.RoomId} - {roomName}");
return CurrentRoom.RoomId;
}
/// <summary>
/// 生成6位房间号
/// </summary>
private string GenerateRoomId()
{
var random = new Random();
return random.Next(100000, 999999).ToString();
}
/// <summary>
/// 玩家准备/取消准备
/// </summary>
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
public void RpcToggleReady()
{
if (CurrentRoom == null) return;
int senderId = Multiplayer.GetRemoteSenderId();
if (!CurrentRoom.ReadyStates.ContainsKey(senderId)) return;
CurrentRoom.ReadyStates[senderId] = !CurrentRoom.ReadyStates[senderId];
// 广播准备状态
Rpc(nameof(RpcReadyStateChanged), senderId,
CurrentRoom.ReadyStates[senderId]);
}
/// <summary>
/// 广播准备状态变化
/// </summary>
[Rpc]
private void RpcReadyStateChanged(int playerId, bool ready)
{
GD.Print($"玩家 {playerId} {(ready ? "已准备" : "取消准备")}");
}
}GDScript
extends Node
## 房间信息
class RoomInfo:
var room_id: String
var room_name: String
var host_id: int
var player_ids: Array[int] = []
var ready_states: Dictionary = {}
var max_players: int = 4
var is_playing: bool = false
func is_full() -> bool:
return player_ids.size() >= max_players
func all_ready() -> bool:
if player_ids.is_empty():
return false
for v in ready_states.values():
if not v:
return false
return true
## 当前房间
var current_room: RoomInfo
## 创建房间
func create_room(room_name: String) -> String:
current_room = RoomInfo.new()
current_room.room_id = generate_room_id()
current_room.room_name = room_name
current_room.host_id = multiplayer.get_unique_id()
current_room.player_ids.append(multiplayer.get_unique_id())
current_room.ready_states[multiplayer.get_unique_id()] = false
print("房间已创建: %s - %s" % [current_room.room_id, room_name])
return current_room.room_id
## 生成6位房间号
func generate_room_id() -> String:
return str(randi() % 900000 + 100000)
## 玩家准备
@rpc("any_peer")
func rpc_toggle_ready() -> void:
if current_room == null:
return
var sender_id = multiplayer.get_remote_sender_id()
if not current_room.ready_states.has(sender_id):
return
current_room.ready_states[sender_id] = not current_room.ready_states[sender_id]
rpc(&"rpc_ready_state_changed", sender_id, current_room.ready_states[sender_id])
@rpc
func rpc_ready_state_changed(player_id: int, ready: bool) -> void:
var state = "已准备" if ready else "取消准备"
print("玩家 %d %s" % [player_id, state])观战模式
观战模式允许额外的玩家以"旁观者"身份加入房间,观看牌局但不参与操作:
| 功能 | 说明 |
|---|---|
| 观战加入 | 不占用玩家座位 |
| 观战视角 | 可以看到所有人的手牌 |
| 延迟同步 | 观战者可能有1-2秒延迟 |
相关信息
观战模式的实现很简单——给观战者发送所有人的手牌(包括对手的),但标记他们为"观战者"身份,禁止他们执行任何出牌操作。
小结
本章我们实现了网络对战系统:
- 网络架构——使用 ENetMultiplayerPeer 实现服务器权威制
- 网络管理器——创建服务器、连接服务器、断开连接
- 牌局同步——手牌保密、出牌同步、状态同步
- 断线重连——处理玩家意外断开的情况
- 房间管理——创建房间、加入房间、准备系统
- 观战模式——允许旁观者观看牌局
提示
网络对战是让游戏"从单机变联机"的关键。Godot 4 的 ENet 系统虽然不如专门的联网框架功能丰富,但对于棋牌游戏来说完全够用了。最后一章我们将打磨游戏的视觉效果和音效,让它成为一个可以发布的产品。
→ 8. 打磨与发布
