每日算法 — 使用java实现节奏地牢:节拍同步与Roguelike地图生成

节奏地牢(Crypt of the NecroDancer)是一款将Roguelike地牢探索与音乐节奏完美结合的创新游戏,玩家必须跟随音乐节拍移动和攻击。本文用Java实现其核心系统:基于BPM的节拍同步引擎、元胞自动机驱动的Roguelike地图生成,以及节拍约束下的A*路径规划。

核心设计思路

1. 节拍同步引擎

游戏内所有动作(移动、攻击、拾取)必须与音乐节拍同步。核心是将实时音频时间映射到离散节拍格子,玩家的每次有效输入只能在”节拍窗口”内生效。

2. Roguelike地图生成

采用元胞自动机(Cellular Automata)生成自然洞穴风格的地牢:先随机填充墙壁,然后通过多轮”出生-死亡”规则平滑化,最后用BFS确保连通性。

3. 节拍约束寻路

敌人AI不能每帧移动,只能在节拍触发时行动。因此寻路算法需要输出节拍数最短路径,而非空间距离最短路径。

完整Java实现

import java.util.*;

/**
 * 节奏地牢核心引擎
 * 包含:节拍同步、元胞自动机地图生成、节拍约束A*寻路
 */
public class CryptEngine {

    // ==================== 节拍同步引擎 ====================
    static class BeatEngine {
        double bpm;              // 每分钟节拍数
        double beatInterval;     // 每拍间隔(秒)
        double currentTime;      // 当前游戏时间
        int currentBeat;         // 当前拍数
        double nextBeatTime;     // 下一拍时间点
        double inputWindow;      // 有效输入窗口(拍前后的容差)

        BeatEngine(double bpm) {
            this.bpm = bpm;
            this.beatInterval = 60.0 / bpm;
            this.inputWindow = beatInterval * 0.25; // ±25%容差
            this.currentTime = 0;
            this.currentBeat = 0;
            this.nextBeatTime = beatInterval;
        }

        void update(double deltaTime) {
            currentTime += deltaTime;
            if (currentTime >= nextBeatTime) {
                currentBeat++;
                nextBeatTime += beatInterval;
                onBeat(currentBeat);
            }
        }

        void onBeat(int beat) {
            System.out.println("♪ Beat " + beat + " (t=" + String.format("%.3f", currentTime) + ")");
        }

        /**
         * 判断当前时间是否在节拍窗口内
         */
        boolean isInBeatWindow() {
            double distToNext = Math.abs(nextBeatTime - currentTime);
            double distToPrev = Math.abs(currentTime - (nextBeatTime - beatInterval));
            return Math.min(distToNext, distToPrev) <= inputWindow;
        }

        /**
         * 获取距下一拍的剩余时间
         */
        double timeToNextBeat() {
            return nextBeatTime - currentTime;
        }
    }

    // ==================== 地图格子类型 ====================
    enum TileType {
        WALL('#'), FLOOR('.'), DOOR('+'), STAIRS('>'), EMPTY(' ');
        final char symbol;
        TileType(char c) { this.symbol = c; }
    }

    // ==================== Roguelike地图 ====================
    static class DungeonMap {
        int width, height;
        TileType[][] grid;
        Random rand;

        DungeonMap(int w, int h, long seed) {
            this.width = w;
            this.height = h;
            this.rand = new Random(seed);
            this.grid = new TileType[h][w];
        }

        // 元胞自动机生成洞穴地牢
        void generateCellularAutomata() {
            // 初始化:随机填充45%为墙壁
            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    if (x == 0 || x == width - 1 || y == 0 || y == height - 1) {
                        grid[y][x] = TileType.WALL; // 边界固定为墙
                    } else {
                        grid[y][x] = rand.nextDouble() < 0.45 ? TileType.WALL : TileType.FLOOR;
                    }
                }
            }

