7. 网络游戏之多人纸牌麻将
2026/4/13大约 11 分钟
网络游戏之多人纸牌麻将
麻将可能是世界上最复杂的纸牌游戏之一。要做一个网络麻将,不仅要处理 4 个玩家的实时通信,还要实现麻将特有的规则(吃、碰、杠、胡)。本章以中国国标麻将为基础,讲解网络麻将的架构设计和核心实现。
为什么麻将比卡牌游戏复杂
| 对比项 | 卡牌游戏 | 麻将 |
|---|---|---|
| 玩家人数 | 通常 2 人 | 4 人 |
| 牌的来源 | 公共牌堆 | 公共牌墙 + 玩家手牌 |
| 交互方式 | 轮流出牌 | 任何玩家都可以对别人打出的牌做反应(吃/碰/杠/胡) |
| 信息隐藏 | 对方手牌不可见 | 其他三方手牌都不可见 |
| 特殊规则 | 少 | 胡牌判定有几十种牌型 |
核心难点
麻将最大的难点是并发操作:当一个玩家打出一手牌后,其他三个玩家可能同时想吃、碰、杠、胡。服务器必须按优先级(胡 > 杠 > 碰 > 吃)处理这些操作,不能让两个人同时碰同一张牌。
整体架构
服务器端设计
麻将服务器
├── 房间管理器(RoomManager)
│ └── 房间(Room)
│ ├── 玩家列表(4 个玩家)
│ ├── 牌墙(Wall)—— 136 张牌
│ ├── 弃牌区(DiscardPile)
│ ├── 回合管理器(TurnManager)
│ └── 规则引擎(RuleEngine)—— 吃/碰/杠/胡判定
├── 匹配系统(MatchMaker)
└── 数据存储(PlayerData, MatchHistory)客户端设计
麻将客户端
├── 网络管理器(NetworkManager)—— 通信
├── 消息处理器(MessageHandler)—— 分发消息
├── 手牌管理器(HandManager)—— 管理自己的牌
├── 牌桌渲染器(TableRenderer)—— 渲染牌面
├── 操作面板(ActionPanel)—— 吃/碰/杠/胡按钮
└── 计时器(TurnTimer)—— 回合倒计时牌的数据结构
麻将牌的表示
麻将共有 136 张牌(不含花牌):
| 花色 | 数量 | 说明 |
|---|---|---|
| 万子 | 36 张 | 一万到九万,每种 4 张 |
| 条子 | 36 张 | 一条到九条,每种 4 张 |
| 筒子 | 36 张 | 一筒到九筒,每种 4 张 |
| 风牌 | 16 张 | 东南西北,每种 4 张 |
| 箭牌 | 12 张 | 中发白,每种 4 张 |
C
// 麻将牌的数据定义
using Godot;
/// <summary>
/// 麻将牌花色
/// </summary>
public enum TileSuit
{
Wan = 0, // 万子
Tiao = 1, // 条子
Tong = 2, // 筒子
Feng = 3, // 风牌(东南西北)
Jian = 4 // 箭牌(中发白)
}
/// <summary>
/// 麻将牌
/// </summary>
public struct MahjongTile
{
public TileSuit Suit { get; }
public int Number { get; } // 1-9(万条筒),1-4(风),1-3(箭)
public MahjongTile(TileSuit suit, int number)
{
Suit = suit;
Number = number;
}
/// <summary>
/// 获取牌的唯一 ID(0-135)
/// </summary>
public int Id => (int)Suit * 9 + (Number - 1);
/// <summary>
/// 显示名称,如"一万""东风""红中"
/// </summary>
public string DisplayName
{
get
{
var suitNames = new[] { "万", "条", "筒", "", "" };
var numberNames = new[] { "一", "二", "三", "四", "五", "六", "七", "八", "九" };
var fengNames = new[] { "东", "南", "西", "北" };
var jianNames = new[] { "中", "发", "白" };
return Suit switch
{
TileSuit.Wan => $"{numberNames[Number - 1]}{suitNames[0]}",
TileSuit.Tiao => $"{numberNames[Number - 1]}{suitNames[1]}",
TileSuit.Tong => $"{numberNames[Number - 1]}{suitNames[2]}",
TileSuit.Feng => $"{fengNames[Number - 1]}风",
TileSuit.Jian => jianNames[Number - 1],
_ => "未知"
};
}
}
public override string ToString() => DisplayName;
}GDScript
# 麻将牌的数据定义
## 麻将牌花色
enum TileSuit {
WAN = 0, # 万子
TIAO = 1, # 条子
TONG = 2, # 筒子
FENG = 3, # 风牌(东南西北)
JIAN = 4 # 箭牌(中发白)
}
## 麻将牌
class MahjongTile:
var suit: int # TileSuit 枚举值
var number: int # 1-9(万条筒),1-4(风),1-3(箭)
func _init(suit: int, number: int):
self.suit = suit
self.number = number
## 获取牌的唯一 ID(0-135)
func get_id() -> int:
return suit * 9 + (number - 1)
## 显示名称,如"一万""东风""红中"
func get_display_name() -> String:
var suit_names = ["万", "条", "筒", "", ""]
var number_names = ["一", "二", "三", "四", "五", "六", "七", "八", "九"]
var feng_names = ["东", "南", "西", "北"]
var jian_names = ["中", "发", "白"]
match suit:
TileSuit.WAN:
return "%s%s" % [number_names[number - 1], suit_names[0]]
TileSuit.TIAO:
return "%s%s" % [number_names[number - 1], suit_names[1]]
TileSuit.TONG:
return "%s%s" % [number_names[number - 1], suit_names[2]]
TileSuit.FENG:
return "%s风" % feng_names[number - 1]
TileSuit.JIAN:
return jian_names[number - 1]
_:
return "未知"
func _to_string() -> String:
return get_display_name()创建和洗牌
C
// 创建牌墙并洗牌
using Godot;
using System.Collections.Generic;
public partial class MahjongWall
{
private List<MahjongTile> _tiles = new();
// 创建完整的 136 张牌
public void CreateWall()
{
_tiles.Clear();
// 万子、条子、筒子:每种 9 个数字,每个 4 张
for (int suit = 0; suit <= 2; suit++)
{
for (int num = 1; num <= 9; num++)
{
for (int copy = 0; copy < 4; copy++)
{
_tiles.Add(new MahjongTile((TileSuit)suit, num));
}
}
}
// 风牌:4 种,每种 4 张
for (int feng = 1; feng <= 4; feng++)
{
for (int copy = 0; copy < 4; copy++)
{
_tiles.Add(new MahjongTile(TileSuit.Feng, feng));
}
}
// 箭牌:3 种,每种 4 张
for (int jian = 1; jian <= 3; jian++)
{
for (int copy = 0; copy < 4; copy++)
{
_tiles.Add(new MahjongTile(TileSuit.Jian, jian));
}
}
GD.Print($"创建了 {_tiles.Count} 张牌");
}
// 洗牌(Fisher-Yates 算法)
public void Shuffle()
{
var random = new Random();
for (int i = _tiles.Count - 1; i > 0; i--)
{
int j = random.Next(i + 1);
(_tiles[i], _tiles[j]) = (_tiles[j], _tiles[i]);
}
}
// 摸牌
public MahjongTile? DrawTile()
{
if (_tiles.Count == 0) return null;
var tile = _tiles[0];
_tiles.RemoveAt(0);
return tile;
}
// 剩余牌数
public int RemainingCount => _tiles.Count;
}GDScript
# 创建牌墙并洗牌
extends RefCounted
var _tiles: Array[MahjongTile] = []
## 创建完整的 136 张牌
func create_wall() -> void:
_tiles.clear()
# 万子、条子、筒子:每种 9 个数字,每个 4 张
for suit in range(3):
for num in range(1, 10):
for _copy in range(4):
_tiles.append(MahjongTile.new(suit, num))
# 风牌:4 种,每种 4 张
for feng in range(1, 5):
for _copy in range(4):
_tiles.append(MahjongTile.new(TileSuit.FENG, feng))
# 箭牌:3 种,每种 4 张
for jian in range(1, 4):
for _copy in range(4):
_tiles.append(MahjongTile.new(TileSuit.JIAN, jian))
print("创建了 %d 张牌" % _tiles.size())
## 洗牌(Fisher-Yates 算法)
func shuffle() -> void:
for i in range(_tiles.size() - 1, 0, -1):
var j = randi() % (i + 1)
_tiles.swap(i, j)
## 摸牌
func draw_tile() -> MahjongTile:
if _tiles.is_empty():
return null
var tile = _tiles.pop_front()
return tile
## 剩余牌数
func remaining_count() -> int:
return _tiles.size()核心游戏流程
游戏开始——发牌
麻将开局时,每人发 13 张牌(庄家 14 张)。
消息协议设计
| 消息类型 | 方向 | 说明 |
|---|---|---|
game_start | 服务器 → 客户端 | 游戏开始,发送初始手牌 |
draw_tile | 客户端 → 服务器 | 请求摸牌 |
tile_drawn | 服务器 → 客户端 | 通知摸到的牌 |
discard_tile | 客户端 → 服务器 | 出牌 |
tile_discarded | 服务器 → 客户端 | 通知有人出牌 |
action_request | 服务器 → 客户端 | 询问是否要吃/碰/杠/胡 |
action_response | 客户端 → 服务器 | 回答吃/碰/杠/胡或跳过 |
action_executed | 服务器 → 客户端 | 通知操作结果 |
game_over | 服务器 → 客户端 | 游戏结束,宣布结果 |
出牌后的并发处理
这是麻将网络编程最关键的部分。当一个玩家出牌后,服务器需要:
1. 检查其他 3 个玩家能否胡这张牌
2. 如果有人能胡,等待他们的响应(有超时时间)
3. 如果没人胡,检查能否杠
4. 如果没人杠,检查能否碰
5. 如果没人碰,检查能否吃(只能被下家吃)
6. 如果没人操作,下家摸牌C
// 出牌后的操作优先级处理
using Godot;
using System.Collections.Generic;
using System.Threading.Tasks;
public partial class ActionResolver
{
private const double ResponseTimeout = 5.0; // 5 秒响应超时
// 当一个玩家出牌后,检查其他玩家的操作
public async void ResolveDiscardAction(
int discardingPlayer,
MahjongTile discardedTile,
MahjongGame game)
{
// 第一步:检查胡(优先级最高)
var huPlayers = new List<int>();
for (int i = 0; i < 4; i++)
{
if (i == discardingPlayer) continue;
if (game.CanWin(i, discardedTile))
huPlayers.Add(i);
}
if (huPlayers.Count > 0)
{
// 询问能胡的玩家
int? winner = await WaitPlayerResponse(huPlayers, "hu");
if (winner != null)
{
game.ExecuteWin(winner.Value, discardedTile);
return;
}
}
// 第二步:检查杠
var gangPlayers = new List<int>();
for (int i = 0; i < 4; i++)
{
if (i == discardingPlayer) continue;
if (game.CanGang(i, discardedTile))
gangPlayers.Add(i);
}
if (gangPlayers.Count > 0)
{
int? ganger = await WaitPlayerResponse(gangPlayers, "gang");
if (ganger != null)
{
game.ExecuteGang(ganger.Value, discardedTile);
return;
}
}
// 第三步:检查碰
var pengPlayers = new List<int>();
for (int i = 0; i < 4; i++)
{
if (i == discardingPlayer) continue;
if (game.CanPeng(i, discardedTile))
pengPlayers.Add(i);
}
if (pengPlayers.Count > 0)
{
int? penger = await WaitPlayerResponse(pengPlayers, "peng");
if (penger != null)
{
game.ExecutePeng(penger.Value, discardedTile);
return;
}
}
// 第四步:检查吃(只有下家能吃)
int nextPlayer = (discardingPlayer + 1) % 4;
if (game.CanChi(nextPlayer, discardedTile))
{
bool wantChi = await AskSinglePlayer(nextPlayer, "chi");
if (wantChi)
{
game.ExecuteChi(nextPlayer, discardedTile);
return;
}
}
// 没人操作,下家摸牌
game.NextTurn(nextPlayer);
}
// 等待玩家响应
private async Task<int?> WaitPlayerResponse(List<int> players, string actionType)
{
// 给每个能操作的玩家发送询问
foreach (var playerId in players)
{
SendActionRequest(playerId, actionType);
}
// 等待第一个响应(或超时)
double elapsed = 0;
while (elapsed < ResponseTimeout)
{
await Task.Delay(100);
elapsed += 0.1;
// 检查是否有玩家响应了
foreach (var playerId in players)
{
if (HasPlayerResponded(playerId, actionType, out bool accepted))
{
return accepted ? playerId : null;
}
}
}
return null; // 超时,视为放弃
}
private async Task<bool> AskSinglePlayer(int playerId, string actionType)
{
SendActionRequest(playerId, actionType);
double elapsed = 0;
while (elapsed < ResponseTimeout)
{
await Task.Delay(100);
elapsed += 0.1;
if (HasPlayerResponded(playerId, actionType, out bool accepted))
return accepted;
}
return false;
}
private void SendActionRequest(int playerId, string actionType)
{
GD.Print($"询问玩家 {playerId} 是否要 {actionType}");
// 发送 WebSocket 消息给对应客户端
}
private bool HasPlayerResponded(int playerId, string actionType, out bool accepted)
{
accepted = false;
// 检查该玩家是否已经回复
// 实际实现中需要维护一个待处理请求的字典
return false;
}
}GDScript
# 出牌后的操作优先级处理
extends RefCounted
const RESPONSE_TIMEOUT = 5.0 # 5 秒响应超时
# 当一个玩家出牌后,检查其他玩家的操作
func resolve_discard_action(
discarding_player: int,
discarded_tile: MahjongTile,
game: Node
) -> void:
# 第一步:检查胡(优先级最高)
var hu_players = []
for i in range(4):
if i == discarding_player: continue
if game.can_win(i, discarded_tile):
hu_players.append(i)
if hu_players.size() > 0:
var winner = await _wait_player_response(hu_players, "hu")
if winner != null:
game.execute_win(winner, discarded_tile)
return
# 第二步:检查杠
var gang_players = []
for i in range(4):
if i == discarding_player: continue
if game.can_gang(i, discarded_tile):
gang_players.append(i)
if gang_players.size() > 0:
var ganger = await _wait_player_response(gang_players, "gang")
if ganger != null:
game.execute_gang(ganger, discarded_tile)
return
# 第三步:检查碰
var peng_players = []
for i in range(4):
if i == discarding_player: continue
if game.can_peng(i, discarded_tile):
peng_players.append(i)
if peng_players.size() > 0:
var penger = await _wait_player_response(peng_players, "peng")
if penger != null:
game.execute_peng(penger, discarded_tile)
return
# 第四步:检查吃(只有下家能吃)
var next_player = (discarding_player + 1) % 4
if game.can_chi(next_player, discarded_tile):
var want_chi = await _ask_single_player(next_player, "chi")
if want_chi:
game.execute_chi(next_player, discarded_tile)
return
# 没人操作,下家摸牌
game.next_turn(next_player)
# 等待玩家响应
func _wait_player_response(players: Array, action_type: String) -> Variant:
# 给每个能操作的玩家发送询问
for player_id in players:
_send_action_request(player_id, action_type)
# 等待第一个响应(或超时)
var elapsed = 0.0
while elapsed < RESPONSE_TIMEOUT:
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
# 检查是否有玩家响应了
for player_id in players:
var result = _check_player_response(player_id, action_type)
if result != null:
return result
return null # 超时,视为放弃
func _ask_single_player(player_id: int, action_type: String) -> bool:
_send_action_request(player_id, action_type)
var elapsed = 0.0
while elapsed < RESPONSE_TIMEOUT:
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
var result = _check_player_response(player_id, action_type)
if result != null:
return result == true
return false
func _send_action_request(player_id: int, action_type: String) -> void:
print("询问玩家 %d 是否要 %s" % [player_id, action_type])
func _check_player_response(player_id: int, action_type: String) -> Variant:
# 检查该玩家是否已经回复
return null客户端牌桌渲染
牌的显示
麻将牌的渲染是客户端的核心 UI 工作。每张牌需要:
- 正面显示花色和数字
- 背面统一显示(其他玩家的手牌)
- 支持选中高亮、拖拽操作
C
// 麻将牌 UI 控件
using Godot;
public partial class TileControl : PanelContainer
{
private Label _label;
private bool _isSelected;
private MahjongTile _tile;
private bool _faceDown;
public override void _Ready()
{
_label = GetNode<Label>("Label");
MouseEntered += () => Modulate = new Color(1.2f, 1.2f, 1.2f);
MouseExited += () => { if (!_isSelected) Modulate = Colors.White; };
}
public void SetTile(MahjongTile tile, bool faceDown = false)
{
_tile = tile;
_faceDown = faceDown;
UpdateDisplay();
}
private void UpdateDisplay()
{
if (_faceDown)
{
_label.Text = "";
SelfModulate = new Color("4a90d9"); // 蓝色背面
}
else
{
_label.Text = _tile.DisplayName;
// 根据花色设置不同颜色
SelfModulate = _tile.Suit switch
{
TileSuit.Wan => new Color("ff6b6b"), // 万子红色
TileSuit.Tiao => new Color("51cf66"), // 条子绿色
TileSuit.Tong => new Color("339af0"), // 筒子蓝色
TileSuit.Feng => new Color("fcc419"), // 风牌黄色
TileSuit.Jian => new Color("cc5de8"), // 箭牌紫色
_ => Colors.White
};
}
}
public void ToggleSelect()
{
_isSelected = !_isSelected;
// 选中时向上偏移,表示要出这张牌
Position = new Vector2(Position.X, _isSelected ? -20 : 0);
Modulate = _isSelected ? new Color(1.3f, 1.3f, 0.8f) : Colors.White;
}
public void OnPressed()
{
ToggleSelect();
EmitSignal(SignalName.TileClicked, _tile);
}
[Signal] public delegate void TileClickedEventHandler(MahjongTile tile);
}GDScript
# 麻将牌 UI 控件
extends PanelContainer
var _label: Label
var _is_selected: bool = false
var _tile: MahjongTile
var _face_down: bool = false
func _ready():
_label = $Label
mouse_entered.connect(func(): modulate = Color(1.2, 1.2, 1.2))
mouse_exited.connect(func():
if not _is_selected: modulate = Color.WHITE
)
func set_tile(tile: MahjongTile, face_down: bool = false):
_tile = tile
_face_down = face_down
_update_display()
func _update_display():
if _face_down:
_label.text = ""
self_modulate = Color("4a90d9") # 蓝色背面
else:
_label.text = _tile.get_display_name()
# 根据花色设置不同颜色
match _tile.suit:
TileSuit.WAN:
self_modulate = Color("ff6b6b") # 万子红色
TileSuit.TIAO:
self_modulate = Color("51cf66") # 条子绿色
TileSuit.TONG:
self_modulate = Color("339af0") # 筒子蓝色
TileSuit.FENG:
self_modulate = Color("fcc419") # 风牌黄色
TileSuit.JIAN:
self_modulate = Color("cc5de8") # 箭牌紫色
_:
self_modulate = Color.WHITE
func toggle_select():
_is_selected = not _is_selected
# 选中时向上偏移,表示要出这张牌
position.y = -20 if _is_selected else 0
modulate = Color(1.3, 1.3, 0.8) if _is_selected else Color.WHITE
func _on_pressed():
toggle_select()
tile_clicked.emit(_tile)
signal tile_clicked(tile: MahjongTile)胡牌判定
胡牌判算是麻将规则引擎中最复杂的部分。基本思路是检查手牌是否能组成"N 个面子 + 1 个雀头"的形式。
- 面子:3 张相同的牌(刻子)或 3 张连续的同花色牌(顺子)
- 雀头:2 张相同的牌(对子)
新手建议
胡牌判定算法本身不涉及网络编程,是一个纯数学/逻辑问题。建议先在单机环境下把胡牌判定做正确,再集成到网络系统中。网上有大量开源的麻将胡牌算法可以参考。
不要自己从零写胡牌算法——它比你想象的复杂得多(还要考虑七对子、十三幺等特殊牌型)。
断线重连的特殊处理
麻将的断线重连比普通游戏更复杂,因为:
- 4 个人都缺一不可:少了一个人就没法打
- 状态恢复要完整:手牌、弃牌、吃碰杠的记录都要恢复
- 操作等待中的断线:如果某人在"是否要碰"的等待中断线了,需要有超时处理
服务器应该在每次操作后保存完整的房间状态快照,玩家重连时直接恢复到最新状态。
下一章
网络游戏的玩法部分讲完了。接下来我们回到"运营"层面——如何设计一个好玩的游戏活动系统。
