基于JavaFX的扫雷游戏实现(二)——游戏界面
废话环节:看过上期文章的小伙伴现在可能还是一头雾水,怎么就完成了核心内容,界面呢?哎我说别急让我先急,博主这不夜以继日地肝出了界面部分嘛。还是老规矩,不会把所有地方都照顾到,只挑一些有代表性的内容介绍,您各位多担待🙏。另外博主的JavaFX是跟着B站视频速成的,指路👉:https://www.bilibili.com/video/BV1Qf4y1F7Zv 有哪些地方讲的不对欢迎在评论区友好交流🤝。
上期内容已经介绍了游戏初始数据,即地雷和数字分布情况的二维数组,那么如何把它与图形界面对应到一起呢?如果您熟悉JavaFX的各种布局和控件的话,很容易会联想到GridPane布局。至于可以点击的格子,用label或button也好,用rectangle绘制矩形也罢,只要看起来像那回事,能设置对应点击事件就OK。选完角儿后就是代码环节了,考虑到纯java代码实现界面不够直观,所以推荐使用fxml文件,因为有对应的可视化设计工具。这里我采用的是Scene Builder,建议大家也了解下。下面给出游戏界面设计图:
图中各部分内容所要承担的功能如下:
- 上方左右两侧的黑色格子是用于显示剩余标记计数和游戏用时的;
- 按钮是游戏重置按钮,不论游戏是否结束,点击就可以重新开局;
- 下方大片区域是要存放格子的GridPane布局;
设计完毕后生成的fxml文件如下(对于 controller 或 fx:id 等内容需要手动设置):
game.fxml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.text.Font?>
<AnchorPane fx:id="anchorPane"
prefWidth="400" prefHeight="500"
maxHeight="-Infinity" maxWidth="-Infinity"
minHeight="-Infinity" minWidth="-Infinity"
xmlns="http://javafx.com/javafx/8.0.171" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="controllers.GameController">
<children>
<Label fx:id="labelTop" style="-fx-background-color: #8e7f7f; ">
<font>
<Font size="1.0"/>
</font>
</Label>
<Label fx:id="labelBottom" style="-fx-background-color: #8e7f7f;">
<font>
<Font size="1.0"/>
</font>
</Label>
<Label fx:id="labelLeft" style="-fx-background-color: #8e7f7f;">
<font>
<Font size="1.0"/>
</font>
</Label>
<Label fx:id="labelRight" style="-fx-background-color: #8e7f7f;">
<font>
<Font size="1.0"/>
</font>
</Label>
<Label fx:id="labelCenter" style="-fx-background-color: #8e7f7f;">
<font>
<Font size="1.0"/>
</font>
</Label>
<GridPane fx:id="grid"/>
<GridPane fx:id="mark" prefWidth="80.0" prefHeight="45.0" AnchorPane.topAnchor="35.0"
AnchorPane.leftAnchor="20.0" style="-fx-background-color: #000000; -fx-hgap: 5.0"/>
<GridPane fx:id="time" prefWidth="80.0" prefHeight="45.0" AnchorPane.topAnchor="35.0"
AnchorPane.rightAnchor="20.0" style="-fx-background-color: #000000; -fx-hgap: 5.0"/>
<Button fx:id="reset" prefHeight="50.0" prefWidth="50.0" AnchorPane.topAnchor="35.0" onAction="#onResetClick"/>
</children>
</AnchorPane>
你可能会有疑问,为什么图中没有格子按钮呢?原因很简单,以扫雷简单模式为例,9*9大小,一共要81个格子。这部分内容如果手动添加可太费时费力了,因为它们初始状态完全一致,所以建议在代码中通过循环来实现,如下:
for (int i = 0; i < GAME.height; ++i) {
for (int j = 0; j < GAME.width; ++j) {
Button button = new Button();
// 设置边界线的外观效果, 使按钮看起来更突出
button.setBorder(new Border(new BorderStroke(Color.web("#737373"), BorderStrokeStyle.SOLID, new CornerRadii(4), new BorderWidths(1))));
button.setPadding(new Insets(0));
// 设置按钮大小和点击事件
button.setPrefSize(GAME.buttonSize, GAME.buttonSize);
button.setOnMouseClicked(event -> {
handleEvent(event);
});
// 添加按钮到指定位置
grid.add(button, j, i);
}
}
而对于错位的重置按钮和暂时不可见的五个label边框,考虑到后续设置不同游戏难度的情况,这部分内容在代码中设置比较合适,我的做法如下:
/**
* 调整边框以及其他组件的位置和大小
*/
private void adjustControls() {
HashMap<String, Double> params = GAME.genParamsMap();
double thickness = params.get("thickness");
double offset = params.get("offset");
double lenVertical = params.get("lenVertical");
double lenHorizontal = params.get("lenHorizontal");
// 计算实际窗口宽高
WIDTH_OFFSET += lenHorizontal + thickness * 2;
HEIGHT_OFFSET += lenVertical;
// 设置窗口大小
anchorPane.setPrefSize(WIDTH_OFFSET, lenVertical);
// 设置网格布局位置
AnchorPane.setTopAnchor(grid, offset + thickness);
AnchorPane.setLeftAnchor(grid, thickness);
// 设置重置按钮的位置
reset.setStyle("-fx-background-size: contain; -fx-background-image: url(" + SMILE_IMG + ")");
AnchorPane.setLeftAnchor(reset, thickness + (lenHorizontal - 50) / 2);
// 设置边框标签的大小和位置
labelTop.setPrefSize(lenHorizontal, thickness);
AnchorPane.setLeftAnchor(labelTop, thickness);
AnchorPane.setTopAnchor(labelTop, 0.0);
labelCenter.setPrefSize(lenHorizontal, thickness);
AnchorPane.setLeftAnchor(labelCenter, thickness);
AnchorPane.setTopAnchor(labelCenter, offset);
labelBottom.setPrefSize(lenHorizontal, thickness);
AnchorPane.setLeftAnchor(labelBottom, thickness);
AnchorPane.setTopAnchor(labelBottom, lenVertical - thickness);
labelLeft.setPrefSize(thickness, lenVertical);
AnchorPane.setLeftAnchor(labelLeft, 0.0);
AnchorPane.setTopAnchor(labelLeft, 0.0);
labelRight.setPrefSize(thickness, lenVertical);
AnchorPane.setLeftAnchor(labelRight, lenHorizontal + thickness);
AnchorPane.setTopAnchor(labelRight, 0.0);
}
注:GAME为游戏难度枚举类实例,genParamsMap是用于生成计算所需数据的静态方法
完整的枚举类代码如下:
GameEnum
package components;
import java.util.HashMap;
/**
* @description: 游戏难度枚举
* @author: 郭小柒w
* @time: 2023/6/11
*/
public enum GameEnum {
EASY(9, 9, 10, 40.0, 30.0),
MEDIUM(16, 16, 40, 35.0, 25.0),
HARD(30, 16, 99, 30.0, 20.0),
CUSTOM();
// 游戏难度规格[宽 x 高], 相应地雷个数
public int width, height, bomb;
// 网格按钮尺寸, 数字字体大小
public double buttonSize, numSize;
GameEnum(int width, int height, int bomb, double buttonSize, double numSize) {
this.width = width;
this.height = height;
this.bomb = bomb;
this.buttonSize = buttonSize;
this.numSize = numSize;
}
GameEnum() {
this.buttonSize = 35.0;
this.numSize = 25.0;
}
// 宽和高限制在简单和困难之间
public void setWidth(int width) {
if (width < EASY.width) {
this.width = EASY.width;
} else if (width > HARD.width) {
this.width = HARD.width;
} else {
this.width = width;
}
}
public void setHeight(int height) {
if (height < EASY.height) {
this.height = EASY.height;
} else if (height > HARD.height) {
this.height = HARD.height;
} else {
this.height = height;
}
}
// 地雷数介于格子数之间
public void setBomb(int bomb) {
if (bomb < 0) {
this.bomb = 0;
} else if (bomb > width * height) {
this.bomb = width * height;
} else {
this.bomb = bomb;
}
}
/**
* 生成游戏窗口和边框大小计算需要用到的参数
* @return 参数集合
*/
public HashMap<String, Double> genParamsMap() {
HashMap<String, Double> params = new HashMap();
// 标签宽度, 固定值10
double thickness = 10.0;
params.put("thickness", thickness);
// 中间位置的标签框相对于布局顶部的偏移量, 固定值110
double offset = 110.0;
params.put("offset", offset);
// 边框标签边的水平和竖直长度, 宽度为固定值10
double lenVertical = height * buttonSize + thickness * 2 + offset;
double lenHorizontal = width * buttonSize;
params.put("lenVertical", lenVertical);
params.put("lenHorizontal", lenHorizontal);
return params;
}
}
为什么要使用枚举类对游戏难度进行区分呢?如果您完整地阅读过我的代码,就会发现MineSweeper类仅负责对接游戏进行中的各种逻辑,对于游戏难度、计时判断、排行计算等功能可以说完全不参与。这是因为和win7自带的扫雷不同,我打算新增一个菜单页,而不是运行程序直接开始游戏。这就需要我合理划分每个类负责的功能,不然就要全部塞进MineSweeper类里,显得过于臃肿(事实上大二时期我用awt和swing干过这种蠢事,那一版扫雷几百行的代码全在一个类里,没有注释还bug百出🤡)。你也可以把难度作为MineSweeper类的一个属性来处理,不过这会导致和难度有关的逻辑修改起来比较麻烦,比如下面的代码是我进行游戏初始化的部分:
public void initialize() {
// 重置剩余可用标记数
REST_FLAG = GAME.bomb;
// 重置点击状态
CLICKED = NO;
// 重置游戏状态
STATE = UNSURE;
// 重置计时器
if (TIMELINE != null) {
TIMELINE.stop();
TIMELINE = null;
}
// 生成新游戏的用到的数据
mineSweeper = new MineSweeper(GAME.width, GAME.height, GAME.bomb, new int[GAME.height][GAME.width]);
// 设置监听
addListener();
// 绘制界面
adjustControls();
// 填充网格布局
addToGrid();
}
很显然,如果没有使用枚举类,创建minesweeper对象的语句将会更繁琐。因为那需要你根据一个难度全局变量,使用if-else或者switch语句对其进行判断,然后才能设置对应长宽地雷数,另外想要增加一个新的难度时也不可避免地要修改多处代码。而现在仅需要这个全局变量是枚举类实例。
至于图中计数和计时两个黑框框为什么不显示内容,这是因为我想实现液晶数字显示的效果,就像计算器(时代眼泪)的显示风格那样。这种情况没有官方类库可以使用,只能魔改大神轮子做一个自定义控件来满足我的需求,内容较多放在下期再说。
有了fxml文件和初始化代码(下期展开讲),通过这段代码来生成界面:
/**
* 打开新窗口
*
* @param filePath fxml文件相对路径
* @param method 方法名
*/
public void openNewWindow(String filePath, String method) {
try {
parent = (Stage) anchorPane.getScene().getWindow();
// 加载设置界面布局文件
FXMLLoader loader = new FXMLLoader();
loader.setLocation(getClass().getResource(filePath));
Parent root = loader.load();
Scene scene = new Scene(root);
// 设置Stage
Stage stage = new Stage();
stage.setResizable(false);
if ("onPlayClick".equals(method)) {
// 根据实际效果重置窗口大小
stage.setOnShown(event -> {
stage.setWidth(WIDTH_OFFSET);
stage.setHeight(HEIGHT_OFFSET);
});
}
// 设置左上角图标
stage.getIcons().add(new Image(ICON_IMG));
stage.setScene(scene);
// 设置父窗体
stage.initOwner(anchorPane.getScene().getWindow());
// 设置除当前窗体外其他窗体均不可编辑
stage.initModality(Modality.WINDOW_MODAL);
// 隐藏父窗口
parent.hide();
stage.setOnCloseRequest(event -> {
if(TIMELINE != null) {
TIMELINE.stop();
TIMELINE = null;
}
// 显示父窗口
parent.show();
// 还原更改的值
WIDTH_OFFSET = 6.0;
HEIGHT_OFFSET = 35.0;
});
stage.showAndWait();
} catch (IOException e) {
System.out.println("Error on [Class:MenuController, Method:" + method + "]=>");
e.printStackTrace();
}
}
打开游戏界面:
/**
* 点击开始新游戏
*/
public void onPlayClick() { openNewWindow("/fxmls/game.fxml", "onPlayClick"); }
最终效果图如下(以简单模式为例):
——————————————我———是———分———割———线——————————————
不知道本期的介绍有没有让您对项目更加了解呢?是否对没有讲的部分更加期待呢?如果看完所有代码后仍有不清楚地方,请在评论区中指出。我会抽时间回复或者出一期答疑😀。下期的话打算讲讲交互的实现,网格按钮点击事件第一期已经介绍过了所以下期不会着重说明。感谢各位阅读,我们下期不见不散👋