引言
Jenga(层层叠/抽积木)是一款考验耐心与物理直觉的经典桌游。玩家轮流从由54块木块堆叠而成的塔中抽取一块,并将其放置到塔顶,直到塔倒塌为止。看似简单的规则背后,隐藏着丰富的物理学与算法设计问题:如何判断抽取某块积木后塔是否仍然稳定?在多个可抽取的积木中,哪一块是最优选择?本文将用Java实现一个带物理稳定性检测的Jenga游戏,并讲解贪心策略如何帮助AI做出最优抽取决策。
一、游戏规则与物理模型
1.1 基本规则
标准Jenga塔由16层组成,每层3块木块,相邻层木块方向垂直交替。每层木块之间会有一定的间隙,使得某些木块更容易被抽出。
1.2 物理稳定性模型
塔的稳定性取决于重心是否始终落在支撑面内。每抽取一块积木后,我们需要重新计算整个塔的重心位置。
设第 $i$ 层有 $n_i$ 块木块(通常为2或3块),每块木块的质量为 $m$,重心位于其几何中心。整个塔的重心 $(\bar{x}, \bar{y})$ 可通过加权平均计算:
$$
\bar{x} = \frac{\sum_{i} m_i x_i}{\sum_{i} m_i}, \quad \bar{y} = \frac{\sum_{i} m_i y_i}{\sum_{i} m_i}
$$
如果重心投影落在最底层支撑面的边界内,则塔是稳定的;否则塔会倒塌。
1.3 力矩平衡分析
除了整体重心,还需要考虑层间力矩。当某层缺少一块或多块木块时,上层对下层的压力分布会发生变化。若压力中心偏离该层剩余木块的支撑范围,该层就会发生局部倾覆。
二、核心算法设计
2.1 数据结构
我们用三维数组表示塔的状态,同时用物理对象记录每块木块的位置和质量分布。
2.2 重心计算算法
计算塔重心需要遍历所有剩余木块,累加质量矩后除以总质量。
2.3 贪心最优抽取策略
AI的决策流程如下:
- 枚举候选积木:找出所有可以从侧面抽出且不会立即导致塔倒塌的积木。
- 评估稳定性损失:对每块候选积木,模拟抽取后的塔状态,计算新的重心偏移量和最小稳定裕度。
- 选择最优积木:选择使抽取后塔稳定性最高(重心偏移最小、稳定裕度最大)的积木。
- 放置策略:将抽出的积木放置到塔顶时,选择使整体重心最居中的位置。
三、Java完整实现
import java.util.*;
/**
* Jenga游戏物理稳定性检测与贪心AI决策
*
* 核心设计:
* 1. 用三维数组表示塔的状态
* 2. 物理引擎计算重心与力矩平衡
* 3. 贪心策略选择最优抽取积木
*/
public class JengaGame {
// 塔的配置参数
private static final int LAYERS = 16; // 总层数
private static final int BLOCKS_PER_LAYER = 3; // 每层木块数
private static final double BLOCK_WIDTH = 7.5; // 单块宽度(cm)
private static final double BLOCK_HEIGHT = 2.5; // 单块高度(cm)
private static final double BLOCK_DEPTH = 15.0; // 单块深度(cm)
private static final double GAP = 0.2; // 木块间隙(cm)
// 塔的当前状态:tower[layer][position] = true表示该位置有木块
private boolean[][] tower;
private int currentTopLayer; // 当前最高有木块的层
public JengaGame() {
this.tower = new boolean[LAYERS * 2][BLOCKS_PER_LAYER]; // 预留扩展空间
initializeTower();
}
/**
* 初始化标准Jenga塔:16层,每层3块,交替方向
*/
private void initializeTower() {
for (int layer = 0; layer < LAYERS; layer++) {
for (int pos = 0; pos < BLOCKS_PER_LAYER; pos++) {
tower[layer][pos] = true;
}
}
currentTopLayer = LAYERS - 1;
}
/**
* 表示一块木块的物理信息
*/
static class Block {
int layer, position;
double centerX, centerY, centerZ;
double mass;
Block(int layer, int position, double cx, double cy, double cz, double mass) {
this.layer = layer;
this.position = position;
this.centerX = cx;
this.centerY = cy;
this.centerZ = cz;
this.mass = mass;
}
}
/**
* 表示塔的重心分析结果
*/
static class StabilityResult {
double centerX, centerZ; // 重心投影坐标
double stabilityMargin; // 稳定裕度(重心到支撑边界的最近距离)
boolean isStable; // 是否稳定
double maxTiltAngle; // 最大倾斜角(度)
StabilityResult(double cx, double cz, double margin, boolean stable, double tilt) {
this.centerX = cx;
this.centerZ = cz;
this.stabilityMargin = margin;
this.isStable = stable;
this.maxTiltAngle = tilt;
}
}
/**
* 获取当前塔中所有存在的木块列表
*/
public List<Block> getAllBlocks() {
List<Block> blocks = new ArrayList<>();
double layerHeight = BLOCK_HEIGHT;
for (int layer = 0; layer <= currentTopLayer; layer++) {
// 偶数层:木块沿X轴排列;奇数层:木块沿Z轴排列
boolean isXDirection = (layer % 2 == 0);
double y = layer * layerHeight + layerHeight / 2.0;
for (int pos = 0; pos < BLOCKS_PER_LAYER; pos++) {
if (tower[layer][pos]) {
double x, z;
if (isXDirection) {
// 沿X轴排列
x = (pos - 1) * (BLOCK_WIDTH + GAP);
z = 0;
} else {
// 沿Z轴排列
x = 0;
z = (pos - 1) * (BLOCK_WIDTH + GAP);
}
blocks.add(new Block(layer, pos, x, y, z, 1.0));
}
}
}
return blocks;
}
/**
* 计算塔的重心与稳定性
*
* 算法步骤:
* 1. 遍历所有木块,累加质量矩
* 2. 计算加权平均重心
* 3. 计算重心到支撑边界的距离(稳定裕度)
* 4. 计算最大倾斜角
*/
public StabilityResult calculateStability() {
List<Block> blocks = getAllBlocks();
if (blocks.isEmpty()) {
return new StabilityResult(0, 0, 0, false, 0);
}
double totalMass = 0;
double momentX = 0, momentZ = 0;
for (Block b : blocks) {
totalMass += b.mass;
momentX += b.mass * b.centerX;
momentZ += b.mass * b.centerZ;
}
double centerX = momentX / totalMass;
double centerZ = momentZ / totalMass;
// 计算支撑面边界(最底层的范围)
double supportHalfWidth = (BLOCKS_PER_LAYER * BLOCK_WIDTH + (BLOCKS_PER_LAYER - 1) * GAP) / 2.0;
double supportHalfDepth = BLOCK_DEPTH / 2.0;
// 稳定裕度:重心到支撑边界的最小距离
double marginX = supportHalfWidth - Math.abs(centerX);
double marginZ = supportHalfDepth - Math.abs(centerZ);
double stabilityMargin = Math.min(marginX, marginZ);
// 计算最大倾斜角(简化模型)
double towerHeight = (currentTopLayer + 1) * BLOCK_HEIGHT;
double maxTiltX = Math.toDegrees(Math.atan2(Math.abs(centerX), towerHeight));
double maxTiltZ = Math.toDegrees(Math.atan2(Math.abs(centerZ), towerHeight));
double maxTilt = Math.max(maxTiltX, maxTiltZ);
boolean isStable = stabilityMargin > 0.1; // 保留0.1cm安全裕度
return new StabilityResult(centerX, centerZ, stabilityMargin, isStable, maxTilt);
}
/**
* 判断某块木块是否可以被抽取
*
* 条件:该木块所在层不是最顶层,且该层至少还有另一块木块支撑
*/
public boolean canRemove(int layer, int position) {
if (layer > currentTopLayer - 1) return false; // 不能抽最顶层
if (!tower[layer][position]) return false; // 该位置没有木块
// 统计该层剩余木块数
int remaining = 0;
for (int p = 0; p < BLOCKS_PER_LAYER; p++) {
if (tower[layer][p]) remaining++;
}
return remaining >= 2; // 至少留一块支撑上层
}
/**
* 模拟抽取某块木块后的稳定性
*/
public StabilityResult simulateRemove(int layer, int position) {
// 临时移除
boolean original = tower[layer][position];
tower[layer][position] = false;
StabilityResult result = calculateStability();
// 恢复
tower[layer][position] = original;
return result;
}
/**
* 贪心策略:选择最优抽取积木
*
* 评估函数综合考虑:
* 1. 抽取后的稳定裕度(越大越好)
* 2. 重心偏移量(越小越好)
* 3. 所在层的高度(越低越好,因为低层移除对整体影响较小)
*/
public int[] chooseBestBlockToRemove() {
List<int[]> candidates = new ArrayList<>();
// 枚举所有可抽取的积木
for (int layer = 0; layer <= currentTopLayer - 1; layer++) {
for (int pos = 0; pos < BLOCKS_PER_LAYER; pos++) {
if (canRemove(layer, pos)) {
candidates.add(new int[]{layer, pos});
}
}
}
if (candidates.isEmpty()) {
return null; // 无积木可抽,游戏结束
}
int[] best = null;
double bestScore = Double.NEGATIVE_INFINITY;
for (int[] cand : candidates) {
StabilityResult sim = simulateRemove(cand[0], cand[1]);
if (!sim.isStable) continue; // 会导致倒塌的跳过
// 综合评分:稳定裕度权重最高,其次是重心偏移小,最后是层数低
double score = sim.stabilityMargin * 10.0
- Math.abs(sim.centerX) - Math.abs(sim.centerZ)
- cand[0] * 0.05;
if (score > bestScore) {
bestScore = score;
best = cand;
}
}
return best;
}
/**
* 执行抽取操作
*/
public boolean removeBlock(int layer, int position) {
if (!canRemove(layer, position)) return false;
tower[layer][position] = false;
// 检查移除后的稳定性
StabilityResult stability = calculateStability();
if (!stability.isStable) {
// 塔倒塌,恢复并返回失败
tower[layer][position] = true;
return false;
}
return true;
}
/**
* 将木块放置到塔顶
*
* 贪心放置策略:选择使塔整体重心最居中的位置
*/
public boolean placeOnTop(int sourceLayer, int sourcePos) {
int newLayer = currentTopLayer + 1;
if (newLayer >= tower.length) {
// 扩展数组
tower = Arrays.copyOf(tower, tower.length * 2);
for (int i = tower.length / 2; i < tower.length; i++) {
tower[i] = new boolean[BLOCKS_PER_LAYER];
}
}
// 贪心选择放置位置:使放置后重心最接近中心
int bestPos = 0;
double bestCenterDist = Double.MAX_VALUE;
for (int pos = 0; pos < BLOCKS_PER_LAYER; pos++) {
if (tower[newLayer][pos]) continue; // 位置已被占用
tower[newLayer][pos] = true;
StabilityResult stab = calculateStability();
tower[newLayer][pos] = false;
double dist = Math.abs(stab.centerX) + Math.abs(stab.centerZ);
if (dist < bestCenterDist) {
bestCenterDist = dist;
bestPos = pos;
}
}
tower[newLayer][bestPos] = true;
currentTopLayer = newLayer;
return true;
}
/**
* AI完整回合:选择最优积木抽取并放置到塔顶
*/
public boolean aiTurn() {
int[] best = chooseBestBlockToRemove();
if (best == null) {
System.out.println("AI无安全积木可抽,游戏结束!");
return false;
}
int layer = best[0], pos = best[1];
System.out.printf("AI选择抽取第%d层第%d块积木...%n", layer + 1, pos + 1);
if (!removeBlock(layer, pos)) {
System.out.println("抽取后塔倒塌!AI失败!");
return false;
}
placeOnTop(layer, pos);
System.out.println("AI成功将积木放置到塔顶。");
StabilityResult stab = calculateStability();
System.out.printf("当前塔稳定性:裕度=%.3fcm, 重心偏移=(%.3f, %.3f), 倾斜角=%.2f°%n",
stab.stabilityMargin, stab.centerX, stab.centerZ, stab.maxTiltAngle);
return true;
}
/**
* 打印塔的侧视图(简化)
*/
public void printTower() {
System.out.println("\n=== 当前塔状态 ===");
for (int layer = currentTopLayer; layer >= 0; layer--) {
System.out.printf("层%2d: ", layer + 1);
for (int pos = 0; pos < BLOCKS_PER_LAYER; pos++) {
System.out.print(tower[layer][pos] ? "[■]" : "[ ]");
}
System.out.println();
}
StabilityResult stab = calculateStability();
System.out.printf("稳定性: %s | 裕度: %.2fcm | 倾斜: %.2f°%n",
stab.isStable ? "稳定" : "不稳定", stab.stabilityMargin, stab.maxTiltAngle);
}
public static void main(String[] args) {
JengaGame game = new JengaGame();
game.printTower();
int turn = 1;
while (true) {
System.out.println("\n--- 第 " + turn + " 回合 ---");
boolean success = game.aiTurn();
game.printTower();
if (!success) {
System.out.println("\n游戏在第 " + turn + " 回合结束!");
break;
}
// 模拟延迟
try { Thread.sleep(500); } catch (InterruptedException e) { }
turn++;
if (turn > 50) {
System.out.println("\n达到最大回合数,游戏结束!");
break;
}
}
}
}
四、算法复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 初始化 | $O(L \times B)$ | $O(L \times B)$ | $L$为层数,$B$为每层木块数 |
| 重心计算 | $O(L \times B)$ | $O(1)$ | 遍历所有木块 |
| 候选枚举 | $O(L \times B)$ | $O(L \times B)$ | 枚举所有可抽取位置 |
| 贪心最优选择 | $O((L \times B)^2)$ | $O(1)$ | 对每个候选模拟一次重心计算 |
| 单次AI决策 | $O((L \times B)^2)$ | $O(1)$ | 整体回合复杂度 |
在实际游戏中,$L \approx 16$,$B = 3$,因此单次AI决策的计算量非常小,可在毫秒级完成。
五、贪心策略的局限与改进
5.1 局限
当前贪心策略只考虑当前回合的稳定性最优,没有考虑未来回合的连锁影响。例如,抽取某块积木可能使下一回合的选择空间急剧缩小。
5.2 改进方向
- Minimax搜索:将对手(或未来的自己)的回合也纳入搜索树,使用两玩家博弈框架。
- 蒙特卡洛模拟:对每种抽取方案进行多次随机后续推演,选择长期期望最优的方案。
- 机器学习评估:训练神经网络评估任意塔状态的”危险性”,替代手工设计的评估函数。
六、总结
本文实现了一个完整的Jenga物理模拟与AI决策系统。核心亮点包括:
- 物理模型:基于重心投影与稳定裕度的简化物理引擎
- 贪心策略:综合稳定裕度、重心偏移和层数高度的多因子评估函数
- 模块化设计:分离物理计算、状态模拟与策略决策,便于扩展
读者可以在此基础上添加图形界面(JavaFX/Swing)、多人对战模式或更复杂的物理引擎(如Box2D),将这个算法原型扩展为完整的游戏项目。