每日算法 — 使用java实现别踩白块:快速反应与贪心最优点击序列

别踩白块(Don’t Tap the White Tile)是一款考验反应速度的经典音乐节奏游戏。玩家需要在黑色方块滑落到屏幕底部之前准确点击它们,同时避免触碰到白色方块。本文用Java实现别踩白块游戏的核心算法,重点讲解贪心最优点击序列与动态难度调整策略。

游戏规则与算法建模

游戏界面由4列组成,黑色方块从顶部向底部匀速滑落。核心算法挑战包括:

  1. 点击判定:在方块进入底部判定区域时,判断玩家点击是否准确
  2. 连击判定:连续准确点击的计数与奖励机制
  3. 动态难度:根据玩家表现调整方块下落速度
  4. 最优序列生成:确保生成的方块序列既有挑战性又可完成

贪心最优点击序列生成

为了保证游戏体验,方块生成不能纯随机——可能出现无法完成的序列(如两列同时要求点击)。我们使用贪心策略确保生成的序列始终可完成,同时保持挑战性。

import java.util.*;

/**
 * 别踩白块 - 方块序列生成器
 * 使用贪心策略确保生成的序列可完成且具有节奏感
 */
public class TileSequenceGenerator {

    private static final int COLS = 4;          // 4列
    private static final double MIN_INTERVAL = 0.15; // 最小间隔(秒)
    private static final double MAX_INTERVAL = 0.5;  // 最大间隔(秒)

    static class Tile {
        int col;            // 所在列 0-3
        double spawnTime;   // 生成时间
        double speed;       // 下落速度 (像素/秒)
        int type;           // 0=普通黑块, 1=长条, 2=奖励块

        Tile(int col, double spawnTime, double speed, int type) {
            this.col = col; this.spawnTime = spawnTime; 
            this.speed = speed; this.type = type;
        }
    }

    private Random rand = new Random();
    private double currentTime = 0;

    /**
     * 贪心生成下一批方块
     * 约束1:同一时刻最多1个黑块(避免无法同时点击)
     * 约束2:相邻时间间隔不能太短(给玩家反应时间)
     * 约束3:列分布尽量均匀(避免总是同一列)
     */
    public List<Tile> generateSequence(int count, double baseSpeed) {
        List<Tile> sequence = new ArrayList<>();
        int[] colCount = new int[COLS]; // 记录每列出现次数

        for (int i = 0; i < count; i++) {
            // 贪心选择:选择出现次数最少的列,增加均匀性
            int minCount = Integer.MAX_VALUE;
            List<Integer> candidates = new ArrayList<>();
            for (int c = 0; c < COLS; c++) {
                if (colCount[c] < minCount) {
                    minCount = colCount[c];
                    candidates.clear();
                    candidates.add(c);
                } else if (colCount[c] == minCount) {
                    candidates.add(c);
                }
            }

            int col = candidates.get(rand.nextInt(candidates.size()));
            colCount[col]++;

            // 动态调整间隔:连击多时加快,失误时减慢
            double interval = MIN_INTERVAL + (MAX_INTERVAL - MIN_INTERVAL) 
                              * (1 - (double)i / count);
            currentTime += interval;

            // 随进度逐渐加速
            double speed = baseSpeed * (1 + (double)i / count * 0.5);

            int type = (rand.nextDouble() < 0.1) ? 2 : 0; // 10%概率奖励块
            sequence.add(new Tile(col, currentTime, speed, type));
        }

        return sequence;
    }

    /**
     * 验证序列是否可完成
     * 确保任意两个相邻方块的点击时间差大于人类反应时间(约0.1秒)
     */
    public boolean isSequenceValid(List<Tile> sequence) {
        final double REACTION_TIME = 0.1;
        for (int i = 1; i < sequence.size(); i++) {
            double diff = sequence.get(i).spawnTime - sequence.get(i-1).spawnTime;
            if (diff < REACTION_TIME) return false;
        }
        return true;
    }
}

动态难度调整算法

游戏需要根据玩家表现实时调整难度。我们使用一个基于最近表现评分的滑动窗口机制:

/**
 * 动态难度调整器
 * 根据玩家最近N次点击的准确率调整下落速度
 */
public class DynamicDifficulty {

