每日算法 — 使用java实现魔法气泡:BFS连通检测与连击深度评估

引言

魔法气泡(Puyo Puyo)是 Compile 于1991年推出的经典连锁消除游戏,以其深度的策略性和华丽的连锁系统闻名。游戏中,玩家控制成对下落的气泡(Puyo),将四个及以上同颜色气泡连在一起即可消除。消除后上方气泡下落,可能触发连锁反应(Chain),产生巨大的攻击压力。本文用 Java 实现魔法气泡的 AI 核心算法,重点讲解BFS连通块检测与连击深度评估。

游戏规则建模

游戏场景为一个 6 列 × 12 行的垂直场地(Field):
– 气泡(Puyo):红、绿、蓝、黄、紫五色,每次下落一对(可以旋转)
– 消除规则:4 个及以上同色气泡在上下左右四连通时消除
– 连锁(Chain):消除后气泡下落,若形成新的 4 连则继续消除,连锁数 +1
– 攻击(Attack):连锁数越高,向对手发送的干扰气泡(Ojama)越多

AI 的目标:选择最优下落位置和旋转角度,最大化连锁深度或即时消除威胁。

核心算法设计

BFS 连通块检测

连通块检测是魔法气泡最基础的算法。对于场地上每个有气泡的格子,使用 BFS 或 Flood Fill 找到其所属的连通区域,统计区域大小(气泡数量)。当区域大小 ≥ 4 时,该区域标记为待消除。

关键细节:
– 四连通:上下左右,不包含对角线
– 颜色严格匹配:只有完全相同颜色的气泡才能连通
– 消除后重力处理:被消除的气泡变为空,上方气泡逐个下落

连击深度评估

连击深度评估是 AI 的核心难点。与简单的单次消除不同,优秀的魔法气泡玩家追求的是”先搭后消”——先在场地上构建多层潜在消除结构,然后一次性触发深层连锁。

我们的评估策略:
1. 即时消除分:当前动作直接消除的气泡数 × 10
2. 一步连锁预测:模拟放置后消除 → 下落 → 再次检测,计算连锁深度
3. 结构潜力分:评估场地上已存在的、差 1-2 个气泡就能形成 4 连的”准连通块”数量
4. 高度风险分:最高气泡列的高度越接近顶部,风险越大
5. 颜色集中度:同色气泡越集中,越容易形成大连锁

Java 实现

import java.util.*;

/**
 * 魔法气泡 AI:BFS连通检测与连击深度评估
 */
public class PuyoPuyoAI {

    static final int ROWS = 12;
    static final int COLS = 6;
    static final int EMPTY = 0;
    static final int RED = 1, GREEN = 2, BLUE = 3, YELLOW = 4, PURPLE = 5;
    static final int OJAMA = 9; // 干扰气泡(灰色)

    // 四连通方向
    static final int[] DX = {0, 0, -1, 1};
    static final int[] DY = {-1, 1, 0, 0};

    int[][] field;

    public PuyoPuyoAI() {
        this.field = new int[ROWS][COLS];
    }

    public PuyoPuyoAI(int[][] field) {
        this.field = new int[ROWS][COLS];
        for (int i = 0; i < ROWS; i++) {
            System.arraycopy(field[i], 0, this.field[i], 0, COLS);
        }
    }

    /**
     * 气泡对:主气泡 + 副气泡,可旋转
     */
    static class PuyoPair {
        int c1, c2; // 两个气泡颜色
        int x;      // 主气泡列(0-5)
        int y;      // 主气泡行(下落中)
        int rotation; // 0=副在上, 1=副在右, 2=副在下, 3=副在左

        PuyoPair(int c1, int c2) {
            this.c1 = c1; this.c2 = c2;
        }

        // 获取两个气泡的相对坐标 (dx, dy)
        int[] getOffset() {
            switch (rotation) {
                case 0: return new int[]{0, -1};  // 上
                case 1: return new int[]{1, 0};   // 右
                case 2: return new int[]{0, 1};   // 下
                case 3: return new int[]{-1, 0};  // 左
                default: return new int[]{0, -1};
            }
        }

