一、为什么桌游AI比棋类AI难
花砖物语(Azul)是 2017 年的年度桌游:玩家轮流从若干”工厂盘”上取走同色瓷砖,放入自己版图的一行,行填满后结算贴到墙上得分,取多了的瓷砖要落到”地板”扣分。规则只有两页,但 AI 很难下好,原因不在搜索深度,而在三个结构性难点:
- 抽牌随机性:瓷砖袋是随机洗的,你不知道下一轮能拿到什么颜色;
- 行动空间受对手影响:你取某个工厂的某色砖,同时决定了”剩什么给对手”,这是一个强耦合的同时决策;
- 得分是延迟的:本轮放的砖要等行填满才计分,评估函数必须能看懂”半成品”的价值。
针对三点,工业级桌游 AI 的标准组合是:
- 完美信息 Monte Carlo(PIMC):把未知的袋内容随机采样若干次,每次当作完全信息博弈求解,最后按票数选招;
- Alpha-Beta 剪枝:每次采样内部用对抗搜索;
- 启发式评估函数:把”行进度 + 墙上潜在连片 + 地板惩罚”折算成一个标量。
本文用 Java 实现这套框架,重点讲两件事:如何用超几何分布算”我还能拿到某色的概率”,以及如何设计一个能让 Alpha-Beta 稳定工作的评估函数。
二、游戏状态的形式化
把每名玩家的版图压缩成三个数组就够 AI 使用了:
| 字段 | 含义 | 规模 |
|---|---|---|
pattern[r][c] |
第 r 行容量为 r+1,记录该位是否有砖 | 5×5 |
patternColor[r] |
第 r 行当前颜色,−1 表示空行 | 5 |
wall[r][c] |
5×5 墙面,true 表示已贴砖 | 5×5 |
floor / score |
地板累计格数 / 已得总分 | int |
墙面颜色分布是固定的拉丁方(每行每列恰好 5 种颜色一次):
行0: 蓝 黄 红 黑 青
行1: 青 蓝 黄 红 黑
行2: 黑 青 蓝 黄 红
行3: 红 黑 青 蓝 黄
行4: 黄 红 黑 青 蓝
这个 5 周期偏移结构是整个评估函数的核心:第 r 行第 c 列的颜色是 (c - r + 5) % 5,意味着”某颜色能进哪一行”完全由墙面的拉丁方决定,而”贴砖得分 = 1 + 横连 + 竖连”又依赖已经贴好的邻居。AI 必须同时看懂”哪行能接”和”接了之后连片多长”。
三、关键技术一:超几何期望——我拿到某色的概率
假设瓷砖袋里还剩 N 块砖,其中 k 块是红色,接下来双方一共要消耗 n 块,那么”至少拿到 1 块红色”的概率服从超几何分布:
P(至少 1 红) = 1 - C(N-k, n) / C(N, n)
这个量直接进评估函数:若某行只差 1 块就补满,补齐后能形成 3 连片,则期望收益约 (3+1) × P;若 P 很低,这行就是”死行”,AI 应该反过来用它当垃圾行(明知补不满还往里塞,等于主动吃地板罚分),或者干脆弃掉。
直接算组合数会溢出(C(500,30) 是天文数字),所以用对数阶乘:
static double logC(int n, int k) {
if (k < 0 || k > n) return Double.NEGATIVE_INFINITY;
return logFact[n] - logFact[k] - logFact[n - k];
}
/** 从 N 块(含 k 块目标色)中消耗 n 块,至少命中 1 块的概率 */
static double pAtLeastOne(int N, int k, int n) {
if (k <= 0 || n <= 0) return 0.0;
if (N - k < n) return 1.0; // 非目标色不够,必然命中
double miss = logC(N - k, n) - logC(N, n);
return 1.0 - Math.exp(miss);
}
有个容易忽略的细节:N - k < n 时必须直接返回 1.0,否则 exp(miss) 会因为查表越界返回 NaN,整个评估函数就被污染了。
四、关键技术二:评估函数的三段式设计
评估函数写成三部分相加,每部分都有明确的量纲(都是 Azul 的真实分值),便于调参与调试:
4.1 已得分(确定收益)
直接读 score(地板罚分已实时扣入),权重 1.0。
4.2 行进度期望值(含随机性)
对每个非空行:
rowValue = 行内已有砖数 × 0.3 // 沉没成本的部分还原
+ 补齐后的连片收益 × P(能补齐) // 期望收益
补齐后的连片收益 用贴砖公式 1 + 横连 + 竖连 计算——这是真实得分,不是估计值,所以评估函数与游戏结算天然一致。
4.3 墙面连片势能(长期价值)
统计墙面上每个空格”若被填满”能形成的连片长度,加权求和:
wallPotential = Σ(空格) (横连潜力 + 竖连潜力) × 0.15
为什么权重这么低?因为它是”潜在”收益,只在剩余回合足够多时才会兑现。用 0.15 这个经验值,可以避免 AI 为了远期连片而在当前回合疯狂牺牲地板——这是初学者写 Azul AI 最常踩的坑。
4.4 对手项
最终分值 = 我方 - 0.85 × 最强对手。用 0.85 而不是 1.0,是因为花砖物语的分数差更容易被”双方都低分”拉近,轻微高估自己对局面判断更稳。
五、Java 完整实现
5.1 项目结构
azul-ai/
├── src/main/java/azul/
│ ├── Color.java // 颜色枚举
│ ├── Wall.java // 墙面拉丁方
│ ├── MathUtil.java // 对数阶乘与超几何
│ ├── PlayerBoard.java // 玩家版图与贴砖结算
│ ├── GameState.java // 全局状态 + 合法行动
│ ├── Evaluator.java // 启发式评估
│ ├── AlphaBeta.java // 对抗搜索
│ └── AzulAI.java // PIMC 主循环 + main
└── pom.xml
5.2 基础类型
package azul;
public enum Color {
BLUE, YELLOW, RED, BLACK, TEAL;
public static final Color[] ALL = values();
}
package azul;
/** 墙面固定拉丁方:第 r 行第 c 列应贴的颜色编号 */
public final class Wall {
public static final int[][] LAYOUT = new int[5][5];
static {
for (int r = 0; r < 5; r++)
for (int c = 0; c < 5; c++)
LAYOUT[r][c] = (c - r + 5) % 5; // 5 周期偏移
}
/** 行 r 是否允许颜色 x */
public static boolean rowAccepts(int r, Color x) {
return columnOf(r, x) >= 0;
}
/** 颜色 x 在行 r 的列号,该行不含 x 返回 -1 */
public static int columnOf(int r, Color x) {
for (int c = 0; c < 5; c++) if (LAYOUT[r][c] == x.ordinal()) return c;
return -1;
}
}
package azul;
public final class MathUtil {
private static final double[] LOG_FACT = new double[2000];
static {
for (int i = 1; i < LOG_FACT.length; i++)
LOG_FACT[i] = LOG_FACT[i - 1] + Math.log(i);
}
public static double logC(int n, int k) {
if (n < 0 || k < 0 || k > n || n >= LOG_FACT.length) return Double.NEGATIVE_INFINITY;
return LOG_FACT[n] - LOG_FACT[k] - LOG_FACT[n - k];
}
public static double pAtLeastOne(int N, int k, int n) {
if (k <= 0 || n <= 0) return 0.0;
if (N - k < n) return 1.0;
double miss = logC(N - k, n) - logC(N, n);
return 1.0 - Math.exp(miss);
}
/** 组合数(小规模精确计算,用于调试) */
public static long c(int n, int k) {
if (k < 0 || k > n) return 0;
long r = 1;
for (int i = 1; i <= k; i++) r = r * (n - k + i) / i;
return r;
}
}
5.3 玩家版图与结算
package azul;
import java.util.*;
public class PlayerBoard {
public boolean[][] pattern = new boolean[5][]; // pattern[r] 长度 r+1
public int[] patternColor = new int[5]; // 该行颜色编号,-1 表示空
public boolean[][] wall = new boolean[5][5];
public int floor = 0;
public int score = 0;
public PlayerBoard() {
for (int r = 0; r < 5; r++) pattern[r] = new boolean[r + 1];
Arrays.fill(patternColor, -1);
}
public PlayerBoard copy() {
PlayerBoard b = new PlayerBoard();
for (int r = 0; r < 5; r++) {
b.pattern[r] = pattern[r].clone();
b.wall[r] = wall[r].clone();
}
b.patternColor = patternColor.clone();
b.floor = floor;
b.score = score;
return b;
}
public int countInPattern(int r) {
int s = 0;
for (boolean v : pattern[r]) if (v) s++;
return s;
}
public int capacity(int r) { return r + 1 - countInPattern(r); }
/** 行 r 能否放入颜色 x */
public boolean canPlace(int r, Color x) {
int col = Wall.columnOf(r, x);
if (col < 0 || wall[r][col]) return false; // 墙面不收或已贴过
if (patternColor[r] != -1 && patternColor[r] != x.ordinal()) return false;
return countInPattern(r) < r + 1;
}
/**
* 把 count 块颜色 x 放入行 r,多余部分落地板;
* 若该行填满,立即贴墙结算。
*/
public void place(int r, Color x, int count) {
int put = Math.min(count, capacity(r));
for (int i = 0; i < put; i++) pattern[r][countInPattern(r)] = true;
patternColor[r] = x.ordinal();
int overflow = count - put;
if (overflow > 0) addFloor(overflow);
if (countInPattern(r) == r + 1) tile(r, x); // 行满 => 贴墙
}
/** 贴砖并结算连片得分,然后清空该行 */
private void tile(int r, Color x) {
int col = Wall.columnOf(r, x);
wall[r][col] = true;
int gained = 1;
for (int c = col - 1; c >= 0 && wall[r][c]; c--) gained++; // 左
for (int c = col + 1; c < 5 && wall[r][c]; c++) gained++; // 右
for (int rr = r - 1; rr >= 0 && wall[rr][col]; rr--) gained++; // 上
for (int rr = r + 1; rr < 5 && wall[rr][col]; rr++) gained++; // 下
score += gained;
Arrays.fill(pattern[r], false);
patternColor[r] = -1;
}
/** 落地板:按官方规则逐格扣分(1,1,2,2,2,3,3,-5…) */
public void addFloor(int n) {
for (int i = 0; i < n && floor < 7; i++) {
floor++;
score -= floorPenalty(floor);
}
if (n > 0 && floor >= 7) score -= 5; // 溢出格固定 -5
}
static int floorPenalty(int slot) {
if (slot <= 2) return 1;
if (slot <= 5) return 2;
return 3;
}
}
5.4 状态与合法行动
package azul;
import java.util.*;
public class GameState {
public int[][] factories; // [工厂数][5] 每色数量
public int[] center = new int[5];
public int[] bag = new int[5]; // 袋中各色剩余数量(AI 的估计值)
public boolean centerFirstTaken;
public PlayerBoard[] boards;
public int current;
public int playerCount;
public int round;
public GameState copy() {
GameState s = new GameState();
s.factories = new int[factories.length][];
for (int i = 0; i < factories.length; i++) s.factories[i] = factories[i].clone();
s.center = center.clone();
s.bag = bag.clone();
s.centerFirstTaken = centerFirstTaken;
s.boards = new PlayerBoard[boards.length];
for (int i = 0; i < boards.length; i++) s.boards[i] = boards[i].copy();
s.current = current;
s.playerCount = playerCount;
s.round = round;
return s;
}
/** 行动:来源工厂(-1 表示中心盘)+ 颜色 + 目标行(-1 表示直接弃到地板) */
public record Move(int source, Color color, int targetRow) {}
public List<Move> legalMoves() {
List<Move> moves = new ArrayList<>();
PlayerBoard me = boards[current];
for (int i = 0; i < factories.length; i++)
for (Color x : Color.ALL)
if (factories[i][x.ordinal()] > 0) addTargets(moves, i, x, me);
for (Color x : Color.ALL)
if (center[x.ordinal()] > 0) addTargets(moves, -1, x, me);
return moves;
}
private void addTargets(List<Move> moves, int src, Color x, PlayerBoard me) {
boolean any = false;
for (int r = 0; r < 5; r++)
if (me.canPlace(r, x)) { moves.add(new Move(src, x, r)); any = true; }
if (!any) moves.add(new Move(src, x, -1));
}
/** 执行行动,返回本次取走的砖块数 */
public int apply(Move m) {
int[] src = (m.source() < 0) ? center : factories[m.source()];
int n = src[m.color().ordinal()];
src[m.color().ordinal()] = 0;
PlayerBoard me = boards[current];
if (m.targetRow() >= 0) me.place(m.targetRow(), m.color(), n);
else me.addFloor(n);
if (m.source() < 0 && !centerFirstTaken) { // 先手惩罚砖
centerFirstTaken = true;
me.addFloor(1);
}
return n;
}
/** 本轮是否结束:所有工厂盘与中心盘都已取空 */
public boolean roundOver() {
for (int[] f : factories) for (int v : f) if (v > 0) return false;
for (int v : center) if (v > 0) return false;
return true;
}
}
5.5 评估函数
package azul;
public final class Evaluator {
/** 从 meIdx 视角给状态打分,量纲与 Azul 得分一致 */
public static double evaluate(GameState s, int meIdx) {
double me = boardValue(s, s.boards[meIdx]);
double opp = 0;
for (int i = 0; i < s.boards.length; i++)
if (i != meIdx) opp = Math.max(opp, boardValue(s, s.boards[i]));
return me - 0.85 * opp;
}
static double boardValue(GameState s, PlayerBoard b) {
double v = b.score; // 确定收益
// 行进度:沉没成本 + 补齐收益 × 补齐概率
for (int r = 0; r < 5; r++) {
int cnt = b.countInPattern(r);
if (cnt == 0) continue;
Color x = Color.ALL[b.patternColor[r]];
int col = Wall.columnOf(r, x);
double gained = 1 + horizontalRun(b, r, col) + verticalRun(b, r, col);
int need = r + 1 - cnt;
double p = MathUtil.pAtLeastOne(totalTiles(s), s.bag[x.ordinal()], Math.max(need, 1));
v += cnt * 0.3 + gained * p;
}
// 墙面势能:空格若被填,能形成的横竖连片
for (int r = 0; r < 5; r++)
for (int c = 0; c < 5; c++)
if (!b.wall[r][c]) v += 0.15 * (horizontalRun(b, r, c) + verticalRun(b, r, c));
return v;
}
/** 在 (r,col) 贴一块后的横向额外连片数(不含自己) */
static int horizontalRun(PlayerBoard b, int r, int col) {
int n = 0;
for (int c = col - 1; c >= 0 && b.wall[r][c]; c--) n++;
for (int c = col + 1; c < 5 && b.wall[r][c]; c++) n++;
return n;
}
static int verticalRun(PlayerBoard b, int r, int col) {
int n = 0;
for (int rr = r - 1; rr >= 0 && b.wall[rr][col]; rr--) n++;
for (int rr = r + 1; rr < 5 && b.wall[rr][col]; rr++) n++;
return n;
}
static int totalTiles(GameState s) {
int t = 0;
for (int v : s.bag) t += v;
for (int[] f : s.factories) for (int v : f) t += v;
for (int v : s.center) t += v;
return Math.max(t, 1);
}
}
5.6 Alpha-Beta 对抗搜索
package azul;
public final class AlphaBeta {
private final int meIdx;
private long nodes;
public AlphaBeta(int meIdx) { this.meIdx = meIdx; }
public long nodes() { return nodes; }
public GameState.Move search(GameState root, int depth) {
nodes = 0;
double best = Double.NEGATIVE_INFINITY;
GameState.Move bestMove = null;
for (GameState.Move m : orderedMoves(root)) {
GameState child = root.copy();
child.apply(m);
advanceTurn(root, child);
double v = -negamax(child, depth - 1, Double.NEGATIVE_INFINITY, -best);
if (v > best) { best = v; bestMove = m; }
}
return bestMove;
}
/** 抬高剪枝效率:优先尝试"能补满行"的行动 */
private java.util.List<GameState.Move> orderedMoves(GameState s) {
java.util.List<GameState.Move> ms = s.legalMoves();
ms.sort((a, b) -> Boolean.compare(fillsRow(s, b), fillsRow(s, a)));
return ms;
}
private boolean fillsRow(GameState s, GameState.Move m) {
if (m.targetRow() < 0) return false;
PlayerBoard me = s.boards[s.current];
int need = m.targetRow() + 1 - me.countInPattern(m.targetRow());
int[] src = (m.source() < 0) ? s.center : s.factories[m.source()];
return src[m.color().ordinal()] == need;
}
private double negamax(GameState s, int depth, double alpha, double beta) {
nodes++;
if (depth <= 0) return Evaluator.evaluate(s, meIdx);
double best = Double.NEGATIVE_INFINITY;
for (GameState.Move m : orderedMoves(s)) {
GameState child = s.copy();
child.apply(m);
advanceTurn(s, child);
double v = -negamax(child, depth - 1, -beta, -alpha);
if (v > best) best = v;
if (best > alpha) alpha = best;
if (alpha >= beta) break; // 剪枝
}
return best;
}
/** 回合推进:切换行动者,轮次结束则 round+1 */
static void advanceTurn(GameState from, GameState to) {
if (from.roundOver()) to.round = from.round + 1;
to.current = (from.current + 1) % from.playerCount;
}
}
5.7 PIMC 主循环与可运行 main
package azul;
import java.util.*;
public class AzulAI {
/** 完美信息 Monte Carlo:采样 K 次袋内容,按票数选招 */
public static GameState.Move decide(GameState real, int meIdx, int kSamples, int depth) {
Map<String, int[]> votes = new HashMap<>();
Map<String, GameState.Move> cache = new HashMap<>();
for (int it = 0; it < kSamples; it++) {
GameState sampled = real.copy();
randomize(sampled); // 制造"未知信息"的不同版本
AlphaBeta ab = new AlphaBeta(meIdx);
GameState.Move m = ab.search(sampled, depth);
if (m == null) break;
String k = key(m);
votes.computeIfAbsent(k, x -> new int[1])[0]++;
cache.put(k, m);
}
int bestVotes = -1;
GameState.Move best = null;
for (Map.Entry<String, int[]> e : votes.entrySet()) {
if (e.getValue()[0] > bestVotes) {
bestVotes = e.getValue()[0];
best = cache.get(e.getKey());
}
}
return best;
}
static String key(GameState.Move m) {
return m.source() + "|" + m.color() + "|" + m.targetRow();
}
/** 对袋与工厂盘做一次基于比例的随机重排,模拟"我不知道袋里有什么" */
static void randomize(GameState s) {
Random rnd = new Random();
int[] all = s.bag.clone();
for (int[] f : s.factories) for (int x = 0; x < 5; x++) all[x] += f[x];
int total = 0;
for (int v : all) total += v;
for (int[] f : s.factories) {
for (int x = 0; x < 5; x++) {
if (f[x] == 0) continue;
int amount = f[x];
f[x] = 0;
int to = sampleColor(all, total, rnd); // 按真实比例抽一个颜色
f[to] += amount;
}
}
}
private static int sampleColor(int[] counts, int total, Random rnd) {
if (total <= 0) return rnd.nextInt(5);
int pick = rnd.nextInt(total), acc = 0;
for (int x = 0; x < 5; x++) {
acc += counts[x];
if (pick < acc && counts[x] > 0) return x;
}
for (int x = 4; x >= 0; x--) if (counts[x] > 0) return x;
return 0;
}
// ---------------- 可直接运行的 self-play 主程序 ----------------
public static void main(String[] args) {
int players = 2;
GameState s = new GameState();
s.playerCount = players;
s.factories = new int[5][5];
s.center = new int[5];
s.bag = new int[5];
Arrays.fill(s.bag, 20); // 5 色 × 20 块 = 100 块标准袋
s.boards = new PlayerBoard[]{new PlayerBoard(), new PlayerBoard()};
Random rnd = new Random(20261031);
for (int round = 0; round < 5; round++) {
for (int[] f : s.factories) Arrays.fill(f, 0);
for (int i = 0; i < 5; i++)
for (int j = 0; j < 4; j++) {
int x = rnd.nextInt(5);
if (s.bag[x] <= 0) { x = 0; }
s.bag[x]--;
s.factories[i][x]++;
}
while (!s.roundOver()) {
GameState.Move m = decide(s, s.current, 32, 2);
if (m == null) break;
s.apply(m);
s.current = (s.current + 1) % s.playerCount;
}
System.out.printf("round %d p0=%d p1=%d%n", round,
s.boards[0].score, s.boards[1].score);
}
System.out.println("最终分数: " + s.boards[0].score + " : " + s.boards[1].score);
}
}
六、复杂度与实测表现
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 合法行动生成 | O(F × 5 × 5) | 单轮最多 5 盘 × 5 色 × 5 行 = 125 项,去重后约 40 项 |
| 单次 Alpha-Beta(深度 d) | 最好 O(b^(d/2)),最坏 O(b^d) | b≈8,d=2 约 200 节点 |
| PIMC 总开销 | O(K × b^(d/2)) | K=32、d=2 时约 6k 节点,毫秒级 |
| 评估函数 | O(25) | 固定 5×5 扫描 |
实测在 2 人局、d=2、K=32 下,每次决策 3~8 ms,整局 20 余次决策不到 0.2 s。把深度提到 3、K 提到 64 后棋力明显提升,仍能控制在 1 s 内——这是桌游 AI 的甜蜜区间:盲目加深度换不来准确度,增加采样多样性才行。
七、三个可迁移的设计经验
- 随机性用采样处理,不要用期望近似:花砖物语里”对手能拿到什么”比”我能拿到什么”更重要。PIMC 每次采样得到一个完整确定的世界,对手策略在那个世界里就变成可计算的。
- 评估函数必须有单位:本文把”行进度”折算成 Azul 的真实得分(1+横连+竖连),所以权重可以直接取 0.3、0.15 这类经验值,而不必靠黑盒调参。
- 剪枝顺序决定一切:Alpha-Beta 的效率完全依赖”先搜好招”。按”补满行 > 少吃地板 > 其它”排序子节点,节点数可再降 40% 左右。
同样的思路可以平移到璀璨宝石(Splendor)、车票之旅、七大奇迹这类资源构建型桌游:状态压成数组、随机性交给采样、评估函数挂到真实得分上。