    private static final int WINDOW_SIZE = 10;    // 评估窗口大小
    private static final double MIN_SPEED = 200;  // 最小速度(像素/秒)
    private static final double MAX_SPEED = 800;  // 最大速度(像素/秒)
    private static final double SPEED_STEP = 30;  // 速度调整步长

    private Queue<Boolean> recentHits = new LinkedList<>(); // 最近点击结果
    private double currentSpeed = 300; // 当前速度

    /**
     * 记录一次点击结果并调整难度
     * @param hit true=准确点击, false=失误
     */
    public void recordHit(boolean hit) {
        recentHits.offer(hit);
        if (recentHits.size() > WINDOW_SIZE) {
            recentHits.poll();
        }

        // 计算最近准确率
        long hits = recentHits.stream().filter(b -> b).count();
        double accuracy = (double) hits / recentHits.size();

        // 贪心调整策略:准确率高则加速,低则减速
        if (accuracy >= 0.9 && recentHits.size() == WINDOW_SIZE) {
            currentSpeed = Math.min(MAX_SPEED, currentSpeed + SPEED_STEP);
        } else if (accuracy < 0.6) {
            currentSpeed = Math.max(MIN_SPEED, currentSpeed - SPEED_STEP * 2);
        }
    }

    public double getCurrentSpeed() { return currentSpeed; }

    /**
     * 计算当前难度等级 (1-10)
     */
    public int getDifficultyLevel() {
        return (int) Math.round((currentSpeed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED) * 9) + 1;
    }
}

点击判定与评分系统

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.*;
import java.util.List;

/**
 * 别踩白块完整游戏实现
 */
public class PianoTilesGame extends JPanel implements ActionListener, MouseListener {

    private static final int WIDTH = 400;
    private static final int HEIGHT = 600;
    private static final int COL_WIDTH = WIDTH / 4;
    private static final int TILE_HEIGHT = 120;
    private static final int HIT_ZONE_Y = HEIGHT - 150; // 判定线位置

    private List<Tile> tiles = new ArrayList<>();
    private Timer timer;
    private double gameTime = 0;
    private int score = 0;
    private int combo = 0;
    private int maxCombo = 0;
    private int missCount = 0;
    private double baseSpeed = 300; // 像素/秒
    private DynamicDifficulty difficulty = new DynamicDifficulty();
    private Random rand = new Random();
    private double nextSpawnTime = 0;

    static class Tile {
        int col;
        double y;       // 当前Y坐标
        double speed;
        boolean clicked;
        boolean missed;
        Color color;

        Tile(int col, double y, double speed) {
            this.col = col; this.y = y; this.speed = speed;
            this.clicked = false; this.missed = false;
            this.color = Color.BLACK;
        }
    }

    public PianoTilesGame() {
        setPreferredSize(new Dimension(WIDTH, HEIGHT));
        setBackground(Color.WHITE);
        addMouseListener(this);
        timer = new Timer(16, this); // ~60 FPS
    }

    /**
     * 贪心策略生成下一个方块
     * 优先选择当前列数最少的列,保证分布均匀
     */
    private void spawnTile() {
        // 统计当前每列的方块数
        int[] colTiles = new int[4];
        for (Tile t : tiles) {
            if (!t.clicked && !t.missed) colTiles[t.col]++;
        }

        // 贪心选择:选择当前方块最少的列
        int minTiles = Integer.MAX_VALUE;
        List<Integer> candidates = new ArrayList<>();
        for (int i = 0; i < 4; i++) {
            if (colTiles[i] < minTiles) {
                minTiles = colTiles[i];
                candidates.clear();
                candidates.add(i);
            } else if (colTiles[i] == minTiles) {
                candidates.add(i);
            }
        }

        int col = candidates.get(rand.nextInt(candidates.size()));
        double speed = difficulty.getCurrentSpeed();
        tiles.add(new Tile(col, -TILE_HEIGHT, speed));
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        double dt = 0.016; // 16ms
        gameTime += dt;

        // 生成新方块
        if (gameTime >= nextSpawnTime) {
            spawnTile();
            // 动态调整生成间隔:速度越快,间隔越短
            double interval = Math.max(0.2, 0.5 - (difficulty.getCurrentSpeed() - 300) / 1000);
            nextSpawnTime = gameTime + interval;
        }

        // 更新方块位置
        Iterator<Tile> it = tiles.iterator();
        while (it.hasNext()) {
            Tile t = it.next();
            t.y += t.speed * dt;

            // 判定失误:方块滑过判定线且未被点击
            if (t.y > HEIGHT && !t.clicked && !t.missed) {
                t.missed = true;
                combo = 0;
                missCount++;
                difficulty.recordHit(false);
            }

            // 移除已滑出屏幕的方块
            if (t.y > HEIGHT + TILE_HEIGHT) {
                it.remove();
            }
        }

        repaint();
    }

