UNO 是全球最受欢迎的卡牌游戏之一,玩家需要按颜色或数字匹配出牌,并善用功能牌(跳过、反转、+2、变色、+4)来干扰对手。本文用 Java 实现一个带 AI 的 UNO 对战系统,核心算法涵盖概率驱动的手牌估值、功能牌最优使用时机判断、以及基于蒙特卡洛模拟的出牌决策。
一、游戏规则与状态建模
UNO 牌组包含108张牌:数字牌(0-9,四色各两套)、功能牌(跳过、反转、+2,四色各两套)、万能牌(变色、+4,各四套)。AI 需要维护以下状态信息:
– 已知信息:场上最后出的牌、各玩家手牌数量、自己手牌
– 推断信息:对手可能持有的颜色和数字分布
– 隐藏信息:牌堆剩余牌、对手具体手牌
二、核心算法:概率估计与启发式评估
AI 决策分为三个层次:
1. 合法出牌筛选:找出所有可出的牌
2. 单牌价值评估:为每张可出的牌打分
3. 决策选择:选择分数最高的出牌方案
2.1 对手颜色分布推断
通过观察对手在特定颜色下的出牌行为,推断其缺少的颜色。若对手连续未响应某颜色,则该颜色在其手牌中的概率降低。
2.2 功能牌使用策略
- +2/+4:在对手手牌少时优先使用,或用于改变对自己有利的颜色
- 跳过/反转:在手牌优势明显时使用,或阻断对手的连击
- 变色:选择自己手牌最多的颜色,或推断对手最不缺的颜色
2.3 蒙特卡洛模拟决策
对于关键决策(如是否挑战+4),模拟多种可能的对手手牌配置,评估期望收益。
三、Java 完整实现
import java.util.*;
/**
* UNO AI 对战系统
* 核心算法:概率推断 + 启发式评估 + 蒙特卡洛模拟
*/
public class UnoGame {
// 颜色定义
public enum Color { RED, YELLOW, GREEN, BLUE, WILD }
// 牌类型
public enum Type { NUMBER, SKIP, REVERSE, DRAW_TWO, WILD, WILD_DRAW_FOUR }
public static class Card {
public final Color color;
public final Type type;
public final int number; // 仅数字牌有效,-1表示非数字
public Card(Color color, Type type, int number) {
this.color = color;
this.type = type;
this.number = number;
}
public boolean isWild() {
return color == Color.WILD;
}
public boolean canPlayOn(Card other, Color currentColor) {
if (isWild()) return true;
return this.color == currentColor ||
(this.type == Type.NUMBER && other.type == Type.NUMBER && this.number == other.number) ||
(this.type == other.type && this.type != Type.NUMBER);
}
@Override
public String toString() {
if (isWild()) return type == Type.WILD ? "变色" : "+4";
String colorStr = Map.of(Color.RED, "红", Color.YELLOW, "黄",
Color.GREEN, "绿", Color.BLUE, "蓝").get(color);
if (type == Type.NUMBER) return colorStr + number;
String typeStr = Map.of(Type.SKIP, "跳过", Type.REVERSE, "反转",
Type.DRAW_TWO, "+2").get(type);
return colorStr + typeStr;
}
}
/**
* AI 玩家,基于概率和启发式策略
*/
public static class UnoAI {
private List<Card> hand;
private Random random = new Random();
// 记录已出过的牌,用于推断剩余牌分布
private Map<Color, Integer> seenColorCount = new EnumMap<>(Color.class);
private int totalSeen = 0;
public UnoAI() {
this.hand = new ArrayList<>();
for (Color c : Color.values()) seenColorCount.put(c, 0);
}
public void drawCard(Card card) {
hand.add(card);
}
public List<Card> getHand() { return hand; }
public int handSize() { return hand.size(); }
/**
* 记录观察到的出牌,更新概率模型
*/
public void observePlay(Card card, Color chosenColor) {
Color effectiveColor = card.isWild() ? chosenColor : card.color;
seenColorCount.merge(effectiveColor, 1, Integer::sum);
totalSeen++;
}
/**
* 获取自己手牌中各颜色数量
*/
private Map<Color, Integer> getHandColorDistribution() {
Map<Color, Integer> dist = new EnumMap<>(Color.class);
for (Color c : new Color[]{Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE}) {
dist.put(c, 0);
}
for (Card c : hand) {
if (!c.isWild()) {
dist.merge(c.color, 1, Integer::sum);
}
}
return dist;
}
/**
* 启发式评估单张牌的出牌价值
* @param card 待评估的牌
* @param currentColor 当前生效颜色
* @param nextPlayerHandSize 下家手牌数
* @return 分数,越高越好
*/
private double evaluateCard(Card card, Color currentColor, int nextPlayerHandSize) {
double score = 0;
Map<Color, Integer> myColors = getHandColorDistribution();
// 基础分:出牌即可减少手牌
score += 10;
if (card.type == Type.NUMBER) {
// 数字牌:优先出数量最多的颜色中的数字牌
score += myColors.getOrDefault(card.color, 0) * 2;
// 保留0和特殊数字?不,数字牌价值相近
} else if (card.type == Type.SKIP || card.type == Type.REVERSE) {
// 控制牌:在对手手牌少时更有价值
score += 15 + (nextPlayerHandSize <= 2 ? 20 : 0);
} else if (card.type == Type.DRAW_TWO) {
// +2:对手手牌越少价值越高,同时改变颜色
score += 20 + (nextPlayerHandSize <= 2 ? 25 : 0);
// 如果+2的颜色不是自己的优势色,适当降分
score += myColors.getOrDefault(card.color, 0) * 1.5;
} else if (card.type == Type.WILD) {
// 变色:选择最优颜色
Color bestColor = chooseBestColor();
score += 12 + myColors.getOrDefault(bestColor, 0) * 3;
} else if (card.type == Type.WILD_DRAW_FOUR) {
// +4:最强牌,但需策略保留
score += 30 + (nextPlayerHandSize <= 2 ? 30 : 0);
// 若自己手牌优势明显,可以早点出
if (handSize() <= 3) score += 10;
}
// 惩罚:如果出完这张牌后,剩余牌颜色过于分散,则降分
List<Card> remaining = new ArrayList<>(hand);
remaining.remove(card);
int distinctColors = (int) remaining.stream()
.filter(c -> !c.isWild())
.map(c -> c.color)
.distinct()
.count();
score -= distinctColors * 3;
return score;
}
/**
* 选择变色/+4后的最优颜色
* 策略:选择自己手牌最多的颜色
*/
public Color chooseBestColor() {
Map<Color, Integer> myColors = getHandColorDistribution();
Color best = Color.RED;
int max = -1;
for (Color c : new Color[]{Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE}) {
int count = myColors.getOrDefault(c, 0);
if (count > max) {
max = count;
best = c;
}
}
return best;
}
/**
* 蒙特卡洛模拟:评估在某种出牌后,模拟多局游戏的胜率
* 用于关键决策(如是否出+4)
*/
private double monteCarloEvaluate(Card card, Color currentColor,
int nextPlayerHandSize, int simulations) {
double totalScore = 0;
for (int sim = 0; sim < simulations; sim++) {
// 简化模拟:假设出牌后,评估剩余手牌的期望出牌能力
List<Card> remaining = new ArrayList<>(hand);
remaining.remove(card);
Color newColor = card.isWild() ? chooseBestColor() : card.color;
// 评估剩余牌在当前颜色下的可出比例
long playable = remaining.stream()
.filter(c -> c.canPlayOn(card, newColor))
.count();
double ratio = remaining.isEmpty() ? 1.0 : (double) playable / remaining.size();
totalScore += ratio * 100 - remaining.size() * 5;
}
return totalScore / simulations;
}
/**
* AI 决策主函数:选择最优出牌
* @param topCard 场上顶牌
* @param currentColor 当前生效颜色(考虑变色牌)
* @param nextPlayerHandSize 下家手牌数
* @return 决策结果 [出牌, 若万能牌则返回选择的颜色]
*/
public Object[] makeDecision(Card topCard, Color currentColor, int nextPlayerHandSize) {
List<Card> playable = new ArrayList<>();
for (Card c : hand) {
if (c.canPlayOn(topCard, currentColor)) {
playable.add(c);
}
}
if (playable.isEmpty()) {
return new Object[]{null, null}; // 需要抽牌
}
Card bestCard = null;
double bestScore = Double.NEGATIVE_INFINITY;
for (Card card : playable) {
double score = evaluateCard(card, currentColor, nextPlayerHandSize);
// 对关键牌使用蒙特卡洛增强评估
if (card.type == Type.WILD_DRAW_FOUR || card.type == Type.WILD) {
score += monteCarloEvaluate(card, currentColor, nextPlayerHandSize, 50) * 0.3;
}
if (score > bestScore) {
bestScore = score;
bestCard = card;
}
}
Color chosenColor = null;
if (bestCard != null && bestCard.isWild()) {
chosenColor = chooseBestColor();
}
return new Object[]{bestCard, chosenColor};
}
public void removeCard(Card card) {
hand.remove(card);
}
}
/**
* 游戏引擎:管理牌堆、玩家轮换和规则判定
*/
public static class GameEngine {
private List<Card> deck;
private List<Card> discardPile;
private List<UnoAI> players;
private int currentPlayer;
private int direction; // 1=顺时针, -1=逆时针
private Color currentColor;
private Card topCard;
private Random random = new Random();
public GameEngine(int numPlayers) {
this.deck = createDeck();
this.discardPile = new ArrayList<>();
this.players = new ArrayList<>();
for (int i = 0; i < numPlayers; i++) players.add(new UnoAI());
this.currentPlayer = 0;
this.direction = 1;
shuffleDeck();
dealCards(7);
// 翻出首张牌
do {
topCard = deck.remove(deck.size() - 1);
discardPile.add(topCard);
} while (topCard.isWild());
currentColor = topCard.color;
}
private List<Card> createDeck() {
List<Card> cards = new ArrayList<>();
Color[] colors = {Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE};
for (Color c : colors) {
cards.add(new Card(c, Type.NUMBER, 0)); // 0 只有一张
for (int num = 1; num <= 9; num++) {
cards.add(new Card(c, Type.NUMBER, num));
cards.add(new Card(c, Type.NUMBER, num));
}
for (int i = 0; i < 2; i++) {
cards.add(new Card(c, Type.SKIP, -1));
cards.add(new Card(c, Type.REVERSE, -1));
cards.add(new Card(c, Type.DRAW_TWO, -1));
}
}
for (int i = 0; i < 4; i++) {
cards.add(new Card(Color.WILD, Type.WILD, -1));
cards.add(new Card(Color.WILD, Type.WILD_DRAW_FOUR, -1));
}
return cards;
}
private void shuffleDeck() {
Collections.shuffle(deck, random);
}
private void dealCards(int count) {
for (int i = 0; i < count; i++) {
for (UnoAI player : players) {
if (!deck.isEmpty()) player.drawCard(deck.remove(deck.size() - 1));
}
}
}
private Card drawCard() {
if (deck.isEmpty()) {
// 回收弃牌堆(保留顶牌)
Card top = discardPile.remove(discardPile.size() - 1);
deck.addAll(discardPile);
discardPile.clear();
discardPile.add(top);
shuffleDeck();
}
return deck.isEmpty() ? null : deck.remove(deck.size() - 1);
}
/**
* 执行一局游戏
* @return 获胜玩家编号
*/
public int playGame(boolean verbose) {
int turns = 0;
final int MAX_TURNS = 500;
while (turns < MAX_TURNS) {
UnoAI player = players.get(currentPlayer);
int nextPlayer = (currentPlayer + direction + players.size()) % players.size();
Object[] decision = player.makeDecision(topCard, currentColor,
players.get(nextPlayer).handSize());
Card played = (Card) decision[0];
Color chosenColor = (Color) decision[1];
if (played == null) {
// 无牌可出,抽牌
Card drawn = drawCard();
if (drawn != null) {
player.drawCard(drawn);
if (verbose) System.out.println("玩家" + currentPlayer + " 抽牌");
}
} else {
player.removeCard(played);
discardPile.add(played);
topCard = played;
if (played.isWild()) {
currentColor = chosenColor != null ? chosenColor : Color.RED;
} else {
currentColor = played.color;
}
if (verbose) {
System.out.printf("玩家%d 出 %s (颜色->%s)%n",
currentPlayer, played,
Map.of(Color.RED, "红", Color.YELLOW, "黄",
Color.GREEN, "绿", Color.BLUE, "蓝").getOrDefault(currentColor, "万"));
}
// 通知所有玩家观察此出牌
for (UnoAI p : players) {
p.observePlay(played, currentColor);
}
// 处理功能牌效果
if (played.type == Type.SKIP) {
currentPlayer = (currentPlayer + direction + players.size()) % players.size();
} else if (played.type == Type.REVERSE) {
direction *= -1;
} else if (played.type == Type.DRAW_TWO) {
nextPlayer = (currentPlayer + direction + players.size()) % players.size();
for (int i = 0; i < 2; i++) {
Card c = drawCard();
if (c != null) players.get(nextPlayer).drawCard(c);
}
currentPlayer = nextPlayer;
} else if (played.type == Type.WILD_DRAW_FOUR) {
nextPlayer = (currentPlayer + direction + players.size()) % players.size();
for (int i = 0; i < 4; i++) {
Card c = drawCard();
if (c != null) players.get(nextPlayer).drawCard(c);
}
currentPlayer = nextPlayer;
}
// 检查胜利
if (player.handSize() == 0) {
if (verbose) System.out.println("玩家" + currentPlayer + " 获胜!");
return currentPlayer;
}
}
currentPlayer = (currentPlayer + direction + players.size()) % players.size();
turns++;
}
// 超时,手牌最少者胜
int winner = 0;
int minHand = Integer.MAX_VALUE;
for (int i = 0; i < players.size(); i++) {
if (players.get(i).handSize() < minHand) {
minHand = players.get(i).handSize();
winner = i;
}
}
return winner;
}
}
public static void main(String[] args) {
int wins = 0;
int games = 100;
for (int i = 0; i < games; i++) {
GameEngine engine = new GameEngine(4);
int winner = engine.playGame(false);
if (winner == 0) wins++; // 玩家0是我们的AI
}
System.out.printf("AI 胜率: %d/%d (%.1f%%)%n", wins, games, 100.0 * wins / games);
// 演示一局
System.out.println("\n=== 单局演示 ===");
GameEngine demo = new GameEngine(4);
demo.playGame(true);
}
}
四、复杂度分析
| 模块 | 时间复杂度 | 说明 |
|---|---|---|
| 合法出牌筛选 | O(|H|) | H 为手牌数 |
| 单牌启发式评估 | O(1) | 固定颜色统计计算 |
| 蒙特卡洛模拟 | O(S × |H|) | S 为模拟次数 |
| 整局游戏 | O(T × P × |H|) | T 为回合数,P 为玩家数 |
五、策略要点总结
- 颜色集中原则:尽量让自己手牌颜色集中,减少被颜色卡住的概率
- 功能牌保留:在对手手牌多且分散时保留功能牌,关键时刻一击制胜
- +4 挑战机制:虽然本文简化处理了+4挑战,但实际可通过对手出牌历史推断其是否真的没有该颜色
- UNO 喊牌时机:最后两张牌时优先出功能牌,避免数字牌被+2/+4拦截
六、扩展方向
- 引入对手建模,追踪每位玩家的出牌偏好
- 实现+4挑战的完整规则(质疑对方是否真的没有当前颜色)
- 使用深度强化学习(如PPO)训练更优策略
- 支持多人联机对战框架