5. 吃碰杠
2026/4/14大约 9 分钟
长沙麻将——吃碰杠
什么是吃碰杠?
吃碰杠是麻将中最核心的互动机制。它不是"吃东西"的"吃",而是"拿别人的牌来组成牌组"的意思。
| 操作 | 俗称 | 一句话解释 | 比喻 |
|---|---|---|---|
| 吃 (Chi) | 拿上家打出的牌 + 手中两张 = 顺子 | 借邻居的砖来搭你的墙 | |
| 碰 (Peng) | 拿任意玩家打出的牌 + 手中两张相同 = 刻子 | 别人扔了一个你正好有的零件 | |
| 杠 (Gang) | 拿任意玩家打出的牌 + 手中三张相同 = 杠 | 别人扔了一个你收集了三个的 |
响应优先级
当一张牌被打出后,所有玩家同时检查是否可以响应。响应的优先级为:
胡 > 杠 > 碰 > 吃也就是说,如果有人能胡这张牌,其他人就不能碰或吃。
吃牌 (Chi)
吃牌规则
- 只能吃上家打出的牌
- 用这张牌加上手中的两张牌,组成一个顺子(同花色连续3张)
- 一张牌可能有多种吃法(比如打出"五万",可以用"三四万"吃,也可以用"六七万"吃)
吃牌检测
C
/// <summary>
/// 检查指定玩家是否能吃指定牌
/// </summary>
/// <param name="player">玩家编号</param>
/// <param name="tile">要吃的牌</param>
/// <returns>所有可能的吃法列表,每种吃法是一个包含3张牌的列表</returns>
public List<List<Tile>> GetChiOptions(int player, Tile tile)
{
var options = new List<List<Tile>>();
var hand = _hands[player];
// 吃牌只能用同一花色的牌组成顺子
// 找手中与 tile 同花色的牌
var sameSuit = new List<Tile>();
foreach (var t in hand)
{
if (t.Suit == tile.Suit)
{
sameSuit.Add(t);
}
}
// 统计每种数字有多少张
var counts = new Dictionary<int, int>();
foreach (var t in sameSuit)
{
if (counts.ContainsKey(t.Number))
counts[t.Number]++;
else
counts[t.Number] = 1;
}
int n = tile.Number;
// 情况1:用 tile 作为顺子的第一张 (tile, tile+1, tile+2)
// 需要手中有 tile+1 和 tile+2
if (n + 2 <= 9 && counts.ContainsKey(n + 1) && counts.ContainsKey(n + 2))
{
options.Add(new List<Tile>
{
tile,
new Tile(tile.Suit, n + 1),
new Tile(tile.Suit, n + 2)
});
}
// 情况2:用 tile 作为顺子的第二张 (tile-1, tile, tile+1)
// 需要手中有 tile-1 和 tile+1
if (n - 1 >= 1 && n + 1 <= 9
&& counts.ContainsKey(n - 1) && counts.ContainsKey(n + 1))
{
options.Add(new List<Tile>
{
new Tile(tile.Suit, n - 1),
tile,
new Tile(tile.Suit, n + 1)
});
}
// 情况3:用 tile 作为顺子的第三张 (tile-2, tile-1, tile)
// 需要手中有 tile-2 和 tile-1
if (n - 2 >= 1 && counts.ContainsKey(n - 2) && counts.ContainsKey(n - 1))
{
options.Add(new List<Tile>
{
new Tile(tile.Suit, n - 2),
new Tile(tile.Suit, n - 1),
tile
});
}
return options;
}
/// <summary>
/// 判断是否可以吃
/// </summary>
public bool CanChi(int player, Tile tile)
{
// 只有下家才能吃
int expectedPlayer = (LastDiscardPlayer + 1) % GameManager.PLAYER_COUNT;
if (player != expectedPlayer) return false;
return GetChiOptions(player, tile).Count > 0;
}GDScript
## 检查指定玩家是否能吃指定牌
## 返回所有可能的吃法列表
func get_chi_options(player: int, tile: Tile) -> Array:
var options: Array = [] # Array[Array[Tile]]
var hand: Array = hands[player]
# 找手中与 tile 同花色的牌
var same_suit: Array = []
for t in hand:
if t.suit == tile.suit:
same_suit.append(t)
# 统计每种数字有多少张
var counts: Dictionary = {}
for t in same_suit:
if counts.has(t.number):
counts[t.number] += 1
else:
counts[t.number] = 1
var n: int = tile.number
# 情况1:tile 作为顺子第一张 (tile, tile+1, tile+2)
if n + 2 <= 9 and counts.has(n + 1) and counts.has(n + 2):
options.append([
tile,
Tile.new(tile.suit, n + 1),
Tile.new(tile.suit, n + 2)
])
# 情况2:tile 作为顺子第二张 (tile-1, tile, tile+1)
if n - 1 >= 1 and n + 1 <= 9 \
and counts.has(n - 1) and counts.has(n + 1):
options.append([
Tile.new(tile.suit, n - 1),
tile,
Tile.new(tile.suit, n + 1)
])
# 情况3:tile 作为顺子第三张 (tile-2, tile-1, tile)
if n - 2 >= 1 and counts.has(n - 2) and counts.has(n - 1):
options.append([
Tile.new(tile.suit, n - 2),
Tile.new(tile.suit, n - 1),
tile
])
return options
## 判断是否可以吃
func _can_chi(player: int, tile: Tile) -> bool:
# 只有下家才能吃
var expected_player: int = (last_discard_player + 1) % GameManager.PLAYER_COUNT
if player != expected_player:
return false
return get_chi_options(player, tile).size() > 0碰牌 (Peng)
碰牌规则
- 任意玩家均可碰(不受位置限制)
- 手中有两张与打出牌相同的牌
- 碰后从手牌中移除这两张,组成刻子放在副露区
- 碰牌后需要打出一张牌
C
/// <summary>
/// 检查是否可以碰
/// </summary>
public bool CanPeng(int player, Tile tile)
{
if (player == LastDiscardPlayer) return false;
int count = 0;
foreach (var t in _hands[player])
{
if (t.Suit == tile.Suit && t.Number == tile.Number)
{
count++;
}
}
return count >= 2;
}
/// <summary>
/// 执行碰牌操作
/// </summary>
public async void ExecutePeng(int player, Tile tile)
{
GD.Print($"[GameManager] 玩家{player} 碰牌: {tile}");
// 从手牌中移除两张相同的牌
int removed = 0;
for (int i = _hands[player].Count - 1; i >= 0 && removed < 2; i--)
{
if (_hands[player][i].Suit == tile.Suit
&& _hands[player][i].Number == tile.Number)
{
_hands[player].RemoveAt(i);
removed++;
}
}
// 创建碰的副露
var meldTiles = new List<Tile> { tile, tile, tile };
var meld = new Meld(MeldType.Peng, meldTiles, LastDiscardPlayer);
_melds[player].Add(meld);
// 设置当前玩家为碰牌的玩家(他要出牌)
CurrentPlayer = player;
GD.Print($"[GameManager] 玩家{player} 副露: {meld}");
// 更新UI
EmitSignal(SignalName.StateChanged, MahjongGameState.MeldProcessing);
// 等待动画
await ToSignal(GetTree().CreateTimer(0.5), SceneTree.TimerSignalName.Timeout);
// 碰牌后需要出一张牌
SetState(MahjongGameState.WaitingForDiscard);
}GDScript
## 检查是否可以碰
func _can_peng(player: int, tile: Tile) -> bool:
if player == last_discard_player:
return false
var count: int = 0
for t in hands[player]:
if t.suit == tile.suit and t.number == tile.number:
count += 1
return count >= 2
## 执行碰牌操作
func _execute_peng(player: int, tile: Tile) -> void:
print("[GameManager] 玩家%d 碰牌: %s" % [player, tile])
# 从手牌中移除两张相同的牌
var removed: int = 0
var i: int = hands[player].size() - 1
while i >= 0 and removed < 2:
if hands[player][i].suit == tile.suit \
and hands[player][i].number == tile.number:
hands[player].remove_at(i)
removed += 1
i -= 1
# 创建碰的副露
var meld_tiles: Array = [tile, tile, tile]
var meld := Meld.new(MeldType.PENG, meld_tiles, last_discard_player)
melds[player].append(meld)
# 设置当前玩家为碰牌的玩家
current_player = player
print("[GameManager] 玩家%d 副露: %s" % [player, meld])
# 碰牌后需要出一张牌
_set_state(MahjongGameState.WAITING_FOR_DISCARD)杠牌 (Gang)
长沙麻将中杠分为三种:
| 杠类型 | 说明 | 来源 |
|---|---|---|
| 明杠 | 手中有3张相同牌 + 别人打出第4张 | 别人出牌 |
| 暗杠 | 手中4张相同的牌,自己选择杠 | 自己手牌 |
| 补杠 | 已经碰了的刻子,又摸到第4张 | 自己摸牌 |
明杠
C
/// <summary>
/// 检查是否可以明杠
/// </summary>
public bool CanGang(int player, Tile tile)
{
if (player == LastDiscardPlayer) return false;
int count = 0;
foreach (var t in _hands[player])
{
if (t.Suit == tile.Suit && t.Number == tile.Number)
{
count++;
}
}
return count >= 3;
}
/// <summary>
/// 执行明杠操作
/// </summary>
public void ExecuteMingGang(int player, Tile tile)
{
GD.Print($"[GameManager] 玩家{player} 明杠: {tile}");
// 从手牌中移除三张相同的牌
int removed = 0;
for (int i = _hands[player].Count - 1; i >= 0 && removed < 3; i--)
{
if (_hands[player][i].Suit == tile.Suit
&& _hands[player][i].Number == tile.Number)
{
_hands[player].RemoveAt(i);
removed++;
}
}
// 创建明杠的副露
var meldTiles = new List<Tile> { tile, tile, tile, tile };
var meld = new Meld(MeldType.MingGang, meldTiles, LastDiscardPlayer);
_melds[player].Add(meld);
// 杠后需要补摸一张牌(杠上花)
CurrentPlayer = player;
DrawTile(); // 杠后补牌
}GDScript
## 检查是否可以明杠
func _can_gang(player: int, tile: Tile) -> bool:
if player == last_discard_player:
return false
var count: int = 0
for t in hands[player]:
if t.suit == tile.suit and t.number == tile.number:
count += 1
return count >= 3
## 执行明杠操作
func _execute_ming_gang(player: int, tile: Tile) -> void:
print("[GameManager] 玩家%d 明杠: %s" % [player, tile])
# 从手牌中移除三张相同的牌
var removed: int = 0
var i: int = hands[player].size() - 1
while i >= 0 and removed < 3:
if hands[player][i].suit == tile.suit \
and hands[player][i].number == tile.number:
hands[player].remove_at(i)
removed += 1
i -= 1
# 创建明杠的副露
var meld_tiles: Array = [tile, tile, tile, tile]
var meld := Meld.new(MeldType.MING_GANG, meld_tiles, last_discard_player)
melds[player].append(meld)
# 杠后补摸一张牌
current_player = player
draw_tile() # 杠上花暗杠
C
/// <summary>
/// 检查玩家是否可以暗杠
/// </summary>
/// <returns>可以暗杠的牌的列表(每种返回一个)</returns>
public List<Tile> GetAnGangOptions(int player)
{
var options = new List<Tile>();
var counts = HandSorter.CountTiles(_hands[player]);
foreach (var kvp in counts)
{
if (kvp.Value >= 4)
{
// 有4张相同的牌,可以暗杠
int suit = kvp.Key / 9;
int number = kvp.Key % 9 + 1;
options.Add(new Tile((TileSuit)suit, number));
}
}
return options;
}
/// <summary>
/// 执行暗杠操作
/// </summary>
public void ExecuteAnGang(int player, Tile tile)
{
GD.Print($"[GameManager] 玩家{player} 暗杠: {tile}");
// 从手牌中移除4张相同的牌
int removed = 0;
for (int i = _hands[player].Count - 1; i >= 0 && removed < 4; i--)
{
if (_hands[player][i].Suit == tile.Suit
&& _hands[player][i].Number == tile.Number)
{
_hands[player].RemoveAt(i);
removed++;
}
}
// 创建暗杠的副露
var meldTiles = new List<Tile> { tile, tile, tile, tile };
var meld = new Meld(MeldType.AnGang, meldTiles, player);
_melds[player].Add(meld);
// 暗杠后补摸一张牌
DrawTile();
}GDScript
## 检查玩家是否可以暗杠
## 返回可以暗杠的牌的列表
func _get_an_gang_options(player: int) -> Array:
var options: Array = []
var counts: Dictionary = HandSorter.count_tiles(hands[player])
for key in counts:
if counts[key] >= 4:
var suit: int = key / 9
var number: int = key % 9 + 1
options.append(Tile.new(suit, number))
return options
## 执行暗杠操作
func _execute_an_gang(player: int, tile: Tile) -> void:
print("[GameManager] 玩家%d 暗杠: %s" % [player, tile])
# 从手牌中移除4张相同的牌
var removed: int = 0
var i: int = hands[player].size() - 1
while i >= 0 and removed < 4:
if hands[player][i].suit == tile.suit \
and hands[player][i].number == tile.number:
hands[player].remove_at(i)
removed += 1
i -= 1
# 创建暗杠的副露
var meld_tiles: Array = [tile, tile, tile, tile]
var meld := Meld.new(MeldType.AN_GANG, meld_tiles, player)
melds[player].append(meld)
# 暗杠后补摸一张牌
draw_tile()操作按钮面板
当有可用的吃碰杠操作时,需要在界面上显示对应的按钮,让玩家选择。
C
using Godot;
/// <summary>
/// 操作按钮面板
/// 根据当前情况显示可用的操作按钮
/// </summary>
public partial class ActionPanel : Control
{
[Export] public Button ChiButton { get; set; }
[Export] public Button PengButton { get; set; }
[Export] public Button GangButton { get; set; }
[Export] public Button HuButton { get; set; }
[Export] public Button PassButton { get; set; }
// 信号
[Signal] public delegate void ChiSelectedEventHandler();
[Signal] public delegate void PengSelectedEventHandler();
[Signal] public delegate void GangSelectedEventHandler();
[Signal] public delegate void HuSelectedEventHandler();
[Signal] public delegate void PassSelectedEventHandler();
public override void _Ready()
{
Visible = false;
ChiButton.Pressed += () => { Hide(); EmitSignal(SignalName.ChiSelected); };
PengButton.Pressed += () => { Hide(); EmitSignal(SignalName.PengSelected); };
GangButton.Pressed += () => { Hide(); EmitSignal(SignalName.GangSelected); };
HuButton.Pressed += () => { Hide(); EmitSignal(SignalName.HuSelected); };
PassButton.Pressed += () => { Hide(); EmitSignal(SignalName.PassSelected); };
}
/// <summary>
/// 显示可用的操作按钮
/// </summary>
public void ShowActions(bool canChi, bool canPeng, bool canGang, bool canHu)
{
ChiButton.Visible = canChi;
PengButton.Visible = canPeng;
GangButton.Visible = canGang;
HuButton.Visible = canHu;
PassButton.Visible = canChi || canPeng || canGang; // 有操作才能"过"
Visible = true;
}
/// <summary>
/// 隐藏所有按钮
/// </summary>
public new void Hide()
{
Visible = false;
}
}GDScript
extends Control
@export var chi_button: Button
@export var peng_button: Button
@export var gang_button: Button
@export var hu_button: Button
@export var pass_button: Button
## 信号
signal chi_selected()
signal peng_selected()
signal gang_selected()
signal hu_selected()
signal pass_selected()
func _ready() -> void:
visible = false
chi_button.pressed.connect(func(): hide_panel(); chi_selected.emit())
peng_button.pressed.connect(func(): hide_panel(); peng_selected.emit())
gang_button.pressed.connect(func(): hide_panel(); gang_selected.emit())
hu_button.pressed.connect(func(): hide_panel(); hu_selected.emit())
pass_button.pressed.connect(func(): hide_panel(); pass_selected.emit())
## 显示可用的操作按钮
func show_actions(can_chi: bool, can_peng: bool, can_gang: bool, can_hu: bool) -> void:
chi_button.visible = can_chi
peng_button.visible = can_peng
gang_button.visible = can_gang
hu_button.visible = can_hu
pass_button.visible = can_chi or can_peng or can_gang
visible = true
## 隐藏所有按钮
func hide_panel() -> void:
visible = false本章小结
| 完成项 | 说明 |
|---|---|
| 吃牌检测 | 检查三种顺子组合方式 |
| 碰牌检测 | 检查手中有2张相同牌 |
| 明杠 | 手中3张 + 别人打出1张 |
| 暗杠 | 手中4张相同牌 |
| 补杠 | 已碰的刻子 + 摸到第4张 |
| 优先级 | 胡 > 杠 > 碰 > 吃 |
| 操作面板 | 动态显示可用按钮 |
下一章,我们将实现最核心也最复杂的胡牌判断——检查手牌是否满足胡牌条件,包括基本胡型、七小对、将将胡、清一色等特殊牌型。