    @Override
    public void mousePressed(MouseEvent e) {
        int col = e.getX() / COL_WIDTH;

        // 贪心查找该列最接近判定线的未点击方块
        Tile target = null;
        double minDist = Double.MAX_VALUE;

        for (Tile t : tiles) {
            if (t.col == col && !t.clicked && !t.missed) {
                double dist = Math.abs(t.y - HIT_ZONE_Y);
                if (dist < minDist && dist < TILE_HEIGHT) {
                    minDist = dist;
                    target = t;
                }
            }
        }

        if (target != null) {
            // 判定成功
            target.clicked = true;
            target.color = Color.GRAY;

            // 计算判定精度
            double accuracy = 1 - minDist / TILE_HEIGHT;
            int points = (int) (10 * accuracy);
            if (accuracy > 0.8) {
                combo++;
                points += combo;
                if (combo > maxCombo) maxCombo = combo;
            } else {
                combo = 0;
            }

            score += Math.max(1, points);
            difficulty.recordHit(true);
        } else {
            // 点击空白区域或白块 = 失误
            combo = 0;
            missCount++;
            difficulty.recordHit(false);
        }

        repaint();
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);

        // 绘制列分割线
        g.setColor(Color.LIGHT_GRAY);
        for (int i = 1; i < 4; i++) {
            g.drawLine(i * COL_WIDTH, 0, i * COL_WIDTH, HEIGHT);
        }

        // 绘制判定线
        g.setColor(Color.RED);
        g.drawLine(0, HIT_ZONE_Y, WIDTH, HIT_ZONE_Y);

        // 绘制方块
        for (Tile t : tiles) {
            g.setColor(t.color);
            g.fillRect(t.col * COL_WIDTH + 2, (int)t.y, COL_WIDTH - 4, TILE_HEIGHT);

            if (t.clicked) {
                g.setColor(Color.GREEN);
                g.drawRect(t.col * COL_WIDTH + 2, (int)t.y, COL_WIDTH - 4, TILE_HEIGHT);
            }
        }

        // 绘制UI
        g.setColor(Color.BLACK);
        g.setFont(new Font("Arial", Font.BOLD, 18));
        g.drawString("Score: " + score, 10, 30);
        g.drawString("Combo: " + combo, 10, 55);
        g.drawString("Max Combo: " + maxCombo, 10, 80);
        g.drawString("Speed: " + String.format("%.0f", difficulty.getCurrentSpeed()), 10, 105);
        g.drawString("Miss: " + missCount, 10, 130);
    }

    public void start() { timer.start(); }

    @Override public void mouseClicked(MouseEvent e) {}
    @Override public void mouseReleased(MouseEvent e) {}
    @Override public void mouseEntered(MouseEvent e) {}
    @Override public void mouseExited(MouseEvent e) {}

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("别踩白块 - 贪心策略");
            PianoTilesGame game = new PianoTilesGame();
            frame.add(game);
            frame.pack();
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
            game.start();
        });
    }
}

算法核心要点总结

贪心策略在别踩白块中的应用:

  1. 序列生成贪心:优先选择出现频率最低的列,保证4列分布均匀,避免玩家疲劳
  2. 点击判定贪心:在点击列中,选择距离判定线最近的方块作为目标,最大化判定精度
  3. 难度调整贪心:根据滑动窗口内的准确率,快速响应调整速度,保持心流体验

时间复杂度分析:
– 方块生成:O(4) = O(1),只需遍历4列统计
– 点击判定:O(N),N为当前方块数,通常 N < 20
– 动态难度更新:O(1),滑动窗口大小固定

扩展方向

  1. 多指同时点击:支持同时点击多个黑块,序列生成需要保证相邻方块间隔大于反应时间
  2. 音乐同步:将方块生成与BPM(每分钟节拍数)绑定,实现音乐节奏游戏
  3. 机器学习难度预测:用玩家历史数据训练模型,预测最佳难度曲线