引言
炸弹猫(Exploding Kittens)是一款风靡全球的卡牌桌游,玩家轮流抽牌,抽到”炸弹猫”即出局,除非手中有”拆除”卡。本文用Java实现炸弹猫游戏的核心AI决策逻辑,重点讲解条件概率计算如何实时评估抽牌风险,以及期望值最大化如何帮助AI做出最优出牌与抽牌决策。
问题建模
将游戏简化为以下核心元素:
– 牌堆:剩余 N 张未知卡牌,其中包含 B 张炸弹猫
– 手牌:玩家可见的私人手牌(拆除卡、窥视卡、洗牌卡等)
– 弃牌堆:已知的公开信息,可推算剩余牌堆组成
– 特殊卡牌效果:
– 拆除卡:抵消炸弹猫,并将炸弹猫秘密放回牌堆任意位置
– 窥视卡:查看牌堆顶部3张牌
– 洗牌卡:打乱牌堆顺序
– skip卡:跳过抽牌阶段
AI 的核心决策问题是:在当前已知信息下,是否抽牌?如果抽牌,风险有多大?
核心算法一:贝叶斯概率与牌堆推断
基于弃牌堆和已使用的卡牌,AI 需要持续更新对牌堆组成的概率估计。
import java.util.*;
public class ExplodingKittensAI {
// 卡牌类型
public enum CardType {
EXPLODING_KITTEN, DEFUSE, PEEK, SHUFFLE, SKIP, ATTACK, SEE_FUTURE, FAVOR
}
// 每种卡牌的总数量(标准游戏规则)
private static final Map<CardType, Integer> TOTAL_COUNTS = Map.of(
CardType.EXPLODING_KITTEN, 4,
CardType.DEFUSE, 6,
CardType.PEEK, 5,
CardType.SHUFFLE, 4,
CardType.SKIP, 4,
CardType.ATTACK, 4,
CardType.SEE_FUTURE, 5,
CardType.FAVOR, 4
);
private List<CardType> deck; // 牌堆(从底到顶)
private List<CardType> discardPile; // 弃牌堆
private List<CardType> hand; // AI手牌
private int totalPlayers; // 玩家总数
private int knownDefusesUsed; // 已知已使用的拆除卡数量
public ExplodingKittensAI(int totalPlayers) {
this.totalPlayers = totalPlayers;
this.deck = new ArrayList<>();
this.discardPile = new ArrayList<>();
this.hand = new ArrayList<>();
this.knownDefusesUsed = 0;
}
/**
* 基于贝叶斯推断,计算牌堆中炸弹猫的期望数量
* 考虑因素:弃牌堆信息、手牌信息、已知的拆除卡使用
*/
public double estimateExplodingKittensInDeck() {
int totalKittens = TOTAL_COUNTS.get(CardType.EXPLODING_KITTEN);
int totalDefuses = TOTAL_COUNTS.get(CardType.DEFUSE);
// 从弃牌堆中统计已出现的炸弹猫和拆除卡
int kittensInDiscard = countInDiscard(CardType.EXPLODING_KITTEN);
int defusesInDiscard = countInDiscard(CardType.DEFUSE);
int defusesInHand = countInHand(CardType.DEFUSE);
// 已知已被拆除卡抵消的炸弹猫(通过弃牌堆中的拆除卡推断)
int defusedKittens = Math.min(defusesInDiscard, totalKittens);
// 剩余炸弹猫的期望数量
int remainingKittens = totalKittens - kittensInDiscard - defusedKittens;
// 考虑牌堆总张数,做比例修正
int knownCards = deck.size() + discardPile.size() + hand.size();
int totalCards = TOTAL_COUNTS.values().stream().mapToInt(Integer::intValue).sum();
// 如果牌堆大小已知,直接按比例估算
if (!deck.isEmpty()) {
double ratio = (double) deck.size() / (totalCards - discardPile.size() - hand.size());
return Math.max(0, remainingKittens * ratio);
}
return Math.max(0, remainingKittens);
}
private int countInDiscard(CardType type) {
return (int) discardPile.stream().filter(c -> c == type).count();
}
private int countInHand(CardType type) {
return (int) hand.stream().filter(c -> c == type).count();
}
/**
* 计算从牌堆顶部抽一张牌时,抽到炸弹猫的概率
* 使用超几何分布思想:剩余炸弹猫 / 剩余未知牌数
*/
public double drawExplosionProbability() {
if (deck.isEmpty()) return 0.0;
double estimatedKittens = estimateExplodingKittensInDeck();
return estimatedKittens / deck.size();
}
核心算法二:期望值决策模型
AI 需要在每一轮评估”抽牌”vs”使用skip/其他卡”的期望收益。
/**
* 计算当前状态下,执行某一动作的期望收益
* @param action 动作类型
* @return 期望收益值(越高越好)
*/
public double calculateExpectedValue(Action action) {
double survivalProb = 1.0;
double futureValue = 0.0;
switch (action) {
case DRAW:
// 直接抽牌的期望
double expProb = drawExplosionProbability();
int defuseCount = countInHand(CardType.DEFUSE);
if (expProb == 0) {
// 安全抽牌
survivalProb = 1.0;
futureValue = 0.5; // 保留手牌的价值
} else if (defuseCount > 0) {
// 有拆除卡兜底,可以冒险
survivalProb = 1.0;
// 但消耗了一张宝贵的拆除卡
futureValue = -2.0 * expProb;
} else {
// 无拆除卡,抽到炸弹即死
survivalProb = 1.0 - expProb;
futureValue = -10.0 * expProb; // 死亡惩罚
}
break;
case PLAY_SKIP:
// 使用SKIP跳过抽牌
survivalProb = 1.0;
futureValue = 1.0; // 安全度过一轮
break;
case PLAY_PEEK:
// 使用窥视卡查看顶部3张
survivalProb = 1.0;
// 信息价值:后续可以更准确地决策
futureValue = 1.5 * Math.min(3, deck.size()) / 3.0;
break;
case PLAY_SHUFFLE:
// 洗牌打乱顺序
survivalProb = 1.0;
// 当已知顶部有炸弹时洗牌有价值
futureValue = hasFutureInfoAboutTop() ? 2.0 : 0.2;
break;
case PLAY_SEE_FUTURE:
// 查看未来(看牌堆顶5张)
survivalProb = 1.0;
futureValue = 2.0 * Math.min(5, deck.size()) / 5.0;
break;
}
return survivalProb * 10.0 + futureValue;
}
/**
* AI主决策函数:选择期望收益最高的动作
*/
public Action chooseBestAction() {
Action[] candidates = Action.values();
Action best = Action.DRAW;
double bestValue = Double.NEGATIVE_INFINITY;
for (Action a : candidates) {
// 检查动作是否合法(手牌中是否有对应卡牌)
if (!isActionValid(a)) continue;
double ev = calculateExpectedValue(a);
if (ev > bestValue) {
bestValue = ev;
best = a;
}
}
return best;
}
private boolean isActionValid(Action action) {
return switch (action) {
case DRAW -> true;
case PLAY_SKIP -> countInHand(CardType.SKIP) > 0;
case PLAY_PEEK -> countInHand(CardType.PEEK) > 0;
case PLAY_SHUFFLE -> countInHand(CardType.SHUFFLE) > 0;
case PLAY_SEE_FUTURE -> countInHand(CardType.SEE_FUTURE) > 0;
};
}
private boolean hasFutureInfoAboutTop() {
// 如果之前用过SEE_FUTURE或PEEK,可能对顶部有信息
// 简化实现:假设没有持久化记忆
return false;
}
enum Action {
DRAW, PLAY_SKIP, PLAY_PEEK, PLAY_SHUFFLE, PLAY_SEE_FUTURE
}
蒙特卡洛模拟验证
为了验证AI决策的合理性,可以用蒙特卡洛模拟大量对局,统计不同策略的胜率。
/**
* 蒙特卡洛模拟:在当前状态下模拟多次抽牌结果
* @param simulations 模拟次数
* @return 生存概率估计
*/
public double monteCarloSurvivalRate(int simulations) {
int survived = 0;
int deckSize = deck.size();
double kittenProb = drawExplosionProbability();
int defuseCount = countInHand(CardType.DEFUSE);
Random rand = new Random();
for (int i = 0; i < simulations; i++) {
boolean exploded = rand.nextDouble() < kittenProb;
if (!exploded) {
survived++;
} else if (defuseCount > 0) {
survived++; // 用拆除卡存活
}
// 若爆炸且无拆除卡,则死亡
}
return (double) survived / simulations;
}
复杂度分析
| 算法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 牌堆概率估计 | O(D) | O(1) |
| 期望收益计算 | O(1) | O(1) |
| 蒙特卡洛模拟 | O(S) | O(1) |
其中 D 为弃牌堆大小,S 为模拟次数。由于卡牌种类固定且数量不多,所有计算都在常数时间内完成,非常适合实时游戏AI。
总结
炸弹猫游戏的AI核心在于不完全信息下的概率推理。通过持续跟踪弃牌堆和手牌信息,AI 可以用贝叶斯方法更新对牌堆组成的估计;再通过期望值模型比较”冒险抽牌”与”保守出牌”的收益。当炸弹猫概率超过阈值且手中无拆除卡时,AI 会果断使用 SKIP 等防御卡牌——这种”概率驱动决策”的思路,同样适用于任何带有随机抽牌机制的策略游戏设计。