6. AI 对手
2026/4/13大约 10 分钟
AI 对手
一个人打麻将多没意思——你得有对手才行。本章我们要给游戏装上"AI大脑",让电脑能像真人一样思考出牌。
生活化比喻
AI对手就像一个"模拟真人"的机器人牌友。它不会像真人一样犹豫不决、打错牌,但它能根据一定的策略来做出合理的出牌决策。AI越"聪明",游戏越好玩。
AI 设计思路
AI 要做什么?
在麻将/字牌游戏中,AI 在每个回合需要做以下决策:
| 决策点 | 问题 | 影响 |
|---|---|---|
| 出牌 | 从手牌中选哪张打出? | 影响自己能否胡牌 + 影响对手能否胡牌 |
| 碰牌 | 别人打的牌要不要碰? | 碰了加快进度但暴露信息 |
| 杠牌 | 能杠要不要杠? | 杠了多摸一张但可能改变手牌结构 |
| 吃牌 | 上家打的牌要不要吃? | 吃了增加组合但可能破坏计划 |
| 胡牌 | 能胡要不要胡? | 一般能胡就胡,但有时可以选择"做大牌" |
AI 难度等级
| 等级 | 策略 | 说明 |
|---|---|---|
| 简单 | 随机出牌 | 随便选一张打出去 |
| 中等 | 贪心策略 | 优先打"无用"的牌,保留有用的搭子 |
| 困难 | 综合策略 | 考虑对手出牌、防守策略、听牌选择 |
相关信息
我们不会做"完美AI"——因为完美的麻将AI需要极其复杂的计算(甚至涉及博弈论),远超本教程的范围。我们的目标是做一个"够用"的AI——它不会犯太蠢的错误,但也不是不可战胜的。
简单AI:随机策略
最简单的AI
"随机AI"就是从手牌中随机选一张打出去。虽然很蠢,但它可以作为最低难度的对手,也适合用来测试游戏逻辑。
C
using Godot;
/// <summary>
/// 简单AI——随机出牌
/// </summary>
public partial class SimpleAI : Node
{
private readonly Random _random = new();
/// <summary>
/// 选择要打出的牌(随机)
/// </summary>
/// <param name="hand">当前手牌</param>
/// <returns>要打出的牌</returns>
public MahjongTile ChooseDiscard(List<MahjongTile> hand)
{
if (hand.Count == 0) return null;
// 随机选一张牌
int index = _random.Next(hand.Count);
return hand[index];
}
/// <summary>
/// 决定是否碰牌(随机决定,50%概率碰)
/// </summary>
public bool ShouldPong(List<MahjongTile> hand, MahjongTile tile)
{
return _random.NextDouble() > 0.5;
}
/// <summary>
/// 决定是否杠牌(随机决定,50%概率杠)
/// </summary>
public bool ShouldKong(List<MahjongTile> hand, MahjongTile tile)
{
return _random.NextDouble() > 0.5;
}
/// <summary>
/// 决定是否吃牌(随机决定,50%概率吃)
/// </summary>
public bool ShouldChi(List<MahjongTile> hand, MahjongTile tile)
{
return _random.NextDouble() > 0.5;
}
/// <summary>
/// 从多种吃法中选择一种(随机选)
/// </summary>
public List<MahjongTile> ChooseChiOption(List<List<MahjongTile>> options)
{
if (options.Count == 0) return null;
int index = _random.Next(options.Count);
return options[index];
}
}GDScript
extends Node
var _random := RandomNumberGenerator.new()
## 选择要打出的牌(随机)
func choose_discard(hand: Array[MahjongTile]) -> MahjongTile:
if hand.is_empty():
return null
var index := randi() % hand.size()
return hand[index]
## 决定是否碰牌
func should_pong(hand: Array[MahjongTile], tile: MahjongTile) -> bool:
return randf() > 0.5
## 决定是否杠牌
func should_kong(hand: Array[MahjongTile], tile: MahjongTile) -> bool:
return randf() > 0.5
## 决定是否吃牌
func should_chi(hand: Array[MahjongTile], tile: MahjongTile) -> bool:
return randf() > 0.5
## 从多种吃法中选择一种
func choose_chi_option(options: Array) -> Array:
if options.is_empty():
return []
var index := randi() % options.size()
return options[index]中等AI:贪心策略
贪心策略的核心思想
"贪心"的意思是:每一步都做当前看起来最好的选择。虽然不保证最终结果最优,但大多数情况下效果不错。
生活化比喻
贪心策略就像去超市买菜——你看到打折的肉就买肉,看到新鲜的蔬菜就买蔬菜。你不会提前规划好一周的菜单再买东西,而是"看到什么好就买什么"。这不一定是最好的购物方式,但至少不会买得太差。
牌的价值评估
要实现贪心策略,首先需要评估每张牌的"价值"——价值低的优先打出:
| 牌的状态 | 价值 | 说明 |
|---|---|---|
| 孤张(无搭子) | 低 | 留着没用,尽快打出 |
| 搭子(2张相关) | 中 | 可能组成组合,保留 |
| 面子(1张就凑成组合) | 高 | 差一张就成组合,千万别打 |
| 对子 | 中高 | 可能碰/胡,保留 |
| 刻子 | 很高 | 已经是组合了 |
搭子检测
"搭子"是指两张有可能组成有效组合的牌:
| 搭子类型 | 示例 | 说明 |
|---|---|---|
| 相邻搭 | 三万+四万 | 差一张就成顺子 |
| 间隔搭 | 三万+五万 | 差中间一张成顺子 |
| 刻子搭 | 两个东风 | 差一张成刻子 |
| 边张搭 | 一万+二万 | 只能往一边发展 |
C
/// <summary>
/// 牌面价值评估器
/// </summary>
public partial class TileEvaluator : Node
{
/// <summary>
/// 评估一张牌的"价值"(越高越值得保留)
/// </summary>
/// <param name="tile">要评估的牌</param>
/// <param name="hand">当前手牌</param>
/// <returns>价值分数</returns>
public int EvaluateTile(MahjongTile tile, List<MahjongTile> hand)
{
int score = 0;
// 基础分:对子加分
var sameTiles = hand.Count(t => t.IsSameAs(tile) && t.TileId != tile.TileId);
score += sameTiles * 10;
if (tile.IsNumberTile)
{
// 数牌:检查顺子搭子
var sameSuit = hand.Where(t =>
t.Suit == tile.Suit && t.TileId != tile.TileId).ToList();
// 相邻搭子(如3万+4万)
if (sameSuit.Any(t => Math.Abs(t.Value - tile.Value) == 1))
{
score += 8;
}
// 间隔搭子(如3万+5万)
if (sameSuit.Any(t => Math.Abs(t.Value - tile.Value) == 2))
{
score += 5;
}
// 边张惩罚(1和9的牌更难组成顺子)
if (tile.Value == 1 || tile.Value == 9)
{
score -= 2;
}
// 中张奖励(4、5、6容易组成顺子)
if (tile.Value >= 4 && tile.Value <= 6)
{
score += 3;
}
}
else
{
// 字牌(风牌/箭牌):只看对子
// 风牌价值较低(只能碰/杠,不能组成顺子)
if (tile.Suit == SuitType.Feng)
{
score -= 1;
}
// 箭牌(中发白)价值稍高(可以做将)
if (tile.Suit == SuitType.Jian)
{
score += 2;
}
}
return score;
}
/// <summary>
/// 选择价值最低的牌打出
/// </summary>
/// <param name="hand">当前手牌</param>
/// <returns>要打出的牌</returns>
public MahjongTile ChooseBestDiscard(List<MahjongTile> hand)
{
if (hand.Count == 0) return null;
MahjongTile bestTile = null;
int lowestScore = int.MaxValue;
foreach (var tile in hand)
{
int score = EvaluateTile(tile, hand);
if (score < lowestScore)
{
lowestScore = score;
bestTile = tile;
}
}
return bestTile;
}
}GDScript
extends Node
## 评估一张牌的价值
func evaluate_tile(tile: MahjongTile, hand: Array[MahjongTile]) -> int:
var score := 0
# 对子加分
var same_count := hand.filter(func(t):
return t.is_same_as(tile) and t.tile_id != tile.tile_id
).size()
score += same_count * 10
if tile.is_number_tile():
# 检查顺子搭子
var same_suit := hand.filter(func(t):
return t.suit == tile.suit and t.tile_id != tile.tile_id
)
# 相邻搭子
for t in same_suit:
if absi(t.value - tile.value) == 1:
score += 8
break
# 间隔搭子
for t in same_suit:
if absi(t.value - tile.value) == 2:
score += 5
break
# 边张惩罚
if tile.value == 1 or tile.value == 9:
score -= 2
# 中张奖励
if tile.value >= 4 and tile.value <= 6:
score += 3
else:
# 风牌价值低
if tile.suit == 3: # SuitType.FENG
score -= 1
# 箭牌价值高
if tile.suit == 4: # SuitType.JIAN
score += 2
return score
## 选择价值最低的牌打出
func choose_best_discard(hand: Array[MahjongTile]) -> MahjongTile:
if hand.is_empty():
return null
var best_tile: MahjongTile = null
var lowest_score := 999999
for tile in hand:
var s := evaluate_tile(tile, hand)
if s < lowest_score:
lowest_score = s
best_tile = tile
return best_tile中等AI:碰杠判断
碰牌策略
| 情况 | 建议 | 原因 |
|---|---|---|
| 手牌差,碰了能加速 | 碰 | 加快进度 |
| 手牌好,碰了破坏听牌 | 不碰 | 保持手牌结构 |
| 对手快胡了 | 不碰 | 避免给对手喂牌 |
| 自己快听牌了 | 碰 | 加速胡牌 |
杠牌策略
| 情况 | 建议 | 原因 |
|---|---|---|
| 杠后能听牌 | 杠 | 多摸一张是好事 |
| 杠后破坏手牌 | 不杠 | 保持手牌结构 |
| 海底杠(牌堆快空了) | 不杠 | 避免被抢杠胡 |
C
/// <summary>
/// 中等AI——贪心策略
/// </summary>
public partial class MediumAI : Node
{
private readonly TileEvaluator _evaluator = new();
/// <summary>
/// 选择要打出的牌
/// </summary>
public MahjongTile ChooseDiscard(List<MahjongTile> hand)
{
return _evaluator.ChooseBestDiscard(hand);
}
/// <summary>
/// 决定是否碰牌
/// </summary>
/// <param name="hand">手牌</param>
/// <param name="melds">已有的明牌</param>
/// <param name="tile">别人打的牌</param>
/// <returns>是否碰</returns>
public bool ShouldPong(List<MahjongTile> hand, List<Meld> melds,
MahjongTile tile)
{
// 简单规则:如果手牌超过10张,碰了加速进度
// 如果手牌少于等于10张,不碰(手牌结构更重要)
if (hand.Count > 10)
{
return true;
}
// 特殊情况:如果是箭牌,总是碰(中发白很有用)
if (tile.Suit == SuitType.Jian)
{
return true;
}
return false;
}
/// <summary>
/// 决定是否杠牌
/// </summary>
public bool ShouldKong(List<MahjongTile> hand, List<Meld> melds,
MahjongTile tile, int wallCount)
{
// 如果牌堆剩余少于10张,不杠(避免海底杠风险)
if (wallCount < 10)
{
return false;
}
// 简单规则:总是杠(杠了多摸一张牌,通常是好事)
return true;
}
/// <summary>
/// 决定是否吃牌
/// </summary>
/// <param name="hand">手牌</param>
/// <param name="tile">上家打的牌</param>
/// <param name="options">可选的吃法</param>
/// <returns>选中的吃法(null表示不吃)</returns>
public List<MahjongTile> ShouldChi(List<MahjongTile> hand, MahjongTile tile,
List<List<MahjongTile>> options)
{
// 简单规则:选择让手牌价值最高的吃法
if (options.Count == 0) return null;
List<MahjongTile> bestOption = null;
int bestScore = int.MinValue;
foreach (var option in options)
{
// 模拟吃牌后的手牌
var simulatedHand = new List<MahjongTile>(hand);
foreach (var t in option)
{
if (t.TileId != tile.TileId) // 移除手中的牌
{
simulatedHand.Remove(t);
}
}
// 评估吃牌后手牌的整体价值
int totalValue = 0;
foreach (var t in simulatedHand)
{
totalValue += _evaluator.EvaluateTile(t, simulatedHand);
}
if (totalValue > bestScore)
{
bestScore = totalValue;
bestOption = option;
}
}
// 只有当吃牌后手牌价值提升时才吃
// 这里简化为总是选择最佳吃法
return bestOption;
}
}GDScript
extends Node
var _evaluator: TileEvaluator = TileEvaluator.new()
## 选择要打出的牌
func choose_discard(hand: Array[MahjongTile]) -> MahjongTile:
return _evaluator.choose_best_discard(hand)
## 决定是否碰牌
func should_pong(hand: Array[MahjongTile], melds: Array, tile: MahjongTile) -> bool:
if hand.size() > 10:
return true
# 箭牌总是碰
if tile.suit == 4: # SuitType.JIAN
return true
return false
## 决定是否杠牌
func should_kong(hand: Array[MahjongTile], melds: Array,
tile: MahjongTile, wall_count: int) -> bool:
# 牌堆少于10张不杠
if wall_count < 10:
return false
return true
## 决定是否吃牌
func should_chi(hand: Array[MahjongTile], tile: MahjongTile,
options: Array) -> Array:
if options.is_empty():
return null
var best_option: Array = null
var best_score := -999999
for option in options:
# 模拟吃牌后的手牌
var simulated_hand := hand.duplicate()
for t in option:
if t.tile_id != tile.tile_id:
simulated_hand.erase(t)
# 评估手牌整体价值
var total_value := 0
for t in simulated_hand:
total_value += _evaluator.evaluate_tile(t, simulated_hand)
if total_value > best_score:
best_score = total_value
best_option = option
return best_option困难AI:综合策略
听牌检测
"听牌"是指只差一张牌就能胡牌的状态。困难AI需要能检测自己是否听牌,以及听哪些牌。
C
/// <summary>
/// 听牌检测器
/// </summary>
public partial class TenpaiDetector : Node
{
/// <summary>
/// 检测当前手牌是否听牌(差一张就胡)
/// </summary>
/// <param name="hand">13张手牌</param>
/// <returns>所有能胡的牌(听牌列表)</returns>
public List<MahjongTile> GetTenpaiTiles(List<MahjongTile> hand)
{
var tenpaiTiles = new List<MahjongTile>();
// 枚举所有可能的牌
for (int suit = 0; suit <= 4; suit++)
{
int maxValue = suit <= 2 ? 9 : (suit == 3 ? 4 : 3);
for (int value = 1; value <= maxValue; value++)
{
var testTile = new MahjongTile((SuitType)suit, value, -1);
// 模拟摸到这张牌
var testHand = new List<MahjongTile>(hand) { testTile };
// 检查是否胡牌
var winChecker = new WinChecker();
if (winChecker.CheckWin(testHand, out _))
{
tenpaiTiles.Add(testTile);
}
}
}
return tenpaiTiles;
}
/// <summary>
/// 获取听牌数量
/// </summary>
public int GetTenpaiCount(List<MahjongTile> hand)
{
return GetTenpaiTiles(hand).Count;
}
}GDScript
extends Node
## 检测当前手牌是否听牌
func get_tenpai_tiles(hand: Array[MahjongTile]) -> Array:
var tenpai_tiles: Array = []
for suit in range(5):
var max_value := 9 if suit <= 2 else (4 if suit == 3 else 3)
for value in range(1, max_value + 1):
var test_tile = MahjongTile.new(suit, value, -1)
# 模拟摸牌
var test_hand := hand.duplicate()
test_hand.append(test_tile)
# 检查胡牌
var win_checker = WinChecker.new()
if win_checker.check_win(test_hand):
tenpai_tiles.append(test_tile)
return tenpai_tiles
## 获取听牌数量
func get_tenpai_count(hand: Array[MahjongTile]) -> int:
return get_tenpai_tiles(hand).size()困难AI出牌策略
困难AI在出牌时会考虑以下几点:
| 策略 | 说明 | 优先级 |
|---|---|---|
| 听牌优先 | 如果打出某张牌能听牌,优先打出 | 最高 |
| 保留搭子 | 保留有搭子的牌,打孤张 | 高 |
| 防守打牌 | 不打对手可能需要的牌 | 中 |
| 生张优先 | 先打还没出现过的牌(更安全) | 中 |
| 字牌处理 | 无用的风牌尽早打出 | 低 |
字牌AI策略
字牌AI和麻将AI类似,但因为规则更简单,策略也相对简单:
| 策略 | 说明 |
|---|---|
| 红字优先 | 优先保留大字(红字),先打小字 |
| 二七十优先 | 保留可能组成二七十的牌(2、7、10) |
| 搭子优先 | 保留有连续搭子的牌 |
| 孤张先打 | 没有搭子的牌尽早打出 |
AI难度设计建议
- 简单AI适合新手练习
- 中等AI适合普通玩家
- 困难AI适合有经验的玩家
- 可以让玩家在游戏设置中选择AI难度
小结
本章我们实现了三级AI系统:
- 简单AI——随机出牌,适合最低难度
- 中等AI——贪心策略,根据牌面价值评估出牌
- 困难AI——综合策略,包含听牌检测和防守判断
- 字牌AI——红字优先、二七十搭子保留等策略
提示
AI是让游戏"可玩"的关键。即使规则引擎做得再完美,没有AI对手游戏也没法玩。接下来我们将实现网络对战功能,让真人可以联机对打。
→ 7. 网络对战