            // 迭代平滑:出生-死亡规则
            for (int step = 0; step < 5; step++) {
                TileType[][] next = new TileType[height][width];
                for (int y = 0; y < height; y++) {
                    for (int x = 0; x < width; x++) {
                        if (x == 0 || x == width - 1 || y == 0 || y == height - 1) {
                            next[y][x] = TileType.WALL;
                            continue;
                        }
                        int wallNeighbors = countWallNeighbors(x, y);
                        if (grid[y][x] == TileType.WALL) {
                            // 墙壁:邻居墙壁<4则变地板(死亡规则)
                            next[y][x] = wallNeighbors < 4 ? TileType.FLOOR : TileType.WALL;
                        } else {
                            // 地板:邻居墙壁>=5则变墙壁(出生规则)
                            next[y][x] = wallNeighbors >= 5 ? TileType.WALL : TileType.FLOOR;
                        }
                    }
                }
                grid = next;
            }

            // 确保连通性:找到最大连通区域,其余填墙
            ensureConnectivity();

            // 放置楼梯
            placeStairs();
        }

        int countWallNeighbors(int x, int y) {
            int count = 0;
            for (int dy = -1; dy <= 1; dy++) {
                for (int dx = -1; dx <= 1; dx++) {
                    if (dx == 0 && dy == 0) continue;
                    int nx = x + dx, ny = y + dy;
                    if (nx < 0 || nx >= width || ny < 0 || ny >= height) {
                        count++;
                    } else if (grid[ny][nx] == TileType.WALL) {
                        count++;
                    }
                }
            }
            return count;
        }

        void ensureConnectivity() {
            boolean[][] visited = new boolean[height][width];
            List<List<int[]>> regions = new ArrayList<>();

            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    if (grid[y][x] == TileType.FLOOR && !visited[y][x]) {
                        List<int[]> region = new ArrayList<>();
                        floodFill(x, y, visited, region);
                        regions.add(region);
                    }
                }
            }

            if (regions.isEmpty()) return;

            // 找到最大区域保留
            List<int[]> largest = regions.get(0);
            for (List<int[]> r : regions) {
                if (r.size() > largest.size()) largest = r;
            }

            // 其他区域填墙
            boolean[][] keep = new boolean[height][width];
            for (int[] p : largest) keep[p[1]][p[0]] = true;

            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    if (grid[y][x] == TileType.FLOOR && !keep[y][x]) {
                        grid[y][x] = TileType.WALL;
                    }
                }
            }
        }

        void floodFill(int sx, int sy, boolean[][] visited, List<int[]> region) {
            Deque<int[]> stack = new ArrayDeque<>();
            stack.push(new int[]{sx, sy});
            while (!stack.isEmpty()) {
                int[] p = stack.pop();
                int x = p[0], y = p[1];
                if (x < 0 || x >= width || y < 0 || y >= height) continue;
                if (visited[y][x] || grid[y][x] != TileType.FLOOR) continue;
                visited[y][x] = true;
                region.add(p);
                stack.push(new int[]{x + 1, y});
                stack.push(new int[]{x - 1, y});
                stack.push(new int[]{x, y + 1});
                stack.push(new int[]{x, y - 1});
            }
        }

        void placeStairs() {
            // 找最远的两个地板点,分别作为起点和终点楼梯
            List<int[]> floors = new ArrayList<>();
            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    if (grid[y][x] == TileType.FLOOR) floors.add(new int[]{x, y});
                }
            }
            if (floors.size() < 2) return;
            int[] start = floors.get(0);
            int[] end = floors.get(floors.size() - 1);
            grid[start[1]][start[0]] = TileType.FLOOR; // 玩家起点
            grid[end[1]][end[0]] = TileType.STAIRS;
        }

        boolean isWalkable(int x, int y) {
            if (x < 0 || x >= width || y < 0 || y >= height) return false;
            return grid[y][x] == TileType.FLOOR || grid[y][x] == TileType.STAIRS;
        }

        void print() {
            for (int y = 0; y < height; y++) {
                for (int x = 0; x < width; x++) {
                    System.out.print(grid[y][x].symbol);
                }
                System.out.println();
            }
        }
    }

    // ==================== 实体基类 ====================
    static class Entity {
        int x, y;
        char symbol;
        String name;

        Entity(String name, int x, int y, char symbol) {
            this.name = name;
            this.x = x;
            this.y = y;
            this.symbol = symbol;
        }

        void move(int dx, int dy, DungeonMap map) {
            if (map.isWalkable(x + dx, y + dy)) {
                x += dx;
                y += dy;
            }
        }
    }

    // ==================== 玩家 ====================
    static class Player extends Entity {
        int hp = 10;
        int combo = 0; // 连续命中节拍加分

        Player(int x, int y) { super("Player", x, y, '@'); }
    }

    // ==================== 敌人AI:节拍约束移动 ====================
    static class Enemy extends Entity {
        int hp = 3;
        int viewRange = 8;

        Enemy(int x, int y) { super("Skeleton", x, y, 'S'); }

        /**
         * 敌人在每个节拍行动一次
         * 策略:如果玩家在视野内,用节拍A*向玩家靠近;否则随机游荡
         */
        void actOnBeat(Player player, DungeonMap map) {
            int dist = Math.abs(player.x - x) + Math.abs(player.y - y);
            if (dist <= viewRange) {
                // 节拍A*:每步代价为1拍,寻找拍数最短路径
                int[] dir = beatAStar(player, map);
                if (dir != null) move(dir[0], dir[1], map);
            } else {
                // 随机游荡
                int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};
                int[] d = dirs[new Random().nextInt(4)];
                move(d[0], d[1], map);
            }
        }

        /**
         * 简化版节拍A*:标准4方向曼哈顿A*
         */
        int[] beatAStar(Player target, DungeonMap map) {
            PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingInt(n -> n.f));
            boolean[][] closed = new boolean[map.height][map.width];
            Map<String, String> cameFrom = new HashMap<>();

            Node start = new Node(x, y, 0, heuristic(x, y, target));
            open.add(start);

            while (!open.isEmpty()) {
                Node current = open.poll();
                if (current.x == target.x && current.y == target.y) {
                    return reconstructFirstStep(cameFrom, target);
                }
                closed[current.y][current.x] = true;

                int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};
                for (int[] d : dirs) {
                    int nx = current.x + d[0], ny = current.y + d[1];
                    if (!map.isWalkable(nx, ny) || closed[ny][nx]) continue;

                    int g = current.g + 1; // 每步1拍
                    int h = heuristic(nx, ny, target);
                    Node neighbor = new Node(nx, ny, g, g + h);

                    String key = nx + "," + ny;
                    String parentKey = current.x + "," + current.y;
                    if (!cameFrom.containsKey(key) || g < getG(open, nx, ny)) {
                        cameFrom.put(key, parentKey);
                        open.add(neighbor);
                    }
                }
            }
            return null; // 无路可走的随机移动
        }

        int heuristic(int x, int y, Player target) {
            return Math.abs(x - target.x) + Math.abs(y - target.y); // 曼哈顿距离
        }

        int[] reconstructFirstStep(Map<String, String> cameFrom, Player target) {
            String cur = target.x + "," + target.y;
            String prev = null;
            while (cameFrom.containsKey(cur)) {
                prev = cur;
                cur = cameFrom.get(cur);
            }
            if (prev == null) return null;
            String[] parts = prev.split(",");
            int px = Integer.parseInt(parts[0]), py = Integer.parseInt(parts[1]);
            return new int[]{px - x, py - y};
        }

        int getG(PriorityQueue<Node> open, int x, int y) {
            for (Node n : open) {
                if (n.x == x && n.y == y) return n.g;
            }
            return Integer.MAX_VALUE;
        }
    }

    static class Node {
        int x, y, g, f;
        Node(int x, int y, int g, int f) { this.x = x; this.y = y; this.g = g; this.f = f; }
    }

    // ==================== 游戏主循环 ====================
    static class GameSession {
        BeatEngine beat;
        DungeonMap map;
        Player player;
        List<Enemy> enemies = new ArrayList<>();
        double time = 0;
        boolean running = true;

        GameSession() {
            beat = new BeatEngine(130); // 130 BPM
            map = new DungeonMap(40, 20, System.currentTimeMillis());
            map.generateCellularAutomata();

            // 放置玩家
            player = new Player(2, 2);
            // 确保玩家在地板上
            for (int y = 0; y < map.height; y++) {
                for (int x = 0; x < map.width; x++) {
                    if (map.grid[y][x] == TileType.FLOOR) {
                        player.x = x; player.y = y;
                        break;
                    }
                }
                if (map.isWalkable(player.x, player.y)) break;
            }

            // 放置敌人
            for (int i = 0; i < 5; i++) {
                int ex = 5 + i * 6, ey = 5 + i * 2;
                if (map.isWalkable(ex, ey)) enemies.add(new Enemy(ex, ey));
            }
        }

        void handleInput(String input) {
            if (!beat.isInBeatWindow()) {
                System.out.println("Miss! 输入不在节拍窗口内");
                player.combo = 0;
                return;
            }

            int dx = 0, dy = 0;
            switch (input) {
                case "UP": dy = -1; break;
                case "DOWN": dy = 1; break;
                case "LEFT": dx = -1; break;
                case "RIGHT": dx = 1; break;
                default: return;
            }

            player.move(dx, dy, map);
            player.combo++;
            System.out.println("Perfect! Combo x" + player.combo);
        }

        void render() {
            char[][] display = new char[map.height][map.width];
            for (int y = 0; y < map.height; y++) {
                for (int x = 0; x < map.width; x++) {
                    display[y][x] = map.grid[y][x].symbol;
                }
            }
            display[player.y][player.x] = player.symbol;
            for (Enemy e : enemies) {
                if (e.hp > 0) display[e.y][e.x] = e.symbol;
            }

            System.out.println("\n+" + "-".repeat(map.width) + "+");
            for (int y = 0; y < map.height; y++) {
                System.out.print("|");
                for (int x = 0; x < map.width; x++) {
                    System.out.print(display[y][x]);
                }
                System.out.println("|");
            }
            System.out.println("+" + "-".repeat(map.width) + "+");
        }

        void runSimulation() {
            System.out.println("=== 节奏地牢模拟启动 (BPM=" + beat.bpm + ") ===\n");
            map.print();

            double dt = 1.0 / 60.0;
            String[] testInputs = {"RIGHT", "RIGHT", "DOWN", "DOWN", "LEFT", "UP", "RIGHT", "RIGHT"};
            int inputIdx = 0;

            for (int frame = 0; frame < 900 && running; frame++) {
                beat.update(dt);
                time += dt;

                // 模拟玩家输入(每约0.5秒一次,尝试对齐节拍)
                if (frame % 30 == 0 && inputIdx < testInputs.length) {
                    if (beat.isInBeatWindow()) {
                        handleInput(testInputs[inputIdx++]);
                    }
                }

                // 敌人在每个节拍行动
                if (beat.currentTime >= beat.nextBeatTime - dt) {
                    for (Enemy e : enemies) {
                        if (e.hp > 0) e.actOnBeat(player, map);
                    }
                }

                // 每180帧渲染一次
                if (frame % 180 == 0 && frame > 0) {
                    render();
                }
            }

            System.out.println("\n=== 模拟结束 ===");
            System.out.println("最终Combo: " + player.combo);
        }
    }

    public static void main(String[] args) {
        GameSession session = new GameSession();
        session.runSimulation();
    }
}