        // 获取两个气泡的绝对坐标 (col, row),假设主气泡在 (x, landingY)
        int[][] getAbsolutePositions(int landingY) {
            int[] off = getOffset();
            return new int[][]{{x, landingY}, {x + off[0], landingY + off[1]}};
        }
    }

    /**
     * AI 决策结果
     */
    static class Decision {
        int x, rotation;
        double score;
        int chainDepth;
        int puyosCleared;

        Decision(int x, int rotation) {
            this.x = x; this.rotation = rotation;
        }
    }

    /**
     * AI 主决策函数
     */
    public Decision decideBestMove(int[][] currentField, int c1, int c2) {
        this.field = currentField;
        List<Decision> candidates = new ArrayList<>();

        for (int rot = 0; rot < 4; rot++) {
            PuyoPair pair = new PuyoPair(c1, c2);
            pair.rotation = rot;

            int[] off = pair.getOffset();
            int minX = Math.max(0, -off[0]);
            int maxX = Math.min(COLS - 1, COLS - 1 - off[0]);

            for (int x = minX; x <= maxX; x++) {
                pair.x = x;
                Decision d = evaluatePlacement(pair);
                if (d != null) {
                    d.x = x;
                    d.rotation = rot;
                    candidates.add(d);
                }
            }
        }

        if (candidates.isEmpty()) return null;
        candidates.sort((a, b) -> Double.compare(b.score, a.score));
        return candidates.get(0);
    }

    /**
     * 评估一个放置位置
     */
    Decision evaluatePlacement(PuyoPair pair) {
        int[][] simField = cloneField();

        // 计算两个气泡的下落位置
        int[][] pos = computeLandingPosition(pair, simField);
        if (pos == null) return null;

        // 放置气泡
        simField[pos[0][1]][pos[0][0]] = pair.c1;
        simField[pos[1][1]][pos[1][0]] = pair.c2;

        Decision d = new Decision(pair.x, pair.rotation);

        // 模拟连锁消除
        ChainResult chain = simulateChain(simField);
        d.chainDepth = chain.depth;
        d.puyosCleared = chain.totalCleared;

        // 综合评分
        d.score = computeScore(simField, chain, pos);

        return d;
    }

    /**
     * 计算气泡对的最终落点
     */
    int[][] computeLandingPosition(PuyoPair pair, int[][] simField) {
        int[] off = pair.getOffset();
        int c1x = pair.x;
        int c2x = pair.x + off[0];

        // 找到每列当前高度
        int h1 = getColumnHeight(simField, c1x);
        int h2 = getColumnHeight(simField, c2x);

        int c1y = ROWS - 1 - h1;
        int c2y = ROWS - 1 - h2;

        // 处理垂直放置的情况(副气泡在主气泡下方)
        if (pair.rotation == 2) { // 副在下
            if (c1y >= c2y) {
                c2y = c1y + 1;
                if (c2y >= ROWS) return null;
            }
        } else if (pair.rotation == 0) { // 副在上
            if (c2y >= c1y) {
                c1y = c2y + 1;
                if (c1y >= ROWS) return null;
            }
        }

        // 检查边界
        if (c1y < 0 || c2y < 0 || c1x < 0 || c1x >= COLS || c2x < 0 || c2x >= COLS) {
            return null;
        }

        // 检查目标位置是否已被占据
        if (simField[c1y][c1x] != EMPTY || simField[c2y][c2x] != EMPTY) {
            return null;
        }

        return new int[][]{{c1x, c1y}, {c2x, c2y}};
    }

    int getColumnHeight(int[][] f, int x) {
        int h = 0;
        for (int y = 0; y < ROWS; y++) {
            if (f[y][x] != EMPTY) h++;
        }
        return h;
    }

