5. 字牌(跑胡子)规则
2026/4/13大约 12 分钟
字牌(跑胡子)规则
本章我们来实现湖南地区最流行的字牌游戏——跑胡子的规则引擎。字牌和麻将虽然都是"凑组合"的游戏,但规则差异很大,需要单独设计一套规则系统。
生活化比喻
如果麻将是"交响乐"(规则复杂、乐器多、演奏时间长),那字牌就是"民谣吉他"(规则简单、节奏明快、上手容易)。两者各有各的乐趣。
字牌基本规则
牌的构成
一副字牌共 80张,比麻将少很多,但别小看它——规则简单不代表没有策略深度。
| 牌类 | 内容 | 数量 | 颜色 |
|---|---|---|---|
| 大字 | 壹贰叁肆伍陆柒捌玖拾 | 每种2张,共20张 | 红色 |
| 小字 | 一二三四五六七八九十 | 每种2张,共20张 | 黑色 |
| 大写 | 壹贰叁肆伍陆柒捌玖拾 | 每种2张,共20张 | 红色 |
| 小写 | 一二三四五六七八九十 | 每种2张,共20张 | 黑色 |
相关信息
字牌中"大字"和"小字"的区别在于大小写(壹 vs 一)和颜色(红 vs 黑)。虽然看起来只有数字不同,但计分时红黑有差异——红字得分更高。
牌的数据结构
C
/// <summary>
/// 字牌大小
/// </summary>
public enum ZiSize
{
Big = 0, // 大字(壹贰叁...)
Small = 1 // 小字(一二三...)
}
/// <summary>
/// 字牌花色
/// </summary>
public enum ZiSuit
{
DaZi = 0, // 大字(红色大写)
XiaoZi = 1 // 小字(黑色小写)
}
/// <summary>
/// 单张字牌
/// </summary>
public class ZiTile
{
/// <summary>花色(大字/小字)</summary>
public ZiSuit Suit { get; }
/// <summary>数值(1-10)</summary>
public int Value { get; }
/// <summary>唯一ID</summary>
public int TileId { get; }
/// <summary>
/// 是否为大字(红色)
/// </summary>
public bool IsBig => Suit == ZiSuit.DaZi;
/// <summary>
/// 是否为红色牌
/// </summary>
public bool IsRed => IsBig;
/// <summary>
/// 基础分值
/// </summary>
public int BaseScore => IsBig ? 2 : 1;
/// <summary>
/// 获取显示名称
/// </summary>
public string DisplayName
{
get
{
string bigChars = "壹贰叁肆伍陆柒捌玖拾";
string smallChars = "一二三四五六七八九十";
string ch = IsBig ? bigChars[Value - 1].ToString()
: smallChars[Value - 1].ToString();
string prefix = IsBig ? "大" : "小";
return $"{prefix}{ch}";
}
}
/// <summary>
/// 判断两张牌是否"相同"(花色和数值一样)
/// </summary>
public bool IsSameAs(ZiTile other)
{
return Suit == other.Suit && Value == other.Value;
}
/// <summary>
/// 判断两张牌是否"等值"(数值相同,不管大小)
/// </summary>
public bool IsEqualValue(ZiTile other)
{
return Value == other.Value;
}
public ZiTile(ZiSuit suit, int value, int tileId)
{
Suit = suit;
Value = value;
TileId = tileId;
}
public override string ToString() => $"[{TileId}]{DisplayName}";
}GDScript
## 字牌花色
enum ZiSuit {
DA_ZI = 0, ## 大字(红色)
XIAO_ZI = 1 ## 小字(黑色)
}
## 单张字牌
class_name ZiTile
## 花色
var suit: int ## ZiSuit 枚举值
## 数值(1-10)
var value: int
## 唯一ID
var tile_id: int
## 是否为大字
func is_big() -> bool:
return suit == ZiSuit.DA_ZI
## 是否为红色
func is_red() -> bool:
return is_big()
## 基础分值
func base_score() -> int:
return 2 if is_big() else 1
## 获取显示名称
func display_name() -> String:
var big_chars := "壹贰叁肆伍陆柒捌玖拾"
var small_chars := "一二三四五六七八九十"
var ch := big_chars[value - 1] if is_big() else small_chars[value - 1]
var prefix := "大" if is_big() else "小"
return "%s%s" % [prefix, ch]
## 判断是否相同
func is_same_as(other: ZiTile) -> bool:
return suit == other.suit and value == other.value
## 判断是否等值
func is_equal_value(other: ZiTile) -> bool:
return value == other.value
func _to_string() -> String:
return "[%d]%s" % [tile_id, display_name()]牌型组合
五种操作
字牌的核心操作比麻将更简洁,只有五种:
| 操作 | 说明 | 示例 |
|---|---|---|
| 偎 | 手中2张 + 摸到1张同牌 | 手中有 大壹大壹,摸到 大壹 → 偎 |
| 提 | 已偎的3张 + 摸到第4张同牌 | 已偎 大壹×3,摸到 大壹 → 提 |
| 碰 | 手中2张 + 他人打出1张同牌 | 手中有 小二小二,别人打 小二 → 碰 |
| 吃 | 手中2张 + 上家打出1张组成顺子 | 手中有 大壹大贰,上家打 大叁 → 吃 |
| 跑 | 手中3张(已偎)+ 他人打出第4张同牌 | 已偔 大壹×3,别人打 大壹 → 跑 |
偎 vs 碰的区别
偎和碰都是三张相同的牌,但来源不同:
- 偎:自己摸到的第三张(从牌堆摸的)
- 碰:别人打出的第三张(从桌面拿的)
这个区别影响计分——通常偎的分数比碰高。
特殊组合:二七十
字牌中有一个非常经典的特殊组合——"二七十":
| 组合名 | 内容 | 规则 |
|---|---|---|
| 二七十 | 贰(或二)+ 柒(或七)+ 拾(或十) | 大小可以混用 |
相关信息
"二七十"是字牌中最具特色的规则之一。它允许大小字混用(比如 大贰+小柒+大拾 也可以),这使得组合更加灵活,策略性更强。
组合数据结构
C
/// <summary>
/// 字牌操作类型
/// </summary>
public enum ZiActionType
{
Wei, // 偎
Ti, // 提
Peng, // 碰
Chi, // 吃
Pao // 跑
}
/// <summary>
/// 字牌组合
/// </summary>
public class ZiMeld
{
/// <summary>操作类型</summary>
public ZiActionType ActionType { get; }
/// <summary>组合中的牌</summary>
public List<ZiTile> Tiles { get; }
/// <summary>分值</summary>
public int Score { get; }
public ZiMeld(ZiActionType actionType, List<ZiTile> tiles)
{
ActionType = actionType;
Tiles = tiles;
Score = CalculateScore();
}
/// <summary>
/// 计算这个组合的分值
/// </summary>
private int CalculateScore()
{
// 基础分 = 所有牌的基础分之和
int base = Tiles.Sum(t => t.BaseScore);
// 操作加成
int multiplier = ActionType switch
{
ZiActionType.Wei => 2, // 偎:2倍
ZiActionType.Ti => 4, // 提:4倍
ZiActionType.Peng => 1, // 碰:1倍
ZiActionType.Chi => 1, // 吃:1倍
ZiActionType.Pao => 4, // 跑:4倍
_ => 1
};
return base * multiplier;
}
public override string ToString()
{
var tilesStr = string.Join("+", Tiles.Select(t => t.DisplayName));
return $"{ActionType}: {tilesStr} = {Score}分";
}
}GDScript
## 字牌操作类型
enum ZiActionType {
WEI, ## 偎
TI, ## 提
PENG, ## 碰
CHI, ## 吃
PAO ## 跑
}
## 字牌组合
class_name ZiMeld
## 操作类型
var action_type: int ## ZiActionType 枚举值
## 组合中的牌
var tiles: Array[ZiTile]
## 分值
var score: int
func _init(action: int, meld_tiles: Array[ZiTile]) -> void:
action_type = action
tiles = meld_tiles
score = calculate_score()
## 计算分值
func calculate_score() -> int:
var base_score := 0
for t in tiles:
base_score += t.base_score()
var multiplier := match action_type:
ZiActionType.WEI: 2
ZiActionType.TI: 4
ZiActionType.PENG: 1
ZiActionType.CHI: 1
ZiActionType.PAO: 4
_: 1
return base_score * multiplier
func _to_string() -> String:
var names := []
for t in tiles:
names.append(t.display_name())
return "%s: %s = %d分" % [action_type, "+".join(names), score]吃牌规则详解
吃牌的组合方式
字牌的吃牌比麻将灵活——不仅可以吃连续的牌,还可以吃"二七十"这个特殊组合:
| 吃法 | 示例 | 说明 |
|---|---|---|
| 连续吃 | 大壹+大贰+大叁 | 三张连续的大字(或小字) |
| 二七十 | 大贰+小柒+大拾 | 二、七、十的组合,大小可混 |
| 混合连续 | 大壹+大贰+小叁 | 同为大字或同为小字时连续即可 |
吃牌限制
和麻将一样,字牌也只能吃上家(你左边的人)打出的牌。不能吃对家或下家的牌。
吃牌检测代码
C
/// <summary>
/// 字牌吃牌检测器
/// </summary>
public partial class ZiChiChecker : Node
{
/// <summary>
/// 二七十的数值集合
/// </summary>
private static readonly int[] ErQiShi = { 2, 7, 10 };
/// <summary>
/// 检测所有可能的吃法
/// </summary>
/// <param name="hand">手牌</param>
/// <param name="tile">上家打出的牌</param>
/// <returns>所有可能的吃法列表</returns>
public List<List<ZiTile>> CheckChi(List<ZiTile> hand, ZiTile tile)
{
var results = new List<List<ZiTile>>();
// 方式1:连续吃(同花色连续三张)
CheckSequenceChi(hand, tile, results);
// 方式2:二七十吃
CheckErQiShiChi(hand, tile, results);
return results;
}
/// <summary>
/// 检查连续吃法
/// </summary>
private void CheckSequenceChi(List<ZiTile> hand, ZiTile tile,
List<List<ZiTile>> results)
{
// 连续吃要求同花色
var sameSuit = hand.Where(t => t.Suit == tile.Suit).ToList();
// 尝试三种连续组合:tile是第1张、第2张、第3张
TrySequenceCombo(results, sameSuit, tile, -2, -1); // tile-2, tile-1, tile
TrySequenceCombo(results, sameSuit, tile, -1, 1); // tile-1, tile, tile+1
TrySequenceCombo(results, sameSuit, tile, 1, 2); // tile, tile+1, tile+2
}
private void TrySequenceCombo(List<List<ZiTile>> results,
List<ZiTile> sameSuit, ZiTile tile, int offset1, int offset2)
{
int v1 = tile.Value + offset1;
int v2 = tile.Value + offset2;
// 字牌数值范围是1-10
if (v1 < 1 || v1 > 10 || v2 < 1 || v2 > 10) return;
var t1 = sameSuit.FirstOrDefault(t => t.Value == v1);
var t2 = sameSuit.FirstOrDefault(t => t.Value == v2);
if (t1 != null && t2 != null)
{
results.Add(new List<ZiTile> { t1, tile, t2 });
}
}
/// <summary>
/// 检查二七十吃法
/// </summary>
private void CheckErQiShiChi(List<ZiTile> hand, ZiTile tile,
List<List<ZiTile>> results)
{
// 检查 tile 的数值是否是 2、7 或 10
if (!ErQiShi.Contains(tile.Value)) return;
// 找出手中数值为 2、7、10 的牌(大小不限)
var erTiles = hand.Where(t => t.Value == 2).ToList();
var qiTiles = hand.Where(t => t.Value == 7).ToList();
var shiTiles = hand.Where(t => t.Value == 10).ToList();
// 根据当前牌的数值,找另外两张
List<ZiTile> needed1, needed2;
if (tile.Value == 2)
{
needed1 = qiTiles;
needed2 = shiTiles;
}
else if (tile.Value == 7)
{
needed1 = erTiles;
needed2 = shiTiles;
}
else // tile.Value == 10
{
needed1 = erTiles;
needed2 = qiTiles;
}
// 尝试所有组合
foreach (var n1 in needed1)
{
foreach (var n2 in needed2)
{
// 排除自己(tile本身不能同时是两张)
if (n1.TileId != tile.TileId && n2.TileId != tile.TileId
&& n1.TileId != n2.TileId)
{
results.Add(new List<ZiTile> { tile, n1, n2 });
}
}
}
}
}GDScript
extends Node
## 二七十的数值
const ER_QI_SHI := [2, 7, 10]
## 检测所有可能的吃法
func check_chi(hand: Array[ZiTile], tile: ZiTile) -> Array:
var results: Array = []
check_sequence_chi(hand, tile, results)
check_er_qi_shi_chi(hand, tile, results)
return results
## 检查连续吃法
func check_sequence_chi(hand: Array[ZiTile], tile: ZiTile, results: Array) -> void:
var same_suit := hand.filter(func(t): return t.suit == tile.suit)
try_sequence_combo(results, same_suit, tile, -2, -1)
try_sequence_combo(results, same_suit, tile, -1, 1)
try_sequence_combo(results, same_suit, tile, 1, 2)
func try_sequence_combo(results: Array, same_suit: Array, tile: ZiTile,
offset1: int, offset2: int) -> void:
var v1 := tile.value + offset1
var v2 := tile.value + offset2
if v1 < 1 or v1 > 10 or v2 < 1 or v2 > 10:
return
var t1 = same_suit.filter(func(t): return t.value == v1)
var t2 = same_suit.filter(func(t): return t.value == v2)
if t1.size() > 0 and t2.size() > 0:
results.append([t1[0], tile, t2[0]])
## 检查二七十吃法
func check_er_qi_shi_chi(hand: Array[ZiTile], tile: ZiTile, results: Array) -> void:
if not ER_QI_SHI.has(tile.value):
return
var er_tiles := hand.filter(func(t): return t.value == 2)
var qi_tiles := hand.filter(func(t): return t.value == 7)
var shi_tiles := hand.filter(func(t): return t.value == 10)
var needed1: Array
var needed2: Array
if tile.value == 2:
needed1 = qi_tiles
needed2 = shi_tiles
elif tile.value == 7:
needed1 = er_tiles
needed2 = shi_tiles
else:
needed1 = er_tiles
needed2 = qi_tiles
for n1 in needed1:
for n2 in needed2:
if n1.tile_id != tile.tile_id and n2.tile_id != tile.tile_id \
and n1.tile_id != n2.tile_id:
results.append([tile, n1, n2])计分规则
基础计分
字牌的计分比麻将简单直接:
| 项目 | 说明 |
|---|---|
| 红字分值 | 每张2分 |
| 黑字分值 | 每张1分 |
| 偎加成 | 组合分值 × 2 |
| 提加成 | 组合分值 × 4 |
| 碰加成 | 组合分值 × 1(无加成) |
| 跑加成 | 组合分值 × 4 |
计分示例
| 操作 | 牌组合 | 计算 | 总分 |
|---|---|---|---|
| 偎(红) | 大壹×3 | (2+2+2) × 2 | 12分 |
| 偎(黑) | 小一×3 | (1+1+1) × 2 | 6分 |
| 提(红) | 大贰×4 | (2+2+2+2) × 4 | 32分 |
| 碰(黑) | 小三×3 | (1+1+1) × 1 | 3分 |
| 吃 | 大壹+大贰+大叁 | (2+2+2) × 1 | 6分 |
| 跑(红) | 大肆×4 | (2+2+2+2) × 4 | 32分 |
计分策略
因为红字(大字)的分值是黑字的两倍,所以高手会优先保留大字、先打小字。这就是字牌策略的核心之一——"红字为王"。
字牌胡牌检测
胡牌条件
字牌的胡牌条件和麻将不同:
| 条件 | 说明 |
|---|---|
| 手牌数 | 通常手牌 + 明牌的组合满足特定条件即可 |
| 组合方式 | 所有的牌必须组成"偎/提/碰/吃/跑"的组合 |
| 特殊牌型 | 部分规则支持"红胡"(全红字)等特殊牌型 |
C
/// <summary>
/// 字牌胡牌检测器
/// </summary>
public partial class ZiWinChecker : Node
{
/// <summary>
/// 检测是否胡牌
/// </summary>
/// <param name="hand">手牌(包括刚摸到的牌)</param>
/// <param name="melds">已有的明牌组合</param>
/// <returns>是否胡牌</returns>
public bool CheckWin(List<ZiTile> hand, List<ZiMeld> melds)
{
// 手牌数量必须合理(通常剩余的手牌能完全组成组合)
// 具体规则因地区而异,这里给出一个通用版本
// 方式1:所有手牌都能组成组合(每3张一组)
if (hand.Count > 0 && hand.Count % 3 == 0)
{
if (CanFormAllMelds(hand))
{
return true;
}
}
// 方式2:手牌 + 已有组合 = 满足胡牌条件
// 这里简化处理,实际规则更复杂
return false;
}
/// <summary>
/// 检查手牌能否全部组成组合
/// </summary>
private bool CanFormAllMelds(List<ZiTile> hand)
{
if (hand.Count == 0) return true;
// 统计每种牌的数量
var counts = new Dictionary<string, int>();
foreach (var tile in hand)
{
string key = $"{(int)tile.Suit}_{tile.Value}";
if (!counts.ContainsKey(key))
counts[key] = 0;
counts[key]++;
}
// 尝试从手牌中提取组合
return TryExtractMelds(counts, hand.Count / 3);
}
/// <summary>
/// 递归尝试提取指定数量的组合
/// </summary>
private bool TryExtractMelds(Dictionary<string, int> counts, int meldsNeeded)
{
if (meldsNeeded == 0)
{
return counts.Values.All(c => c == 0);
}
// 找到第一个有牌的位置
string firstKey = counts.FirstOrDefault(kvp => kvp.Value > 0).Key;
if (firstKey == null) return meldsNeeded == 0;
var parts = firstKey.Split('_');
int suit = int.Parse(parts[0]);
int value = int.Parse(parts[1]);
// 尝试1:提取刻子(偎/碰)
if (counts[firstKey] >= 3)
{
counts[firstKey] -= 3;
if (TryExtractMelds(counts, meldsNeeded - 1))
{
counts[firstKey] += 3;
return true;
}
counts[firstKey] += 3;
}
// 尝试2:提取顺子(连续吃)
if (value <= 8)
{
string key2 = $"{suit}_{value + 1}";
string key3 = $"{suit}_{value + 2}";
if (counts.GetValueOrDefault(key2, 0) > 0 &&
counts.GetValueOrDefault(key3, 0) > 0)
{
counts[firstKey]--;
counts[key2]--;
counts[key3]--;
if (TryExtractMelds(counts, meldsNeeded - 1))
{
counts[firstKey]++;
counts[key2]++;
counts[key3]++;
return true;
}
counts[firstKey]++;
counts[key2]++;
counts[key3]++;
}
}
// 尝试3:提取二七十组合
if (value == 2 || value == 7 || value == 10)
{
var otherValues = ErQiShi.Except(new[] { value }).ToArray();
string keyOther1 = $"{suit}_{otherValues[0]}";
string keyOther2 = $"{suit}_{otherValues[1]}";
if (counts.GetValueOrDefault(keyOther1, 0) > 0 &&
counts.GetValueOrDefault(keyOther2, 0) > 0)
{
counts[firstKey]--;
counts[keyOther1]--;
counts[keyOther2]--;
if (TryExtractMelds(counts, meldsNeeded - 1))
{
counts[firstKey]++;
counts[keyOther1]++;
counts[keyOther2]++;
return true;
}
counts[firstKey]++;
counts[keyOther1]++;
counts[keyOther2]++;
}
}
return false;
}
private static readonly int[] ErQiShi = { 2, 7, 10 };
}GDScript
extends Node
## 检测是否胡牌
func check_win(hand: Array[ZiTile], melds: Array) -> bool:
if hand.size() > 0 and hand.size() % 3 == 0:
if can_form_all_melds(hand):
return true
return false
## 检查手牌能否全部组成组合
func can_form_all_melds(hand: Array[ZiTile]) -> bool:
if hand.is_empty():
return true
var counts := {}
for tile in hand:
var key := "%d_%d" % [tile.suit, tile.value]
if not counts.has(key):
counts[key] = 0
counts[key] += 1
return try_extract_melds(counts, hand.size() / 3)
## 递归尝试提取组合
func try_extract_melds(counts: Dictionary, melds_needed: int) -> bool:
if melds_needed == 0:
for v in counts.values():
if v != 0:
return false
return true
var first_key := ""
for key in counts.keys():
if counts[key] > 0:
first_key = key
break
if first_key == "":
return melds_needed == 0
var parts := first_key.split("_")
var suit := int(parts[0])
var value := int(parts[1])
# 尝试刻子
if counts[first_key] >= 3:
counts[first_key] -= 3
if try_extract_melds(counts, melds_needed - 1):
counts[first_key] += 3
return true
counts[first_key] += 3
# 尝试顺子
if value <= 8:
var key2 := "%d_%d" % [suit, value + 1]
var key3 := "%d_%d" % [suit, value + 2]
if counts.get(key2, 0) > 0 and counts.get(key3, 0) > 0:
counts[first_key] -= 1
counts[key2] -= 1
counts[key3] -= 1
if try_extract_melds(counts, melds_needed - 1):
counts[first_key] += 1
counts[key2] += 1
counts[key3] += 1
return true
counts[first_key] += 1
counts[key2] += 1
counts[key3] += 1
# 尝试二七十
if value == 2 or value == 7 or value == 10:
var others := [2, 7, 10].filter(func(v): return v != value)
var key_o1 := "%d_%d" % [suit, others[0]]
var key_o2 := "%d_%d" % [suit, others[1]]
if counts.get(key_o1, 0) > 0 and counts.get(key_o2, 0) > 0:
counts[first_key] -= 1
counts[key_o1] -= 1
counts[key_o2] -= 1
if try_extract_melds(counts, melds_needed - 1):
counts[first_key] += 1
counts[key_o1] += 1
counts[key_o2] += 1
return true
counts[first_key] += 1
counts[key_o1] += 1
counts[key_o2] += 1
return false字牌 vs 麻将的区别总结
| 对比维度 | 麻将 | 字牌(跑胡子) |
|---|---|---|
| 牌数 | 136张 | 80张 |
| 花色 | 万/条/筒/风/箭 | 大字/小字 |
| 组合 | 对子+刻子+顺子 | 偎/提/碰/吃/跑 |
| 特殊组合 | 七对/清一色等 | 二七十 |
| 计分 | 番数制 | 分值累加 |
| 胡牌条件 | 4组合+1对子 | 全部组成组合 |
| 游戏节奏 | 较慢 | 较快 |
| 策略重点 | 防守+进攻并重 | 进攻为主 |
小结
本章我们实现了字牌(跑胡子)的规则引擎:
- 牌的数据结构——大字/小字的定义和属性
- 五种操作——偎、提、碰、吃、跑的规则
- 吃牌检测——连续吃法和"二七十"特殊吃法
- 计分系统——红黑字分值 + 操作加成
- 胡牌检测——包含二七十组合的递归检测算法
提示
字牌规则虽然比麻将简单,但"二七十"这个特殊组合给策略增加了不少深度。理解好这个机制,是做好字牌AI的关键。
→ 6. AI 对手
