5. 网络游戏之卡牌
2026/4/13大约 11 分钟
网络游戏之卡牌
从本章开始,我们要进入网络游戏的领域。先从最经典的类型——卡牌游戏入手,因为它的交互模式相对简单(回合制、不需要实时同步),非常适合作为网络编程的入门项目。
什么是网络卡牌游戏
打个比方:你在家里和远方的朋友打牌。你们各拿自己的牌,轮流出牌,谁也不知道对方手里有什么——除非你作弊偷看。
网络卡牌游戏就是把这种"面对面打牌"搬到了线上。核心要解决的问题是:
| 问题 | 说明 |
|---|---|
| 通信 | 两个玩家的设备怎么"说话"? |
| 同步 | 怎么保证双方看到的状态一致? |
| 安全 | 怎么防止玩家偷看对方的牌或篡改数据? |
| 掉线 | 玩家网络断开了怎么办? |
网络通信基础
客户端-服务器模型(推荐)
几乎所有网络游戏都采用这种架构:
玩家A的设备(客户端) ←──网络──→ 游戏服务器 ←──网络──→ 玩家B的设备(客户端)- 客户端:玩家手机/电脑上运行的程序,负责显示画面、接收操作
- 服务器:运行在云端的程序,负责规则判定、数据存储、转发消息
为什么一定要有服务器
你可能会想:能不能让两个客户端直接连接,不要服务器?
答案是:技术上可以(P2P 模式),但不推荐。因为:
- 没有服务器做裁判,玩家可以作弊(修改自己的手牌)
- 一个玩家掉线,另一个也受影响
- 无法存储全局数据(排行榜、玩家账号)
服务器是整个游戏的"裁判"和"仓库"。
Godot 的网络方案
Godot 4 提供了内置的网络功能,基于 ENet 库(一个轻量级的网络通信库):
| 方案 | 适用场景 | 说明 |
|---|---|---|
| ENet(内置) | 实时多人游戏 | Godot 自带,基于 UDP,支持可靠传输 |
| WebSocket | 网页游戏、卡牌/回合制 | 兼容浏览器,基于 TCP |
| HTTP 请求 | 登录、排行榜、充值 | 简单的请求-响应模式 |
对于卡牌游戏,推荐使用 WebSocket 或 HTTP + 自定义协议,因为卡牌游戏不需要实时同步,对延迟要求不高。
使用 WebSocket 建立连接
以下是客户端连接服务器的基础代码:
C
// WebSocket 客户端连接
using Godot;
public partial class NetworkClient : Node
{
private WebSocketPeer _peer = new();
// 连接服务器
public void ConnectToServer(string url)
{
Error err = _peer.ConnectToUrl(url);
if (err != Error.Ok)
{
GD.PrintErr($"连接失败:{err}");
EmitSignal(SignalName.ConnectionFailed);
}
else
{
GD.Print("正在连接服务器...");
}
}
// 每帧处理网络消息
public override void _Process(double delta)
{
_peer.Poll();
var state = _peer.GetReadyState();
switch (state)
{
case WebSocketPeer.State.Open:
// 检查是否有新消息
while (_peer.GetAvailablePacketCount() > 0)
{
var packet = _peer.GetPacket();
string message = System.Text.Encoding.UTF8.GetString(packet);
HandleMessage(message);
}
break;
case WebSocketPeer.State.Closed:
GD.Print("与服务器断开连接");
break;
}
}
// 发送消息给服务器
public void SendMessage(string message)
{
if (_peer.GetReadyState() == WebSocketPeer.State.Open)
{
_peer.SendText(message);
}
}
// 处理服务器发来的消息
private void HandleMessage(string message)
{
GD.Print($"收到服务器消息:{message}");
// 根据 JSON 消息类型分发处理
}
[Signal] public delegate void ConnectionFailedEventHandler();
[Signal] public delegate void ConnectedEventHandler();
}GDScript
# WebSocket 客户端连接
extends Node
var _peer: WebSocketPeer = WebSocketPeer.new()
# 连接服务器
func connect_to_server(url: String) -> void:
var err = _peer.connect_to_url(url)
if err != OK:
push_error("连接失败:%s" % err)
connection_failed.emit()
else:
print("正在连接服务器...")
# 每帧处理网络消息
func _process(_delta):
_peer.poll()
var state = _peer.get_ready_state()
match state:
WebSocketPeer.STATE_OPEN:
# 检查是否有新消息
while _peer.get_available_packet_count() > 0:
var packet = _peer.get_packet()
var message = packet.get_string_from_utf8()
handle_message(message)
WebSocketPeer.STATE_CLOSED:
print("与服务器断开连接")
# 发送消息给服务器
func send_message(message: String) -> void:
if _peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
_peer.send_text(message)
# 处理服务器发来的消息
func handle_message(message: String) -> void:
print("收到服务器消息:%s" % message)
# 根据 JSON 消息类型分发处理
signal connection_failed
signal connected卡牌游戏的消息协议
卡牌游戏的核心是消息协议——客户端和服务器之间用什么格式"说话"。
消息格式设计
每条消息是一个 JSON 对象,包含 type(消息类型)和对应的 data(数据):
{
"type": "play_card",
"data": {
"card_id": 1005,
"target_position": 3
}
}常见消息类型
| 消息类型 | 方向 | 说明 |
|---|---|---|
join_room | 客户端 → 服务器 | 加入房间 |
game_start | 服务器 → 客户端 | 游戏开始,发送初始手牌 |
play_card | 客户端 → 服务器 | 出牌 |
card_played | 服务器 → 客户端 | 通知双方某张牌被打出 |
draw_card | 客户端 → 服务器 | 摸牌 |
card_drawn | 服务器 → 客户端 | 通知某玩家摸了一张牌(不告诉对方是什么牌) |
turn_end | 客户端 → 服务器 | 结束回合 |
turn_start | 服务器 → 客户端 | 通知轮到谁了 |
game_over | 服务器 → 客户端 | 游戏结束,宣布结果 |
实现一个简单的卡牌对战
服务端架构(概念)
服务器不需要 Godot 的图形功能,可以用任何语言编写(Node.js、Python、Go、Java 等)。以下是服务端的伪代码逻辑:
// 服务端游戏房间管理
class GameRoom:
players = [] // 房间内的玩家
deck = [] // 牌堆
currentTurn = 0 // 当前轮到谁
// 洗牌并发手牌
function startGame():
deck = shuffle(createDeck())
for each player:
hand = deck.pop(5) // 每人发 5 张牌
send(player, "game_start", { hand })
// 处理出牌
function onPlayCard(player, cardId):
if player != currentTurn:
return error("还没轮到你")
if cardId not in player.hand:
return error("你没有这张牌")
// 从手牌移除
player.hand.remove(cardId)
// 通知双方
broadcast("card_played", { player: player.id, card: cardId })
// 切换回合
switchTurn()
// 切换回合
function switchTurn():
currentTurn = (currentTurn + 1) % players.length
broadcast("turn_start", { player: currentTurn })核心原则
所有游戏逻辑都在服务器上执行。 客户端只负责:
- 显示画面
- 接收玩家操作
- 把操作发送给服务器
- 根据服务器的反馈更新画面
客户端不能自己做判定(比如判断谁赢了),否则玩家可以修改客户端代码作弊。
客户端——手牌管理
C
// 客户端手牌管理器
using Godot;
using System.Collections.Generic;
public partial class HandManager : Node
{
// 玩家手中的牌
private List<int> _handCards = new();
// 对手的手牌数量(只知道数量,不知道具体是哪些牌)
private int _opponentHandCount = 0;
// 初始化手牌(收到服务器的 game_start 消息后调用)
public void SetInitialHand(int[] cardIds)
{
_handCards.Clear();
_handCards.AddRange(cardIds);
RenderHand();
}
// 打出一张牌(发送给服务器,等待确认)
public void PlayCard(int cardIndex)
{
if (cardIndex < 0 || cardIndex >= _handCards.Count)
{
GD.PrintErr("无效的卡牌索引");
return;
}
int cardId = _handCards[cardIndex];
var message = new Godot.Collections.Dictionary
{
{ "type", "play_card" },
{ "data", new Godot.Collections.Dictionary { { "card_id", cardId } } }
};
// 发送给服务器
GetNode<NetworkClient>("/root/NetworkClient")
.Call("send_message", Json.Stringify(message));
}
// 服务器确认出牌成功后,从手牌中移除
public void OnCardPlayed(int cardId)
{
_handCards.Remove(cardId);
RenderHand();
}
// 摸牌(收到服务器的 card_drawn 消息后调用)
public void OnCardDrawn(int cardId)
{
_handCards.Add(cardId);
RenderHand();
}
// 更新对手手牌数量显示
public void OnOpponentCardDrawn()
{
_opponentHandCount++;
UpdateOpponentHandDisplay();
}
// 渲染手牌到 UI
private void RenderHand()
{
var container = GetNode<HBoxContainer>("UI/HandContainer");
foreach (var child in container.GetChildren())
child.QueueFree();
for (int i = 0; i < _handCards.Count; i++)
{
var button = new Button();
button.Text = $"Card {_handCards[i]}";
int index = i; // 闭包捕获
button.Pressed += () => PlayCard(index);
container.AddChild(button);
}
}
private void UpdateOpponentHandDisplay()
{
var label = GetNode<Label>("UI/OpponentHandCount");
label.Text = $"对手手牌:{_opponentHandCount} 张";
}
}GDScript
# 客户端手牌管理器
extends Node
# 玩家手中的牌
var hand_cards: Array[int] = []
# 对手的手牌数量(只知道数量,不知道具体是哪些牌)
var opponent_hand_count: int = 0
# 初始化手牌(收到服务器的 game_start 消息后调用)
func set_initial_hand(card_ids: Array) -> void:
hand_cards.clear()
hand_cards.assign(card_ids)
render_hand()
# 打出一张牌(发送给服务器,等待确认)
func play_card(card_index: int) -> void:
if card_index < 0 or card_index >= hand_cards.size():
push_error("无效的卡牌索引")
return
var card_id = hand_cards[card_index]
var message = {
"type": "play_card",
"data": {"card_id": card_id}
}
# 发送给服务器
var client = get_node_or_null("/root/NetworkClient")
if client:
client.send_message(JSON.stringify(message))
# 服务器确认出牌成功后,从手牌中移除
func on_card_played(card_id: int) -> void:
hand_cards.erase(card_id)
render_hand()
# 摸牌(收到服务器的 card_drawn 消息后调用)
func on_card_drawn(card_id: int) -> void:
hand_cards.append(card_id)
render_hand()
# 更新对手手牌数量显示
func on_opponent_card_drawn() -> void:
opponent_hand_count += 1
update_opponent_hand_display()
# 渲染手牌到 UI
func render_hand() -> void:
var container = get_node("UI/HandContainer")
for child in container.get_children():
child.queue_free()
for i in range(hand_cards.size()):
var button = Button.new()
button.text = "Card %d" % hand_cards[i]
var idx = i # 闭包捕获
button.pressed.connect(func(): play_card(idx))
container.add_child(button)
func update_opponent_hand_display() -> void:
var label = get_node("UI/OpponentHandCount")
label.text = "对手手牌:%d 张" % opponent_hand_count客户端——消息分发器
C
// 消息分发器——收到服务器消息后,分发给对应的管理器
using Godot;
using System.Text.Json;
public partial class MessageDispatcher : Node
{
public override void _Ready()
{
// 连接 NetworkClient 的信号
var client = GetNode<NetworkClient>("/root/NetworkClient");
// 假设 NetworkClient 有一个 MessageReceived 信号
// client.MessageReceived += OnMessageReceived;
}
private void OnMessageReceived(string message)
{
var json = Json.ParseString(message);
var type = json.GetProperty("type").GetString();
switch (type)
{
case "game_start":
var cards = json.GetProperty("data")
.GetProperty("hand").Deserialize<int[]>();
GetNode<HandManager>("HandManager").SetInitialHand(cards);
break;
case "card_played":
int playerId = json.GetProperty("data").GetProperty("player").GetInt32();
int cardId = json.GetProperty("data").GetProperty("card").GetInt32();
GetNode<PlayField>("PlayField").ShowPlayedCard(playerId, cardId);
// 如果是自己出的牌,从手牌中移除
if (playerId == GetMyPlayerId())
GetNode<HandManager>("HandManager").OnCardPlayed(cardId);
break;
case "card_drawn":
int drawer = json.GetProperty("data").GetProperty("player").GetInt32();
if (drawer == GetMyPlayerId())
{
// 自己摸的牌,服务器会告诉你是什么牌
int newCard = json.GetProperty("data").GetProperty("card").GetInt32();
GetNode<HandManager>("HandManager").OnCardDrawn(newCard);
}
else
{
// 对手摸的牌,你不知道是什么
GetNode<HandManager>("HandManager").OnOpponentCardDrawn();
}
break;
case "turn_start":
int turnPlayer = json.GetProperty("data").GetProperty("player").GetInt32();
GetNode<TurnIndicator>("UI/TurnIndicator")
.ShowTurn(turnPlayer == GetMyPlayerId());
break;
case "game_over":
string winner = json.GetProperty("data").GetProperty("winner").GetString();
GetNode<GameUI>("UI").ShowGameOver(winner);
break;
}
}
private int GetMyPlayerId()
{
// 从本地配置或服务器分配中获取
return 1;
}
}GDScript
# 消息分发器——收到服务器消息后,分发给对应的管理器
extends Node
func _ready():
var client = get_node_or_null("/root/NetworkClient")
if client and client.message_received.is_connected(_on_message_received):
pass
# 假设 NetworkClient 有一个 message_received 信号
# client.message_received.connect(_on_message_received)
func _on_message_received(message: String) -> void:
var json = JSON.new()
json.parse(message)
var data = json.data
var type = data["type"]
match type:
"game_start":
var cards = data["data"]["hand"]
var hand_mgr = get_node("HandManager")
hand_mgr.set_initial_hand(cards)
"card_played":
var player_id = data["data"]["player"]
var card_id = data["data"]["card"]
var play_field = get_node("PlayField")
play_field.show_played_card(player_id, card_id)
# 如果是自己出的牌,从手牌中移除
if player_id == _get_my_player_id():
get_node("HandManager").on_card_played(card_id)
"card_drawn":
var drawer = data["data"]["player"]
if drawer == _get_my_player_id():
# 自己摸的牌,服务器会告诉你是什么牌
var new_card = data["data"]["card"]
get_node("HandManager").on_card_drawn(new_card)
else:
# 对手摸的牌,你不知道是什么
get_node("HandManager").on_opponent_card_drawn()
"turn_start":
var turn_player = data["data"]["player"]
get_node("UI/TurnIndicator").show_turn(
turn_player == _get_my_player_id()
)
"game_over":
var winner = data["data"]["winner"]
get_node("UI").show_game_over(winner)
func _get_my_player_id() -> int:
# 从本地配置或服务器分配中获取
return 1防作弊设计
核心原则:客户端永远不可信
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 出牌 | 客户端判断牌是否合法,直接显示 | 客户端发送请求,服务器验证后才广播 |
| 洗牌 | 客户端洗牌,把结果发给服务器 | 服务器洗牌,只把"你的手牌"发给你 |
| 摸牌 | 客户端从牌堆中随机选一张 | 服务器选好牌,告诉你"你摸到了什么" |
| 判胜 | 客户端比较双方分数 | 服务器计算结果,通知双方 |
信息隔离
不同玩家能看到的信息是不一样的:
服务器知道:所有牌的分布、每个玩家的手牌、牌堆剩余
玩家 A 知道:自己的手牌、场上已打出的牌、对手手牌数量
玩家 B 知道:自己的手牌、场上已打出的牌、对手手牌数量服务器在转发消息时,必须过滤掉不该给某个玩家看的信息。
掉线重连
网络不稳定时,玩家可能会掉线。好的卡牌游戏应该支持断线重连:
- 暂停游戏:检测到玩家掉线后,游戏暂停,等待对方重连
- 保留状态:服务器保留该房间的完整游戏状态
- 恢复画面:玩家重连后,服务器把当前状态完整发送给客户端,客户端重新渲染
C
// 断线重连处理
public partial class ReconnectManager : Node
{
[Export] public float ReconnectInterval = 3.0f;
[Export] public int MaxRetryCount = 5;
private int _retryCount;
private string _serverUrl;
private string _roomId;
public void OnDisconnected()
{
GD.Print("与服务器断开连接,尝试重连...");
_retryCount = 0;
StartReconnecting();
}
private async void StartReconnecting()
{
while (_retryCount < MaxRetryCount)
{
_retryCount++;
GD.Print($"第 {_retryCount} 次重连...");
var client = GetNode<NetworkClient>("/root/NetworkClient");
client.ConnectToServer(_serverUrl);
// 等待连接结果
await ToSignal(GetTree().CreateTimer(ReconnectInterval),
SceneTreeTimer.SignalName.Timeout);
// 检查是否重连成功
if (IsConnected())
{
// 重连成功,请求恢复游戏状态
RequestGameState();
return;
}
}
GD.Print("重连失败,返回大厅");
GetTree().ChangeSceneToFile("res://scenes/lobby.tscn");
}
private void RequestGameState()
{
var message = new Godot.Collections.Dictionary
{
{ "type", "rejoin" },
{ "data", new Godot.Collections.Dictionary { { "room_id", _roomId } } }
};
GetNode<NetworkClient>("/root/NetworkClient")
.Call("send_message", Json.Stringify(message));
}
private bool IsConnected()
{
// 检查 WebSocket 连接状态
return true; // 实际实现需要检查具体状态
}
}GDScript
# 断线重连处理
extends Node
@export var reconnect_interval: float = 3.0
@export var max_retry_count: int = 5
var _retry_count: int = 0
var _server_url: String
var _room_id: String
func on_disconnected():
print("与服务器断开连接,尝试重连...")
_retry_count = 0
_start_reconnecting()
func _start_reconnecting():
while _retry_count < max_retry_count:
_retry_count += 1
print("第 %d 次重连..." % _retry_count)
var client = get_node_or_null("/root/NetworkClient")
if client:
client.connect_to_server(_server_url)
# 等待连接结果
await get_tree().create_timer(reconnect_interval).timeout
# 检查是否重连成功
if _is_connected():
# 重连成功,请求恢复游戏状态
_request_game_state()
return
print("重连失败,返回大厅")
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
func _request_game_state():
var message = {
"type": "rejoin",
"data": {"room_id": _room_id}
}
var client = get_node_or_null("/root/NetworkClient")
if client:
client.send_message(JSON.stringify(message))
func _is_connected() -> bool:
# 检查 WebSocket 连接状态
return true # 实际实现需要检查具体状态下一章
卡牌游戏的网络基础打好了。下一章我们来做更有挑战性的——多人实时战斗,这涉及到更复杂的同步问题。