复杂度分析

模块 时间复杂度 空间复杂度 说明
元胞自动机生成 O(S × W × H) O(W × H) S为迭代次数,通常为5
连通性填充 O(W × H) O(W × H) BFS遍历全图
节拍A*寻路 O(E log V) O(V) V为地图格子数,E≈4V
每帧节拍检测 O(1) O(1) 两次浮点比较
敌人行动(每拍) O(K × E log V) O(V) K为敌人数

对于40×20的地图,元胞自动机5轮迭代仅需约4000次操作;敌人A*寻路在小型地图上也仅需毫秒级,完全满足130BPM(约460ms/拍)的实时响应。

扩展方向

  1. 多轨节拍:支持不同敌人跟随不同乐器声部(鼓点、贝斯、旋律),形成层次化的节奏对抗;
  2. 动态难度调整(DDM):根据玩家Combo数实时调整BPM或敌人密度;
  3. 房间-走廊生成:将元胞自动机与二进制空间分割(BSP)结合,生成有明确房间结构的地牢;
  4. 音频可视化集成:将FFT频谱数据映射到地图装饰元素(如灯光、粒子),实现视听同步。

节奏地牢证明了游戏机制可以与音乐节奏深度绑定。通过本文的Java实现,读者可以掌握节拍同步、元胞自动机与约束寻路的核心技术,并创作出独具风格的音乐Roguelike游戏。