    /**
     * 模拟连锁消除
     */
    ChainResult simulateChain(int[][] simField) {
        int depth = 0;
        int totalCleared = 0;
        boolean changed;

        do {
            changed = false;
            boolean[][] visited = new boolean[ROWS][COLS];
            boolean[][] toClear = new boolean[ROWS][COLS];

            // BFS 检测所有连通块
            for (int y = 0; y < ROWS; y++) {
                for (int x = 0; x < COLS; x++) {
                    if (simField[y][x] == EMPTY || simField[y][x] == OJAMA || visited[y][x]) continue;

                    List<int[]> group = bfsGroup(simField, x, y, visited);
                    if (group.size() >= 4) {
                        for (int[] p : group) {
                            toClear[p[1]][p[0]] = true;
                        }
                    }
                }
            }

            // 执行消除
            int cleared = 0;
            for (int y = 0; y < ROWS; y++) {
                for (int x = 0; x < COLS; x++) {
                    if (toClear[y][x]) {
                        simField[y][x] = EMPTY;
                        cleared++;
                        changed = true;
                    }
                }
            }

            if (changed) {
                totalCleared += cleared;
                applyGravity(simField);
                depth++;
            }
        } while (changed);

        return new ChainResult(depth, totalCleared);
    }

    /**
     * BFS 找到一个连通块的所有气泡
     */
    List<int[]> bfsGroup(int[][] f, int sx, int sy, boolean[][] visited) {
        List<int[]> group = new ArrayList<>();
        int color = f[sy][sx];
        Queue<int[]> q = new LinkedList<>();
        q.add(new int[]{sx, sy});
        visited[sy][sx] = true;

        while (!q.isEmpty()) {
            int[] cur = q.poll();
            group.add(cur);

            for (int d = 0; d < 4; d++) {
                int nx = cur[0] + DX[d];
                int ny = cur[1] + DY[d];
                if (inBounds(nx, ny) && !visited[ny][nx] && f[ny][nx] == color) {
                    visited[ny][nx] = true;
                    q.add(new int[]{nx, ny});
                }
            }
        }
        return group;
    }

    /**
     * 应用重力:让气泡下落
     */
    void applyGravity(int[][] f) {
        for (int x = 0; x < COLS; x++) {
            int writeY = ROWS - 1;
            for (int y = ROWS - 1; y >= 0; y--) {
                if (f[y][x] != EMPTY) {
                    if (writeY != y) {
                        f[writeY][x] = f[y][x];
                        f[y][x] = EMPTY;
                    }
                    writeY--;
                }
            }
        }
    }

    /**
     * 综合评分
     */
    double computeScore(int[][] simField, ChainResult chain, int[][] placedPos) {
        double score = 0;

        // 1. 连锁深度是最重要指标
        score += chain.depth * chain.depth * 100;

        // 2. 消除气泡数
        score += chain.totalCleared * 10;

        // 3. 结构潜力:统计"准连通块"(大小为2或3的连通块)
        score += countPotentialChains(simField) * 30;

        // 4. 高度风险:最高列越接近顶部风险越大
        int maxHeight = 0;
        for (int x = 0; x < COLS; x++) {
            maxHeight = Math.max(maxHeight, getColumnHeight(simField, x));
        }
        score -= maxHeight * 5;

        // 5. 颜色集中度奖励:同色气泡相邻越多潜力越大
        score += countColorConcentration(simField) * 2;

        // 6. 放置位置奖励:放在中间列通常更灵活
        int centerX = (placedPos[0][0] + placedPos[1][0]) / 2;
        score -= Math.abs(centerX - COLS / 2) * 3;

        return score;
    }

    /**
     * 统计准连通块数量(大小为2或3的连通块)
     */
    int countPotentialChains(int[][] f) {
        int count = 0;
        boolean[][] visited = new boolean[ROWS][COLS];

        for (int y = 0; y < ROWS; y++) {
            for (int x = 0; x < COLS; x++) {
                if (f[y][x] == EMPTY || f[y][x] == OJAMA || visited[y][x]) continue;
                List<int[]> group = bfsGroup(f, x, y, visited);
                if (group.size() == 2 || group.size() == 3) {
                    count++;
                }
            }
        }
        return count;
    }

    /**
     * 统计颜色集中度:同色相邻对的数量
     */
    int countColorConcentration(int[][] f) {
        int count = 0;
        for (int y = 0; y < ROWS; y++) {
            for (int x = 0; x < COLS; x++) {
                if (f[y][x] == EMPTY || f[y][x] == OJAMA) continue;
                // 检查右邻居
                if (x + 1 < COLS && f[y][x + 1] == f[y][x]) count++;
                // 检查下邻居
                if (y + 1 < ROWS && f[y + 1][x] == f[y][x]) count++;
            }
        }
        return count;
    }

