基于JavaFX的扫雷游戏实现(一)——整体概述
我在不要更新挑战中坚持了一年🎉🎉🎉,你也来试试吧(咕咕咕)!
好言归正传,本次更新带来的是经典游戏扫雷,基于JavaFX实现。篇幅有限,文章主要介绍核心操作实现,不会列出所有代码。需要完整源码或是想预览最终效果,可以点击下方链接。后续会逐步更新细节实现方面的内容,将来吧反正(肯定不鸽!)
视频演示:
https://www.bilibili.com/video/BV1jh4y1u7ad
源码(项目所使用的JDK为1.8版本):
如果您已经看过视频,或是成功运行代码,相信对本项目和扫雷已经有了初步认知。如果您是直接阅读的本篇文章,这里也提供了在线的扫雷入口,方便您快速了解:扫雷游戏网页版 - Minesweeper(非本人制作,仅分享)
怎么样,是否找回了那些年在微机课上偷偷玩扫雷的快乐。总之不管您之前有没有玩过,我建议先熟悉下它的规则和操作,本项目主要是围绕这些内容编写。
规则:
- 扫雷游戏是在一个方格网格中进行的,其中包含了地雷和数字。
- 目标是清除所有非地雷方格而不触发地雷。
操作:
- 游戏开始时,你会看到一个方格网格,其中的方格是覆盖的。
- 你可以通过鼠标左键点击一个方格来揭开它。如果揭开的方格是地雷,游戏结束,你输了。
- 如果揭开的方格是数字,它会显示周围相邻方格中地雷的数量。
- 如果揭开的方格是空白方格(数字为0),它会自动揭开相邻的空白方格和数字方格,直到边界或者遇到数字方格为止。
- 如果你认为某个方格是地雷,你可以使用鼠标右键进行标记。标记的方格会显示一个旗帜图标,表示你认为该方格是地雷。
- 如果你揭开了所有非地雷方格,游戏胜利。
了解完这些,让我们尝试使用代码来实现它。
首先是数据来源的问题。每生成一局新游戏,都有对应的地雷数字分布记录,用于指导你推断哪些地方是数字,哪些地方是地雷。考虑到游戏界面行列整齐排放的格子,用二维数组存取对应数据最直观易懂。那么选定数据结构后,如何生成初始数据呢?鉴于每局游戏的数据几乎不会重复,如果只靠我们预输入的数据,没玩几局就腻了。为此可以采用随机生成数据的方式,我的做法如下:
/**
* 生成新游戏的地图数据
*/
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里指出。这次的项目拖拖拉拉大概进行了一个月吧,实际用来写代码的时间也不能算多,拖延症大抵是没救了(悲)希望下次更新不是明年吧😢