基于JavaFX的扫雷游戏实现(一)——整体概述

发布时间 2023-07-03 11:28:39作者: 郭小柒w

 

我在不要更新挑战中坚持了一年???,你也来试试吧(咕咕咕)!

  好言归正传,本次更新带来的是经典游戏扫雷,基于JavaFX实现。篇幅有限,文章主要介绍核心操作实现,不会列出所有代码。需要完整源码或是想预览最终效果,可以点击下方链接。后续会逐步更新细节实现方面的内容,将来吧反正(肯定不鸽!

 视频演示:

    https://www.bilibili.com/video/BV1jh4y1u7ad

  源码(项目所使用的JDK为1.8版):

    1. GitHub:https://github.com/xiao-qi-w/MineSweeper
    2. 百度网盘&提取码:https://pan.baidu.com/s/1GqGbfCdluc1yrrniAGh7SQ?pwd=abcd 

  如果您已经看过视频,或是成功运行代码,相信对本项目和扫雷已经有了初步认知。如果您是直接阅读的本篇文章,这里也提供了在线的扫雷入口,方便您快速了解:扫雷游戏网页版 - Minesweeper非本人制作,仅分享

  怎么样,是否找回了那些年在微机课上偷偷玩扫雷的快乐。总之不管您之前有没有玩过,我建议先熟悉下它的规则和操作,本项目主要是围绕这些内容编写。

规则:

  1. 扫雷游戏是在一个方格网格中进行的,其中包含了地雷和数字。
  2. 目标是清除所有非地雷方格而不触发地雷。

操作:

  1. 游戏开始时,你会看到一个方格网格,其中的方格是覆盖的。
  2. 你可以通过鼠标左键点击一个方格来揭开它。如果揭开的方格是地雷,游戏结束,你输了。
  3. 如果揭开的方格是数字,它会显示周围相邻方格中地雷的数量。
  4. 如果揭开的方格是空白方格(数字为0),它会自动揭开相邻的空白方格和数字方格,直到边界或者遇到数字方格为止。
  5. 如果你认为某个方格是地雷,你可以使用鼠标右键进行标记。标记的方格会显示一个旗帜图标,表示你认为该方格是地雷。
  6. 如果你揭开了所有非地雷方格,游戏胜利。

       了解完这些,让我们尝试使用代码来实现它。

       首先是数据来源的问题。每生成一局新游戏,都有对应的地雷数字分布记录,用于指导你推断哪些地方是数字,哪些地方是地雷。考虑到游戏界面行列整齐排放的格子,用二维数组存取对应数据最直观易懂。那么选定数据结构后,如何生成初始数据呢?鉴于每局游戏的数据几乎不会重复,如果只靠我们预输入的数据,没玩几局就腻了。为此可以采用随机生成数据的方式,我的做法如下:

/**
 * 生成新游戏的地图数据
 */
public void init() {
    // 用于记录地雷的位置, 避免重复选择
    HashSet<Integer> set = new HashSet();
    // 确定随机数据范围
    int count = height * width;
    // 开始随机
    for (int rest = bomb; rest > 0; ) {
        int index = rand.nextInt(count);
        // 如果当前位置可以设置为地雷, 标记该位置, 地雷剩余个数减一
        if (!set.contains(index)) {
            set.add(index);
            map[index / width][index % width] = BOOM;
            rest -= 1;
        }
    }
    // 统计地雷分布情况
    for (int i = 0; i < height; ++i) {
        for (int j = 0; j < width; ++j) {
            if (map[i][j] != BOOM) {
                map[i][j] = countBomb(i, j);
            }
        }
    }
}

  map是用于存储数据的二维数组;width和height分别表示横向和纵向的格子数,即map每个维度的长

  仅生成地雷位置还不够,我们还需要知道地雷周围对应的数字,上面代码中的countBomb方法负责完成这部分工作,具体实现如下:

/**
 * 统计当前格子周围的地雷个数
 *
 * @param x 横坐标
 * @param y 纵坐标
 * @return count 地雷个数
 */
public int countBomb(int x, int y) {
    int count = 0;
    // 依次判断周围格子是否存在地雷
    for (int i = 0; i < 8; ++i) {
        int newX = x + positions[i][0];
        int newY = y + positions[i][1];
        if (newX > -1 && newX < height && newY > -1 && newY < width && map[newX][newY] == BOMB) {
            count += 1;
        }
    }
    return count;
}

  positions为相对方位坐标数组,用于计算周围八个格子的坐标;BOMB是int常量,值为9,表示地雷

  这样就有了初始游戏数据,仅有这个还不够,我们最终要把它展示在屏幕上。不妨想一下,在绘制界面的过程中,我们可以根据数据的不同来确定某个格子具体显示为地雷,数字或是空白。比如0-8表示周围地雷个数统计,9表示地雷。可是游戏一开始全是未知的格子,难道我们要再设置一个相应的boolean数组记录格子是否被点开吗?这样做虽然可行,但我觉得较为麻烦,所以我是这样设计的:

// 数字常量 [0:空白格, 9:地雷]
public static final byte BLANK = 0;
public static final byte BOMB = 9;
// [20:旗帜标记判断, 40:问号标记判断]
public static final byte FLAG = 20;
public static final byte GUESS = 40;
// [99:边界标记, 超过这个数字代表当前格子已被点开]
public static final byte BOUND = 99;

  对于可能用于逻辑判断的量,将它们定义为常量,这样在代码中就不会出现 if ( 变量 == 9 ),却不清楚‘9’是什么含义的情况,避免降低可读性。其次是格子是否被点击过的问题,可以设置一个边界值进行区分。因为地雷和周围数字只占用了很少一部分整型数据,所以可以根据数据是否超过某个范围来判断是否被点击过。最后是右键标记问题,我印象里的操作是右键一次采用旗帜标记,两次采用问号标记,所以设置两个对应常量用于判断。下面是点击过程中的逻辑判断代码:

// 获取按钮
Button button = (Button) buttons.get(row * GAME.width + column);
// 根据左右键设置不同响应逻辑
if (event.getButton() == MouseButton.SECONDARY) {
    // 定义图片路径
    String imagePath = null;
    // 右键对应行为
    if (map[row][column] >= GUESS) {
        // 不设置图片, 还原雷的数目
        map[row][column] -= GUESS;
        REST_FLAG += 1;
    } else if (map[row][column] >= FLAG) {
        // 如果已经被标记, 路径更换为问号图片, 表示不确定
        imagePath = GUESS_IMG;
        map[row][column] = map[row][column] - FLAG + GUESS;
    } else {
        // 未被标记过, 判断是否还有可用标记
        if (REST_FLAG > 0) {
            imagePath = FLAG_IMG;
            map[row][column] += FLAG;
            REST_FLAG -= 1;
        }
    }
    button.setStyle("-fx-background-size: contain; -fx-background-image: url(" + imagePath + ")");
} else {
    // 左键对应行为
    if (map[row][column] <= BOUND && map[row][column] >= FLAG) {
        // 如果被标记, 则先清空标记
        map[row][column] -= map[row][column] >= GUESS ? GUESS : FLAG;
        REST_FLAG += 1;
        button.setStyle("-fx-background-size: contain; -fx-background-image: url(" + null + ")");
    } else {
        // 更新点击过的数据
        mineSweeper.clickCell(row, column);

        if (STATE == UNSURE) {
            // 统计非雷格子已点开数目
            int count = 0;
            for (int i = 0; i < GAME.height; ++i) {
                for (int j = 0; j < GAME.width; ++j) {
                    if (map[i][j] > BOUND) {
                        Button btn = (Button) buttons.get(i * GAME.width + j);
                        count += 1;
                        int value = map[i][j] - 100;
                        if (value != BLANK) {
                            // 消除空白填充
                            btn.setPadding(new Insets(0.0));
                            // 设置粗体和字体颜色
                            btn.setFont(Font.font("Arial", FontWeight.BOLD, GAME.numSize));
                            btn.setTextFill(NUMS[value - 1]);
                            btn.setText(value + "");
                        }
                        btn.setStyle("-fx-border-color: #737373; -fx-opacity: 1; -fx-background-color: #ffffff");
                        btn.setDisable(true);
                    }
                }
            }
            // 判断全部非雷格子是否全部点开
            if (count + GAME.bomb == GAME.width * GAME.height) {
                STATE = WIN;
            }
        } else if (STATE == LOSS) {
            // 游戏失败, 显示所有地雷位置
            for (int i = 0; i < GAME.height; ++i) {
                for (int j = 0; j < GAME.width; ++j) {
                    if (map[i][j] == BOMB) {
                        Button btn = (Button) buttons.get(i * GAME.width + j);
                        btn.setStyle("-fx-background-color:#ffffff; -fx-background-size: contain; -fx-background-image: url(" + UNEXPLODED_IMG + ")");
                    }
                }
            }
            button.setStyle("-fx-background-color:#ffffff; -fx-background-size: contain; -fx-background-image: url(" + EXPLODED_IMG + ")");
        }
    }
}

  注:阅读时请先忽略掉界面控件相关的操作,仅需关注map数据的变化

  这段代码根据左或右键点击来进行对应的操作,同时引出了新的问题,点击格子后不总是只更新它自身的数据,像操作中说的,如果它是空白格 (数据为0),还需要展开它周围的格子,这个过程是怎么进行的呢?它在上述代码中体现为mineSweeper.clickCell(row, column); 具体实现如下:

/**
 * 展开与当前位置相连的所有空白区域, 包括包裹这层空白区域数字边界
 *
 * @param x 横坐标
 * @param y 纵坐标
 */
public void clickCell(int x, int y) {
    if (map[x][y] == BLANK) {
        map[x][y] += 100;
        // 点击到空白区域, 递归判断周围8个方向
        for (int i = 0; i < 8; i += 1) {
            int newX = x + positions[i][0];
            int newY = y + positions[i][1];
            if (newX > -1 && newX < height && newY > -1 && newY < width
                    && map[newX][newY] != BOMB && map[newX][newY] < FLAG) {
                // 递归展开非雷和未标记区域
                clickCell(newX, newY);
            }
        }
    } else if (map[x][y] == BOMB) {
        // 点击到地雷, 游戏状态设置为失败
        STATE = LOSS;
    } else if (map[x][y] < BOUND) {
        // 点击到数字格, 数值加100用于区分是否已被点开
        map[x][y] += 100;
    }
}

  至此,我们基本完成了扫雷的核心内容,剩余的功能如计时,成绩排行,难度设置,胜负判定等只能说是使这个玩法更像是完整的游戏。因为本文是概述性质的,所以这些功能和界面统一放在后续文章里结合着讲。

———————————————我———是———分———割———线——————————————

  隔了这么久再次写博客,都不知道从何写起讲些什么了。如果文章或者演示里有哪些不清楚的地方,还请留意后续更新。另外GitHub的代码我应该还会更新,如果有不足之处欢迎在issue里指出。这次的项目拖拖拉拉大概进行了一个月吧,实际用来写代码的时间也不能算多,拖延症大抵是没救了(悲)希望下次更新不是明年吧?