    boolean inBounds(int x, int y) {
        return x >= 0 && x < COLS && y >= 0 && y < ROWS;
    }

    int[][] cloneField() {
        int[][] clone = new int[ROWS][COLS];
        for (int i = 0; i < ROWS; i++) {
            System.arraycopy(field[i], 0, clone[i], 0, COLS);
        }
        return clone;
    }

    static class ChainResult {
        int depth, totalCleared;
        ChainResult(int d, int t) { depth = d; totalCleared = t; }
    }

    // 主程序演示
    public static void main(String[] args) {
        int[][] field = new int[ROWS][COLS];

        // 构建一个有潜力的盘面
        field[11][1] = RED; field[11][2] = RED;
        field[10][1] = RED; field[10][2] = RED;
        field[11][3] = BLUE; field[10][3] = BLUE; field[9][3] = BLUE;
        field[11][4] = GREEN; field[10][4] = GREEN;
        field[9][4] = GREEN;

        PuyoPuyoAI ai = new PuyoPuyoAI(field);

        // 当前下落的是红色-蓝色气泡对
        Decision best = ai.decideBestMove(field, RED, BLUE);

        System.out.println("=== 魔法气泡 AI 决策结果 ===");
        if (best != null) {
            System.out.println("最佳动作:列=" + best.x + ", 旋转=" + best.rotation);
            System.out.println("预测连锁深度:" + best.chainDepth);
            System.out.println("预测消除气泡数:" + best.puyosCleared);
            System.out.println("综合评分:" + String.format("%.2f", best.score));
        }
    }
}

算法解析

BFS vs DFS 的选择

在连通块检测中,BFS 和 DFS 都能完成任务,但 BFS 有独特优势:
– BFS 按层扩展,天然适合”找所有距离为 k 的邻居”这类操作
– 在魔法气泡中,连通块通常较小(最大 12×6 = 72 格),BFS 的队列开销可以忽略
– BFS 的迭代顺序更稳定,便于调试和可视化

连锁模拟的核心循环

连锁模拟采用”检测 → 消除 → 重力 → 再检测”的循环模式。每次循环称为一个”Step”,Step 数即为连锁深度。关键在于:
1. 消除必须一次性标记所有满足条件的连通块,不能逐次消除
2. 重力下落要按列独立处理,保持每列内部顺序
3. 干扰气泡(Ojama)不参与任何连通,但会阻挡下落

结构潜力评估

高级魔法气泡 AI 不仅看当前能消多少,更关注”下一步能消多少”。countPotentialChains 统计大小为 2 或 3 的连通块数量,这些是”差一点就消除”的结构。AI 倾向于:
– 将新气泡放在准连通块旁边,为下一手创造消除机会
– 避免破坏已有的准连通结构
– 构建”阶梯式”多层连锁架构

复杂度分析

操作 时间复杂度 空间复杂度
BFS 连通块检测 O(ROWS × COLS) O(ROWS × COLS)
单次连锁模拟 O(K × ROWS × COLS) O(ROWS × COLS)
结构潜力统计 O(ROWS × COLS) O(ROWS × COLS)
完整决策(约20个候选) O(20 × K × ROWS × COLS) O(ROWS × COLS)

对于 12×6 的场地,单次决策在微秒到毫秒级完成,完全满足实时性要求。

扩展思路

  1. 树搜索:用 Minimax 或 Expectimax 搜索未来 2-3 步,考虑对手发送的干扰气泡
  2. 形状识别:识别经典连锁形状(如楼梯、塔、GTR),直接套用最优落点
  3. 深度学习:用强化学习训练神经网络评估盘面价值,AlphaZero 风格的自对弈训练

总结

本文通过 Java 实现了魔法气泡的 AI 核心系统,涵盖:
– BFS 四连通检测:准确识别所有可消除的连通块
– 连锁深度模拟:迭代消除+重力模拟精确预测连锁结果
– 多维度评估函数:兼顾即时消除、连锁深度、结构潜力和风险控制

读者可以从中学习到:BFS 在网格连通性检测中的应用、消除类游戏的连锁模拟技巧、以及贪心评估函数的设计方法论。