7. AI玩家
2026/4/14大约 9 分钟
长沙麻将——AI玩家
什么是AI玩家?
AI(Artificial Intelligence,人工智能)玩家就是"电脑对手"——它代替真人来打麻将。AI需要做两件事:
- 出牌决策:手中有14张牌,打哪一张最好?
- 吃碰杠决策:别人打出一张牌,要不要吃/碰/杠?
这就像请了一个"麻将教练"坐在你对面——他不是随随便便出牌,而是会根据手牌的情况做出"合理"的决策。
AI难度等级
我们设计三个难度的AI:
| 难度 | 出牌策略 | 吃碰杠策略 | 说明 |
|---|---|---|---|
| 简单 | 随机出牌 | 随机决定 | 新手AI,像刚学麻将的人 |
| 中等 | 保留搭子 | 有利才吃碰 | 普通AI,会基本的牌效分析 |
| 困难 | 听牌优先 | 精确计算 | 高手AI,会计算最优出牌 |
出牌策略
简单AI:随机出牌
最简单的策略——从手牌中随机选一张打出。
C
using Godot;
using System;
using System.Collections.Generic;
/// <summary>
/// AI难度等级
/// </summary>
public enum AIDifficulty
{
Easy, // 简单
Medium, // 中等
Hard // 困难
}
/// <summary>
/// AI玩家
/// </summary>
public partial class AIPlayer : Node
{
/// <summary>AI难度</summary>
public AIDifficulty Difficulty { get; set; } = AIDifficulty.Medium;
private static readonly Random _random = new Random();
/// <summary>
/// AI出牌决策
/// </summary>
/// <param name="hand">当前手牌</param>
/// <param name="melds">已有副露</param>
/// <returns>要打出的牌在手牌中的索引</returns>
public int DecideDiscard(List<Tile> hand, List<Meld> melds)
{
return Difficulty switch
{
AIDifficulty.Easy => RandomDiscard(hand),
AIDifficulty.Medium => MediumDiscard(hand),
AIDifficulty.Hard => HardDiscard(hand, melds),
_ => RandomDiscard(hand)
};
}
/// <summary>
/// 简单AI:随机出牌
/// </summary>
private int RandomDiscard(List<Tile> hand)
{
int index = _random.Next(hand.Count);
GD.Print($"[AI-简单] 随机出牌: {hand[index]} (索引:{index})");
return index;
}
}GDScript
extends Node
## AI难度等级
enum AIDifficulty {
EASY, ## 简单
MEDIUM, ## 中等
HARD ## 困难
}
## AI难度
var difficulty: int = AIDifficulty.MEDIUM
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
## AI出牌决策
## hand: 当前手牌, melds: 已有副露
## 返回要打出的牌在手牌中的索引
func decide_discard(hand: Array, melds: Array) -> int:
match difficulty:
AIDifficulty.EASY:
return _random_discard(hand)
AIDifficulty.MEDIUM:
return _medium_discard(hand)
AIDifficulty.HARD:
return _hard_discard(hand, melds)
return _random_discard(hand)
## 简单AI:随机出牌
func _random_discard(hand: Array) -> int:
var index: int = _rng.randi_range(0, hand.size() - 1)
print("[AI-简单] 随机出牌: %s (索引:%d)" % [hand[index], index])
return index中等AI:保留搭子
中等AI的策略是:优先打出"孤张"(与其他牌没有关系的单张),保留"搭子"(能组成顺子或刻子的牌)。
什么是"搭子"?想象你在搭积木——有些积木能和其他积木拼在一起(搭子),有些积木怎么也拼不上去(孤张)。当然优先扔掉没用的孤张了。
搭子的类型:
| 搭子类型 | 说明 | 示例 |
|---|---|---|
| 对子 | 两张相同的牌 | [五万][五万] |
| 两面搭 | 两张同花色差1的牌 | [三万][五万],等四万 |
| 边搭 | 两张同花色差2的牌 | [一万][三万],等二万 |
| 嵌搭 | 两张同花色差2的牌 | [三万][五万],等四万 |
C
/// <summary>
/// 中等AI出牌策略
/// 打出价值最低的牌(孤张优先)
/// </summary>
private int MediumDiscard(List<Tile> hand)
{
// 给每张牌打分,分数越低越应该打出
var scores = new List<(int index, int score)>();
for (int i = 0; i < hand.Count; i++)
{
int score = EvaluateTile(hand, i);
scores.Add((i, score));
}
// 按分数排序,打出分数最低的
scores.Sort((a, b) => a.score.CompareTo(b.score));
int discardIndex = scores[0].index;
GD.Print($"[AI-中等] 出牌: {hand[discardIndex]} " +
$"(分数:{scores[0].score}, 索引:{discardIndex})");
return discardIndex;
}
/// <summary>
/// 评估一张牌的"价值"
/// 价值越高,越应该保留
/// </summary>
private int EvaluateTile(List<Tile> hand, int tileIndex)
{
Tile tile = hand[tileIndex];
int score = 0;
// 1. 检查是否是对子的一部分
int sameCount = 0;
for (int i = 0; i < hand.Count; i++)
{
if (i != tileIndex && hand[i].Id == tile.Id)
{
sameCount++;
}
}
// 有配对的牌更有价值
score += sameCount * 10;
// 2. 检查是否能组成顺子(相邻牌)
for (int i = 0; i < hand.Count; i++)
{
if (i == tileIndex) continue;
if (hand[i].Suit == tile.Suit)
{
int diff = Mathf.Abs(hand[i].Number - tile.Number);
if (diff == 1) score += 8; // 相邻(两面搭)
else if (diff == 2) score += 4; // 差2(嵌搭/边搭)
}
}
// 3. 中间牌更有价值(更容易组成顺子)
if (tile.Number >= 3 && tile.Number <= 7)
{
score += 3; // 中间牌
}
else if (tile.Number == 2 || tile.Number == 8)
{
score += 1; // 边缘牌
}
// 1和9最不容易组成顺子
return score;
}GDScript
## 中等AI出牌策略
## 打出价值最低的牌(孤张优先)
func _medium_discard(hand: Array) -> int:
# 给每张牌打分
var scores: Array = []
for i in range(hand.size()):
var score: int = _evaluate_tile(hand, i)
scores.append({"index": i, "score": score})
# 按分数排序,打出分数最低的
scores.sort_custom(func(a, b): return a["score"] < b["score"])
var discard_index: int = scores[0]["index"]
print("[AI-中等] 出牌: %s (分数:%d, 索引:%d)" % [
hand[discard_index], scores[0]["score"], discard_index
])
return discard_index
## 评估一张牌的"价值"
## 价值越高,越应该保留
func _evaluate_tile(hand: Array, tile_index: int) -> int:
var tile: Tile = hand[tile_index]
var score: int = 0
# 1. 检查是否是对子的一部分
var same_count: int = 0
for i in range(hand.size()):
if i != tile_index and hand[i].id == tile.id:
same_count += 1
# 有配对的牌更有价值
score += same_count * 10
# 2. 检查是否能组成顺子(相邻牌)
for i in range(hand.size()):
if i == tile_index:
continue
if hand[i].suit == tile.suit:
var diff: int = absi(hand[i].number - tile.number)
if diff == 1:
score += 8 # 相邻(两面搭)
elif diff == 2:
score += 4 # 差2(嵌搭/边搭)
# 3. 中间牌更有价值
if tile.number >= 3 and tile.number <= 7:
score += 3 # 中间牌
elif tile.number == 2 or tile.number == 8:
score += 1 # 边缘牌
return score困难AI:听牌优先
困难AI的策略是:选择能让手牌"听牌"(只差一张就胡)的出牌。如果多种出牌方式都能听牌,选择"听牌数"最多(有更多种牌能胡)的方案。
C
/// <summary>
/// 困难AI出牌策略
/// 选择听牌数最多的出牌方案
/// </summary>
private int HardDiscard(List<Tile> hand, List<Meld> melds)
{
var winChecker = GetNode<WinChecker>("/root/Main/WinChecker");
int bestIndex = 0;
int bestWaitingCount = -1;
// 尝试打出每一张牌,计算剩余手牌的"听牌数"
for (int i = 0; i < hand.Count; i++)
{
// 临时移除这张牌
var testHand = new List<Tile>(hand);
testHand.RemoveAt(i);
// 计算听牌数(有多少种牌能让手牌胡)
int waitingCount = CountWaitingTiles(testHand, melds, winChecker);
GD.Print($"[AI-困难] 打出 {hand[i]} → 听牌数: {waitingCount}");
if (waitingCount > bestWaitingCount)
{
bestWaitingCount = waitingCount;
bestIndex = i;
}
}
GD.Print($"[AI-困难] 最佳出牌: {hand[bestIndex]} " +
$"(听牌数:{bestWaitingCount})");
return bestIndex;
}
/// <summary>
/// 计算听牌数
/// 遍历所有可能的牌,看有多少种能让手牌胡
/// </summary>
private int CountWaitingTiles(List<Tile> hand, List<Meld> melds,
WinChecker winChecker)
{
int count = 0;
var existingIds = new HashSet<int>();
foreach (var t in hand) existingIds.Add(t.Id);
// 遍历所有27种牌
for (int suit = 0; suit < 3; suit++)
{
for (int number = 1; number <= 9; number++)
{
var testTile = new Tile((TileSuit)suit, number);
var testHand = new List<Tile>(hand) { testTile };
if (winChecker.CanWin(testHand, melds))
{
// 检查这种牌是否还有剩余(已出的牌不计)
// 简化处理:假设所有牌都可能摸到
count++;
}
}
}
return count;
}GDScript
## 困难AI出牌策略
## 选择听牌数最多的出牌方案
func _hard_discard(hand: Array, melds: Array) -> int:
var win_checker = get_node("/root/Main/WinChecker")
var best_index: int = 0
var best_waiting_count: int = -1
# 尝试打出每一张牌,计算剩余手牌的"听牌数"
for i in range(hand.size()):
# 临时移除这张牌
var test_hand: Array = hand.duplicate()
test_hand.remove_at(i)
# 计算听牌数
var waiting_count: int = _count_waiting_tiles(test_hand, melds, win_checker)
print("[AI-困难] 打出 %s → 听牌数: %d" % [hand[i], waiting_count])
if waiting_count > best_waiting_count:
best_waiting_count = waiting_count
best_index = i
print("[AI-困难] 最佳出牌: %s (听牌数:%d)" % [hand[best_index], best_waiting_count])
return best_index
## 计算听牌数
## 遍历所有可能的牌,看有多少种能让手牌胡
func _count_waiting_tiles(hand: Array, melds: Array, win_checker) -> int:
var count: int = 0
# 遍历所有27种牌
for suit in range(3):
for number in range(1, 10):
var test_tile := Tile.new(suit, number)
var test_hand: Array = hand.duplicate()
test_hand.append(test_tile)
if win_checker.can_win(test_hand, melds):
count += 1
return count吃碰杠决策
AI也需要决定是否要吃、碰或杠。
C
/// <summary>
/// AI决定是否碰牌
/// </summary>
public bool ShouldPeng(List<Tile> hand, Tile tile, AIDifficulty difficulty)
{
return difficulty switch
{
AIDifficulty.Easy => _random.Next(2) == 0, // 50%概率碰
AIDifficulty.Medium => true, // 中等AI总是碰
AIDifficulty.Hard => _shouldPengHard(hand, tile),
_ => false
};
}
/// <summary>
/// 困难AI的碰牌决策
/// 碰牌后检查是否能更快听牌
/// </summary>
private bool _shouldPengHard(List<Tile> hand, Tile tile)
{
// 模拟碰牌后的手牌
var testHand = new List<Tile>(hand);
for (int i = testHand.Count - 1; i >= 0; i--)
{
if (testHand[i].Id == tile.Id)
{
testHand.RemoveAt(i);
if (testHand.Count <= hand.Count - 2) break;
}
}
// 碰牌后手牌数减少,需要多出一张
// 简化:如果碰后手牌数 <= 2,不碰(太少了)
if (testHand.Count <= 2) return false;
return true;
}
/// <summary>
/// AI决定是否吃牌
/// </summary>
public bool ShouldChi(List<Tile> hand, Tile tile, AIDifficulty difficulty)
{
return difficulty switch
{
AIDifficulty.Easy => _random.Next(3) == 0, // 33%概率吃
AIDifficulty.Medium => _random.Next(2) == 0, // 50%概率吃
AIDifficulty.Hard => true, // 困难AI总是吃有利牌
_ => false
};
}
/// <summary>
/// AI决定是否杠牌
/// 杠牌通常总是有利(多摸一张牌)
/// </summary>
public bool ShouldGang(List<Tile> hand, Tile tile, AIDifficulty difficulty)
{
return difficulty switch
{
AIDifficulty.Easy => _random.Next(3) != 0, // 67%概率杠
_ => true, // 中等和困难AI总是杠
};
}GDScript
## AI决定是否碰牌
func should_peng(hand: Array, tile: Tile, diff: int) -> bool:
match diff:
AIDifficulty.EASY:
return _rng.randi_range(0, 1) == 0 # 50%概率碰
AIDifficulty.MEDIUM:
return true # 中等AI总是碰
AIDifficulty.HARD:
return _should_peng_hard(hand, tile)
return false
## 困难AI的碰牌决策
func _should_peng_hard(hand: Array, tile: Tile) -> bool:
# 模拟碰牌后的手牌
var test_hand: Array = hand.duplicate()
var removed: int = 0
var i: int = test_hand.size() - 1
while i >= 0 and removed < 2:
if test_hand[i].id == tile.id:
test_hand.remove_at(i)
removed += 1
i -= 1
# 碰牌后手牌太少就不碰
if test_hand.size() <= 2:
return false
return true
## AI决定是否吃牌
func should_chi(hand: Array, tile: Tile, diff: int) -> bool:
match diff:
AIDifficulty.EASY:
return _rng.randi_range(0, 2) == 0 # 33%概率吃
AIDifficulty.MEDIUM:
return _rng.randi_range(0, 1) == 0 # 50%概率吃
AIDifficulty.HARD:
return true
return false
## AI决定是否杠牌
func should_gang(hand: Array, tile: Tile, diff: int) -> bool:
match diff:
AIDifficulty.EASY:
return _rng.randi_range(0, 2) != 0 # 67%概率杠
_:
return true # 中等和困难AI总是杠AI自动操作流程
当轮到AI出牌时,需要自动执行出牌决策:
C
/// <summary>
/// AI自动执行出牌
/// </summary>
public async void AIPlayTurn(int player)
{
var gameManager = GetNode<GameManager>("/root/Main/GameManager");
var hand = gameManager.GetHand(player);
var melds = gameManager.GetMelds(player);
// 等待一小段时间,模拟"思考"
await ToSignal(GetTree().CreateTimer(0.8), SceneTree.TimerSignalName.Timeout);
// AI决定出哪张牌
int discardIndex = DecideDiscard(hand, melds);
// 执行出牌
gameManager.DiscardTile(discardIndex);
}GDScript
## AI自动执行出牌
func ai_play_turn(player: int) -> void:
var game_manager = get_node("/root/Main/GameManager")
var hand: Array = game_manager.get_hand(player)
var melds: Array = game_manager.get_melds(player)
# 等待一小段时间,模拟"思考"
await get_tree().create_timer(0.8).timeout
# AI决定出哪张牌
var discard_index: int = decide_discard(hand, melds)
# 执行出牌
game_manager.discard_tile(discard_index)本章小结
| 完成项 | 说明 |
|---|---|
| 三级难度 | 简单(随机)、中等(搭子分析)、困难(听牌计算) |
| 简单AI | 随机出牌,随机吃碰杠 |
| 中等AI | 评估牌面价值,打出孤张 |
| 困难AI | 遍历所有出牌方案,选择听牌数最多的 |
| 碰杠决策 | 根据难度调整概率 |
| AI流程 | 延迟模拟思考,自动执行操作 |
下一章,我们将完善游戏界面——四人牌桌布局、手牌排列、出牌区域和吃碰杠按钮。
