4. 发牌与摸牌
2026/4/14大约 7 分钟
长沙麻将——发牌与摸牌
发牌与摸牌的流程
发牌和摸牌是麻将每局游戏的第一步。你可以把它想象成"发扑克牌"——先把所有牌洗混,然后一张一张发给每个玩家。
发牌规则
| 玩家 | 发牌数 | 说明 |
|---|---|---|
| 庄家(东) | 14张 | 庄家多一张,先出牌 |
| 其他三家 | 各13张 | 然后依次摸牌出牌 |
总共发出:14 + 13 x 3 = 53张
剩余牌墙:108 - 53 = 55张(之后每轮摸一张)
洗牌算法
洗牌就是把牌的顺序完全打乱。我们使用经典的 Fisher-Yates 洗牌算法——从最后一张牌开始,随机选一张和它交换,然后处理倒数第二张,依此类推。
这个算法就像你洗牌的动作:拿起一叠牌,从中间随机抽出一张放到最上面,重复很多次。
C
using System;
/// <summary>
/// 洗牌(Fisher-Yates算法)
/// </summary>
private void ShuffleWall()
{
// 先用 TileManager 生成108张牌
var tileManager = GetNode<TileManager>("/root/Main/TileManager");
_wall = tileManager.GenerateFullDeck();
// Fisher-Yates 洗牌
var rng = new Random();
for (int i = _wall.Count - 1; i > 0; i--)
{
int j = rng.Next(i + 1);
// 交换位置 i 和位置 j 的牌
(_wall[i], _wall[j]) = (_wall[j], _wall[i]);
}
GD.Print($"[GameManager] 洗牌完成,牌墙剩余 {_wall.Count} 张");
}
/// <summary>
/// 从牌墙摸一张牌
/// </summary>
/// <returns>摸到的牌</returns>
private Tile DrawFromWall()
{
if (_wall.Count == 0)
{
GD.PrintErr("[GameManager] 牌墙已空!");
return default;
}
// 从牌墙末尾取牌
Tile tile = _wall[_wall.Count - 1];
_wall.RemoveAt(_wall.Count - 1);
return tile;
}GDScript
## 洗牌(Fisher-Yates算法)
func _shuffle_wall() -> void:
# 先用 TileManager 生成108张牌
var tile_manager = get_node("/root/Main/TileManager")
wall = tile_manager.generate_full_deck()
# Fisher-Yates 洗牌
var rng := RandomNumberGenerator.new()
rng.randomize()
for i in range(wall.size() - 1, 0, -1):
var j: int = rng.randi_range(0, i)
# 交换位置 i 和位置 j 的牌
var temp = wall[i]
wall[i] = wall[j]
wall[j] = temp
print("[GameManager] 洗牌完成,牌墙剩余 %d 张" % wall.size())
## 从牌墙摸一张牌
func _draw_from_wall() -> Tile:
if wall.is_empty():
push_error("[GameManager] 牌墙已空!")
return null
# 从牌墙末尾取牌
var tile: Tile = wall[wall.size() - 1]
wall.pop_back()
return tile发牌
发牌按顺序给每个玩家发牌:庄家14张,其余13张。
C
/// <summary>
/// 发牌
/// 庄家14张,其余三家各13张
/// </summary>
private void DealTiles()
{
GD.Print("[GameManager] 开始发牌...");
// 依次给每个玩家发牌
for (int i = 0; i < GameManager.INITIAL_HAND_SIZE; i++)
{
for (int player = 0; player < GameManager.PLAYER_COUNT; player++)
{
Tile tile = DrawFromWall();
_hands[player].Add(tile);
}
}
// 庄家多发一张
Tile extraTile = DrawFromWall();
_hands[Dealer].Add(extraTile);
// 排序每个玩家的手牌
for (int player = 0; player < GameManager.PLAYER_COUNT; player++)
{
HandSorter.Sort(_hands[player]);
GD.Print($"[GameManager] 玩家{player}手牌: {_hands[player].Count}张");
}
GD.Print($"[GameManager] 发牌完成,牌墙剩余 {_wall.Count} 张");
GD.Print($"[GameManager] 庄家(玩家{Dealer})先出牌");
}GDScript
## 发牌
## 庄家14张,其余三家各13张
func _deal_tiles() -> void:
print("[GameManager] 开始发牌...")
# 依次给每个玩家发牌
for i in range(GameManager.INITIAL_HAND_SIZE):
for player in range(GameManager.PLAYER_COUNT):
var tile: Tile = _draw_from_wall()
hands[player].append(tile)
# 庄家多发一张
var extra_tile: Tile = _draw_from_wall()
hands[dealer].append(extra_tile)
# 排序每个玩家的手牌
for player in range(GameManager.PLAYER_COUNT):
HandSorter.sort(hands[player])
print("[GameManager] 玩家%d手牌: %d张" % [player, hands[player].size()])
print("[GameManager] 发牌完成,牌墙剩余 %d 张" % wall.size())
print("[GameManager] 庄家(玩家%d)先出牌" % dealer)摸牌
每个玩家的回合开始时,从牌墙摸一张牌,然后检查是否可以胡牌。
C
/// <summary>
/// 当前玩家摸牌
/// </summary>
public async void DrawTile()
{
// 检查牌墙是否为空
if (_wall.Count == 0)
{
GD.Print("[GameManager] 牌墙已空,流局!");
SetState(MahjongGameState.Draw);
EmitSignal(SignalName.GameDraw);
return;
}
SetState(MahjongGameState.Drawing);
// 从牌墙摸一张牌
Tile drawnTile = DrawFromWall();
_hands[CurrentPlayer].Add(drawnTile);
GD.Print($"[GameManager] 玩家{CurrentPlayer} 摸牌: {drawnTile}");
// 发出摸牌信号
EmitSignal(SignalName.TileDrawn, CurrentPlayer, drawnTile);
// 排序手牌
HandSorter.Sort(_hands[CurrentPlayer]);
// 短暂延迟,让玩家看到摸到的牌
await ToSignal(GetTree().CreateTimer(0.3), SceneTree.TimerSignalName.Timeout);
// 检查是否自摸胡牌
var winChecker = GetNode<WinChecker>("/root/Main/WinChecker");
if (winChecker.CanWin(_hands[CurrentPlayer], _melds[CurrentPlayer]))
{
GD.Print($"[GameManager] 玩家{CurrentPlayer} 自摸胡牌!");
SetState(MahjongGameState.Scoring);
EmitSignal(SignalName.PlayerWin, CurrentPlayer, true);
return;
}
// 检查是否可以暗杠或补杠
// (这部分在第5章"吃碰杠"中详细实现)
// 进入出牌阶段
SetState(MahjongGameState.WaitingForDiscard);
}GDScript
## 当前玩家摸牌
func draw_tile() -> void:
# 检查牌墙是否为空
if wall.is_empty():
print("[GameManager] 牌墙已空,流局!")
_set_state(MahjongGameState.DRAW_GAME)
game_draw.emit()
return
_set_state(MahjongGameState.DRAWING)
# 从牌墙摸一张牌
var drawn_tile: Tile = _draw_from_wall()
hands[current_player].append(drawn_tile)
print("[GameManager] 玩家%d 摸牌: %s" % [current_player, drawn_tile])
# 发出摸牌信号
tile_drawn.emit(current_player, drawn_tile)
# 排序手牌
HandSorter.sort(hands[current_player])
# 短暂延迟,让玩家看到摸到的牌
await get_tree().create_timer(0.3).timeout
# 检查是否自摸胡牌
var win_checker = get_node("/root/Main/WinChecker")
if win_checker.can_win(hands[current_player], melds[current_player]):
print("[GameManager] 玩家%d 自摸胡牌!" % current_player)
_set_state(MahjongGameState.SCORING)
player_win.emit(current_player, true)
return
# 进入出牌阶段
_set_state(MahjongGameState.WAITING_FOR_DISCARD)出牌
玩家从手牌中选择一张打出。如果是人类玩家,需要等待点击选择;如果是AI玩家,自动选择。
C
/// <summary>
/// 当前玩家出牌
/// </summary>
/// <param name="tileIndex">要打出的牌在手牌中的索引</param>
public void DiscardTile(int tileIndex)
{
if (CurrentState != MahjongGameState.WaitingForDiscard)
{
GD.PrintWarn("[GameManager] 当前不在出牌阶段!");
return;
}
// 从手牌中移除
if (tileIndex < 0 || tileIndex >= _hands[CurrentPlayer].Count)
{
GD.PrintErr($"[GameManager] 无效的出牌索引: {tileIndex}");
return;
}
Tile discardedTile = _hands[CurrentPlayer][tileIndex];
_hands[CurrentPlayer].RemoveAt(tileIndex);
// 记录最后打出的牌
LastDiscard = discardedTile;
LastDiscardPlayer = CurrentPlayer;
GD.Print($"[GameManager] 玩家{CurrentPlayer} 出牌: {discardedTile}");
// 发出出牌信号
EmitSignal(SignalName.TileDiscarded, CurrentPlayer, discardedTile);
// 进入响应阶段(检查其他玩家是否要吃/碰/杠/胡)
SetState(MahjongGameState.WaitingForResponse);
}
/// <summary>
/// 出牌后,检查其他玩家的响应
/// </summary>
public async void CheckResponses()
{
// 优先级:胡 > 碰/杠 > 吃
// 1. 检查是否有人能胡(点炮)
for (int player = 0; player < GameManager.PLAYER_COUNT; player++)
{
if (player == CurrentPlayer) continue;
var winChecker = GetNode<WinChecker>("/root/Main/WinChecker");
// 临时将打出的牌加入手牌,检查是否胡
var testHand = new List<Tile>(_hands[player]);
testHand.Add(LastDiscard);
if (winChecker.CanWin(testHand, _melds[player]))
{
// 有人可以胡牌
await HandleWin(player, false);
return;
}
}
// 2. 检查是否有人能碰/杠
for (int player = 0; player < GameManager.PLAYER_COUNT; player++)
{
if (player == CurrentPlayer) continue;
if (CanPeng(player, LastDiscard) || CanGang(player, LastDiscard))
{
// 有人可以碰/杠(AI自动决定,人类玩家弹窗选择)
await HandlePengGang(player);
return;
}
}
// 3. 检查下家是否能吃
int nextPlayer = (CurrentPlayer + 1) % GameManager.PLAYER_COUNT;
if (CanChi(nextPlayer, LastDiscard))
{
await HandleChi(nextPlayer);
return;
}
// 没有人响应,下一个玩家摸牌
NextTurn();
}
/// <summary>
/// 切换到下一个玩家
/// </summary>
private void NextTurn()
{
CurrentPlayer = (CurrentPlayer + 1) % GameManager.PLAYER_COUNT;
GD.Print($"[GameManager] 轮到玩家{CurrentPlayer}");
// 下一个玩家摸牌
DrawTile();
}GDScript
## 当前玩家出牌
## tile_index: 要打出的牌在手牌中的索引
func discard_tile(tile_index: int) -> void:
if current_state != MahjongGameState.WAITING_FOR_DISCARD:
push_warning("[GameManager] 当前不在出牌阶段!")
return
# 从手牌中移除
if tile_index < 0 or tile_index >= hands[current_player].size():
push_error("[GameManager] 无效的出牌索引: %d" % tile_index)
return
var discarded_tile: Tile = hands[current_player][tile_index]
hands[current_player].remove_at(tile_index)
# 记录最后打出的牌
last_discard = discarded_tile
last_discard_player = current_player
print("[GameManager] 玩家%d 出牌: %s" % [current_player, discarded_tile])
# 发出出牌信号
tile_discarded.emit(current_player, discarded_tile)
# 进入响应阶段
_set_state(MahjongGameState.WAITING_FOR_RESPONSE)
## 出牌后,检查其他玩家的响应
func _check_responses() -> void:
# 优先级:胡 > 碰/杠 > 吃
# 1. 检查是否有人能胡(点炮)
var win_checker = get_node("/root/Main/WinChecker")
for player in range(GameManager.PLAYER_COUNT):
if player == current_player:
continue
var test_hand: Array = hands[player].duplicate()
test_hand.append(last_discard)
if win_checker.can_win(test_hand, melds[player]):
await _handle_win(player, false)
return
# 2. 检查是否有人能碰/杠
for player in range(GameManager.PLAYER_COUNT):
if player == current_player:
continue
if _can_peng(player, last_discard) or _can_gang(player, last_discard):
await _handle_peng_gang(player)
return
# 3. 检查下家是否能吃
var next_player: int = (current_player + 1) % GameManager.PLAYER_COUNT
if _can_chi(next_player, last_discard):
await _handle_chi(next_player)
return
# 没有人响应,下一个玩家摸牌
_next_turn()
## 切换到下一个玩家
func _next_turn() -> void:
current_player = (current_player + 1) % GameManager.PLAYER_COUNT
print("[GameManager] 轮到玩家%d" % current_player)
# 下一个玩家摸牌
draw_tile()流局判断
当牌墙被摸完时,如果还没有人胡牌,就是"流局"——这局没有人赢,大家都白玩了。
C
/// <summary>
/// 检查是否流局
/// 流局条件:牌墙剩余牌数不足(通常剩余0张或指定数量)
/// </summary>
public bool IsDraw()
{
// 长沙麻将通常在牌墙剩一定数量时流局
// 最简单的情况是牌墙摸完就流局
return _wall.Count <= 0;
}
/// <summary>
/// 处理流局
/// </summary>
public void HandleDraw()
{
GD.Print("[GameManager] 流局!");
// 显示流局提示
SetState(MahjongGameState.Draw);
EmitSignal(SignalName.GameDraw);
// 可以在这里显示流局弹窗
// GD.Print("所有玩家的手牌:");
// for (int i = 0; i < PLAYER_COUNT; i++)
// {
// GD.Print($" 玩家{i}: {string.Join(", ", _hands[i])}");
// }
}GDScript
## 检查是否流局
## 流局条件:牌墙剩余牌数不足
func is_draw() -> bool:
return wall.is_empty()
## 处理流局
func handle_draw() -> void:
print("[GameManager] 流局!")
# 显示流局提示
_set_state(MahjongGameState.DRAW_GAME)
game_draw.emit()本章小结
| 完成项 | 说明 |
|---|---|
| 洗牌 | Fisher-Yates 算法,108张牌完全随机 |
| 发牌 | 庄家14张,其余13张,按顺序发 |
| 摸牌 | 从牌墙末尾取牌,检查自摸 |
| 出牌 | 从手牌中移除一张,进入响应阶段 |
| 响应检查 | 优先级:胡 > 碰杠 > 吃 |
| 流局 | 牌墙摸完无人胡牌 |
下一章,我们将实现吃碰杠系统——这是麻将中最有策略深度的部分,也是区分高手和新手的关键。
