废话环节:看过上期文章的小伙伴现在可能还是一头雾水,怎么就完成了核心内容,界面呢?哎我说别急让我先急,博主这不夜以继日地肝出了界面部分嘛。还是老规矩,不会把所有地方都照顾到,只挑一些有代表性的内容介绍,您各位多担待。另外博主的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
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">
anchorpane.leftanchor="20.0" style="-fx-background-color: #000000; -fx-hgap: 5.0"/>
anchorpane.rightanchor="20.0" style="-fx-background-color: #000000; -fx-hgap: 5.0"/>
你可能会有疑问,为什么图中没有格子按钮呢?原因很简单,以简单模式为例,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 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: ");
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 genparamsmap() {
hashmap 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"); }
最终效果图如下(以简单模式为例):
——————————————我———是———分———割———线——————————————
不知道本期的介绍有没有让您对项目更加了解呢?是否对没有讲的部分更加期待呢?如果看完所有代码后仍有不清楚地方,请在评论区中指出。我会抽时间回复或者出一期答疑。下期的话打算讲讲交互的实现,网格按钮点击事件第一期已经介绍过了所以下期不会着重说明。感谢各位阅读,我们下期不见不散