6. 胡牌判断
2026/4/14大约 10 分钟
长沙麻将——胡牌判断
什么是胡牌判断?
胡牌判断是麻将游戏中最复杂的算法——给定一组牌(通常是14张),判断它们是否可以分解成"若干个顺子/刻子 + 一个对子"。
你可以把它想象成"拆积木":给你一堆积木块,问能不能把它们刚好拼成几组"三个一排"加上一组"两个一排"。
基本胡型
胡牌条件
一局14张手牌要胡牌,必须满足:
14张 = N x (3张一组) + 1 x (2张一组)其中:
- 3张一组可以是顺子(同花色连续3张)或刻子(3张相同)
- 2张一组是将(对子)
递归判断算法
判断胡牌的核心思路是递归尝试:
- 先假设某张牌作为"将"(对子)
- 在剩余的牌中,依次尝试拆出顺子或刻子
- 如果所有的牌都被成功拆完,就说明可以胡牌
C
using Godot;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// 胡牌判断器
/// </summary>
public partial class WinChecker : Node
{
/// <summary>
/// 判断手牌是否可以胡牌
/// </summary>
/// <param name="hand">手牌(含刚摸到的牌,14张)</param>
/// <param name="melds">已有的副露</param>
/// <returns>是否可以胡牌</returns>
public bool CanWin(List<Tile> hand, List<Meld> melds)
{
if (hand.Count % 3 != 2)
{
// 手牌数不对(必须是3n+2张)
return false;
}
// 统计每种牌的数量
var counts = new int[27]; // 3花色 x 9数字 = 27种
foreach (var tile in hand)
{
counts[tile.Id]++;
}
// 尝试每种牌作为"将"
for (int i = 0; i < 27; i++)
{
if (counts[i] >= 2)
{
// 取出两张作为将
counts[i] -= 2;
// 检查剩余的牌是否能全部拆成顺子或刻子
if (CanDecompose(counts))
{
counts[i] += 2; // 还原
GD.Print("[WinChecker] 可以胡牌!");
return true;
}
counts[i] += 2; // 还原
}
}
return false;
}
/// <summary>
/// 递归检查剩余的牌是否能全部拆成顺子或刻子
/// </summary>
/// <param name="counts">每种牌的数量数组(长度27)</param>
/// <returns>是否能完全分解</returns>
private bool CanDecompose(int[] counts)
{
// 找到第一个数量不为0的牌
int first = -1;
for (int i = 0; i < 27; i++)
{
if (counts[i] > 0)
{
first = i;
break;
}
}
// 所有牌都拆完了
if (first == -1)
{
return true;
}
// 尝试1:作为刻子拆出(需要3张相同)
if (counts[first] >= 3)
{
counts[first] -= 3;
if (CanDecompose(counts))
{
counts[first] += 3;
return true;
}
counts[first] += 3;
}
// 尝试2:作为顺子的第一张拆出
// 需要同一花色,且 first+1 和 first+2 存在
int suit = first / 9;
int number = first % 9;
if (number <= 6 // 数字不超过7(才能有+1和+2)
&& first + 1 < 27 && (first + 1) / 9 == suit
&& first + 2 < 27 && (first + 2) / 9 == suit
&& counts[first + 1] > 0
&& counts[first + 2] > 0)
{
counts[first] -= 1;
counts[first + 1] -= 1;
counts[first + 2] -= 1;
if (CanDecompose(counts))
{
counts[first] += 1;
counts[first + 1] += 1;
counts[first + 2] += 1;
return true;
}
counts[first] += 1;
counts[first + 1] += 1;
counts[first + 2] += 1;
}
// 两种尝试都失败了
return false;
}
}GDScript
extends Node
## 判断手牌是否可以胡牌
## hand: 手牌(含刚摸到的牌,14张), melds: 已有的副露
func can_win(hand: Array, melds: Array) -> bool:
if hand.size() % 3 != 2:
# 手牌数不对(必须是3n+2张)
return false
# 统计每种牌的数量
var counts: Array = [] # 长度27
for i in range(27):
counts.append(0)
for tile in hand:
counts[tile.id] += 1
# 尝试每种牌作为"将"
for i in range(27):
if counts[i] >= 2:
# 取出两张作为将
counts[i] -= 2
# 检查剩余的牌是否能全部拆成顺子或刻子
if _can_decompose(counts):
counts[i] += 2 # 还原
print("[WinChecker] 可以胡牌!")
return true
counts[i] += 2 # 还原
return false
## 递归检查剩余的牌是否能全部拆成顺子或刻子
## counts: 每种牌的数量数组(长度27)
func _can_decompose(counts: Array) -> bool:
# 找到第一个数量不为0的牌
var first: int = -1
for i in range(27):
if counts[i] > 0:
first = i
break
# 所有牌都拆完了
if first == -1:
return true
# 尝试1:作为刻子拆出(需要3张相同)
if counts[first] >= 3:
counts[first] -= 3
if _can_decompose(counts):
counts[first] += 3
return true
counts[first] += 3
# 尝试2:作为顺子的第一张拆出
var suit: int = first / 9
var number: int = first % 9
if number <= 6 \
and first + 1 < 27 and (first + 1) / 9 == suit \
and first + 2 < 27 and (first + 2) / 9 == suit \
and counts[first + 1] > 0 \
and counts[first + 2] > 0:
counts[first] -= 1
counts[first + 1] -= 1
counts[first + 2] -= 1
if _can_decompose(counts):
counts[first] += 1
counts[first + 1] += 1
counts[first + 2] += 1
return true
counts[first] += 1
counts[first + 1] += 1
counts[first + 2] += 1
# 两种尝试都失败了
return false特殊牌型
长沙麻将除了基本胡型外,还有几种特殊牌型,每种都有额外的加分(番数)。
特殊牌型一览
| 牌型 | 条件 | 番数 | 说明 |
|---|---|---|---|
| 基本胡 | 标准胡型 | 1番 | 最基础的胡法 |
| 七小对 | 7个对子 | 2番 | 全部是对子,没有顺子和刻子 |
| 将将胡 | 所有顺子/刻子都包含2、5、8 | 2番 | 每组牌都有2/5/8 |
| 清一色 | 只有一种花色 | 3番 | 全是万或全是筒或全是条 |
| 自摸 | 自己摸到的牌胡 | 加底 | 分数翻倍 |
| 杠上花 | 杠后补摸的牌胡 | 2番 | 杠之后立刻自摸 |
| 杠上炮 | 杠后别人打出的牌胡 | 1番 | 杠之后别人点炮 |
| 扎鸟 | 胡牌后翻牌加分 | 额外 | 翻到的牌决定加番 |
七小对判断
C
/// <summary>
/// 判断是否为七小对
/// 七小对 = 7个对子 = 14张牌全是两两配对
/// </summary>
public bool IsSevenPairs(List<Tile> hand)
{
if (hand.Count != 14) return false;
var counts = new Dictionary<int, int>();
foreach (var tile in hand)
{
if (counts.ContainsKey(tile.Id))
counts[tile.Id]++;
else
counts[tile.Id] = 1;
}
// 每种牌的数量必须都是2(或4,4算两个对子)
int pairCount = 0;
foreach (var kvp in counts)
{
if (kvp.Value == 1 || kvp.Value == 3)
{
return false; // 有单张或三张,不是对子
}
pairCount += kvp.Value / 2;
}
return pairCount == 7;
}GDScript
## 判断是否为七小对
## 七小对 = 7个对子 = 14张牌全是两两配对
func is_seven_pairs(hand: Array) -> bool:
if hand.size() != 14:
return false
var counts: Dictionary = {}
for tile in hand:
if counts.has(tile.id):
counts[tile.id] += 1
else:
counts[tile.id] = 1
# 每种牌的数量必须都是2(或4,4算两个对子)
var pair_count: int = 0
for key in counts:
if counts[key] == 1 or counts[key] == 3:
return false # 有单张或三张,不是对子
pair_count += counts[key] / 2
return pair_count == 7清一色判断
C
/// <summary>
/// 判断是否为清一色
/// 清一色 = 所有牌(包括副露)都是同一种花色
/// </summary>
public bool IsPureSuit(int player, List<Tile> hand, List<Meld> melds)
{
// 获取第一张牌的花色作为参考
if (hand.Count == 0) return false;
TileSuit targetSuit = hand[0].Suit;
// 检查手牌
foreach (var tile in hand)
{
if (tile.Suit != targetSuit) return false;
}
// 检查副露
foreach (var meld in melds)
{
foreach (var tile in meld.Tiles)
{
if (tile.Suit != targetSuit) return false;
}
}
return true;
}GDScript
## 判断是否为清一色
## 清一色 = 所有牌(包括副露)都是同一种花色
func is_pure_suit(hand: Array, melds: Array) -> bool:
# 获取第一张牌的花色作为参考
if hand.is_empty():
return false
var target_suit: int = hand[0].suit
# 检查手牌
for tile in hand:
if tile.suit != target_suit:
return false
# 检查副露
for meld in melds:
for tile in meld.tiles:
if tile.suit != target_suit:
return false
return true扎鸟系统
扎鸟是长沙麻将的特色。胡牌后,从牌墙末尾翻开指定数量的牌:
| 胡牌方式 | 翻鸟数量 | 说明 |
|---|---|---|
| 点炮 | 1张 | 翻1张 |
| 自摸 | 2张 | 翻2张(更多加分机会) |
扎鸟规则
翻开的牌("鸟")与胡牌结果的关系:
| 翻到的牌 | 效果 |
|---|---|
| 与胡牌的花色相同 | 额外加1番 |
| 数字与胡牌者方位对应 | 额外加1番(庄家对应1/5/9等) |
C
/// <summary>
/// 扎鸟(翻鸟)
/// </summary>
/// <param name="winner">胡牌的玩家</param>
/// <param name="isSelfDraw">是否自摸</param>
/// <returns>翻到的鸟牌列表</returns>
public List<Tile> ZhaNiao(int winner, bool isSelfDraw)
{
int birdCount = isSelfDraw ? 2 : 1;
var birds = new List<Tile>();
GD.Print($"[GameManager] 扎鸟:翻{birdCount}张");
for (int i = 0; i < birdCount; i++)
{
if (_wall.Count > 0)
{
Tile bird = DrawFromWall();
birds.Add(bird);
GD.Print($"[GameManager] 翻到鸟: {bird}");
}
}
return birds;
}
/// <summary>
/// 计算扎鸟加番数
/// </summary>
/// <param name="birds">翻到的鸟牌</param>
/// <param name="winHand">胡牌者的手牌</param>
/// <param name="winner">胡牌者编号</param>
/// <returns>额外番数</returns>
public int CalculateBirdBonus(List<Tile> birds, List<Tile> winHand, int winner)
{
int bonus = 0;
foreach (var bird in birds)
{
// 规则1:鸟的花色与胡牌手牌中的花色相同
foreach (var tile in winHand)
{
if (tile.Suit == bird.Suit)
{
bonus++;
GD.Print($"[GameManager] 鸟 {bird} 花色匹配,+1番");
break;
}
}
// 规则2:鸟的数字对应玩家方位
// 简化版:1/5=庄家,2/6=下家,3/7=对家,4/8=上家,9=庄家
int birdPlayer = _birdNumberToPlayer(bird.Number);
if (birdPlayer == winner)
{
bonus++;
GD.Print($"[GameManager] 鸟 {bird} 方位匹配玩家{winner},+1番");
}
}
return bonus;
}
/// <summary>
/// 鸟的数字对应玩家编号
/// </summary>
private int _birdNumberToPlayer(int number)
{
return number switch
{
1 or 5 or 9 => 0, // 庄家
2 or 6 => 1, // 下家
3 or 7 => 2, // 对家
4 or 8 => 3, // 上家
_ => -1
};
}GDScript
## 扎鸟(翻鸟)
## winner: 胡牌玩家, is_self_draw: 是否自摸
func zha_niao(winner: int, is_self_draw: bool) -> Array:
var bird_count: int = 2 if is_self_draw else 1
var birds: Array = []
print("[GameManager] 扎鸟:翻%d张" % bird_count)
for i in range(bird_count):
if not wall.is_empty():
var bird: Tile = _draw_from_wall()
birds.append(bird)
print("[GameManager] 翻到鸟: %s" % bird)
return birds
## 计算扎鸟加番数
func calculate_bird_bonus(birds: Array, win_hand: Array, winner: int) -> int:
var bonus: int = 0
for bird in birds:
# 规则1:鸟的花色与胡牌手牌中的花色相同
for tile in win_hand:
if tile.suit == bird.suit:
bonus += 1
print("[GameManager] 鸟 %s 花色匹配,+1番" % bird)
break
# 规则2:鸟的数字对应玩家方位
var bird_player: int = _bird_number_to_player(bird.number)
if bird_player == winner:
bonus += 1
print("[GameManager] 鸟 %s 方位匹配玩家%d,+1番" % [bird, winner])
return bonus
## 鸟的数字对应玩家编号
func _bird_number_to_player(number: int) -> int:
match number:
1, 5, 9:
return 0 # 庄家
2, 6:
return 1 # 下家
3, 7:
return 2 # 对家
4, 8:
return 3 # 上家
return -1完整胡牌判断
综合所有条件,判断是否胡牌以及胡牌的番数:
C
/// <summary>
/// 完整的胡牌判断结果
/// </summary>
public class WinResult
{
/// <summary>是否胡牌</summary>
public bool IsWin { get; set; }
/// <summary>是否自摸</summary>
public bool IsSelfDraw { get; set; }
/// <summary>番数</summary>
public int Fan { get; set; } = 1;
/// <summary>牌型名称</summary>
public string PatternName { get; set; } = "基本胡";
/// <summary>扎鸟加番</summary>
public int BirdBonus { get; set; }
}
/// <summary>
/// 完整胡牌判断
/// </summary>
public WinResult CheckWin(List<Tile> hand, List<Meld> melds,
bool isSelfDraw, int player, List<Tile> birds)
{
var result = new WinResult { IsSelfDraw = isSelfDraw };
// 基本胡牌检查
bool basicWin = CanWin(hand, melds);
bool sevenPairs = IsSevenPairs(hand);
if (!basicWin && !sevenPairs)
{
result.IsWin = false;
return result;
}
result.IsWin = true;
// 计算番数
result.Fan = 1; // 基础1番
if (sevenPairs)
{
result.Fan += 1;
result.PatternName = "七小对";
}
// 清一色
if (IsPureSuit(player, hand, melds))
{
result.Fan += 2;
result.PatternName = "清一色";
}
// 自摸加底
if (isSelfDraw)
{
result.Fan *= 2;
result.PatternName += "(自摸)";
}
// 扎鸟加番
if (birds != null && birds.Count > 0)
{
result.BirdBonus = CalculateBirdBonus(birds, hand, player);
result.Fan += result.BirdBonus;
}
GD.Print($"[WinChecker] 胡牌结果: {result.PatternName}, " +
$"{result.Fan}番, 扎鸟+{result.BirdBonus}");
return result;
}GDScript
## 完整的胡牌判断结果
class WinResult:
var is_win: bool = false
var is_self_draw: bool = false
var fan: int = 1
var pattern_name: String = "基本胡"
var bird_bonus: int = 0
## 完整胡牌判断
func check_win(hand: Array, melds: Array,
is_self_draw: bool, player: int, birds: Array) -> WinResult:
var result := WinResult.new()
result.is_self_draw = is_self_draw
# 基本胡牌检查
var basic_win: bool = can_win(hand, melds)
var seven_pairs: bool = is_seven_pairs(hand)
if not basic_win and not seven_pairs:
result.is_win = false
return result
result.is_win = true
# 计算番数
result.fan = 1 # 基础1番
if seven_pairs:
result.fan += 1
result.pattern_name = "七小对"
# 清一色
if is_pure_suit(hand, melds):
result.fan += 2
result.pattern_name = "清一色"
# 自摸加底
if is_self_draw:
result.fan *= 2
result.pattern_name += "(自摸)"
# 扎鸟加番
if birds != null and birds.size() > 0:
result.bird_bonus = calculate_bird_bonus(birds, hand, player)
result.fan += result.bird_bonus
print("[WinChecker] 胡牌结果: %s, %d番, 扎鸟+%d" % [
result.pattern_name, result.fan, result.bird_bonus
])
return result本章小结
| 完成项 | 说明 |
|---|---|
| 基本胡判断 | 递归拆解为顺子/刻子 + 将 |
| 七小对 | 14张全是两两配对 |
| 清一色 | 所有牌同花色 |
| 番数计算 | 基础1番 + 特殊牌型加番 |
| 自摸加底 | 自摸时番数翻倍 |
| 扎鸟系统 | 胡牌后翻牌决定额外加番 |
下一章,我们将实现AI玩家——让电脑能够自动出牌、决定吃碰杠,并且有不同的难度等级。
