Java Swing实现扫雷游戏
前言
这是使用Java Swing制作扫雷游戏的笔记。本篇记录怎么让系统自动布雷,绘制基础面板,面板重绘、以及使用鼠标监听器实现翻开和插旗。
环境
- JDK 17
- IntelliJ IDEA
- 纯 Java 项目(未使用 Maven)
一、整体设计
整个游戏只有一个类,但它同时扮演两个角色:
publicclasshomework0927_2extendsJPanelimplementsMouseListener{extends JPanel:它本身就是一块面板,负责把雷区画出来,其中extends继承,可以使用JPanel的所有方法,同时拥有其全部属性;implements MouseListener:它同时是鼠标监听器,负责处理点击,其中implements表示实现,其后接接口,表示将会实现接口所规定的所有方法。
入口:
publicstaticvoidmain(String[]args){homework0927_2 ms=newhomework0927_2();ms.initUI();}二、两个核心数组
2.1 thunderArr:存"雷和数字"
publicint[][]thunderArr=newint[LINE-1][LINE-1];约定:-1为雷、0为空白、>0为周围雷数。
2.2 state:存"每格的状态"
publicint[][]state=newint[LINE-1][LINE-1];约定:0表示未翻开、1表示已翻开、2表示插旗。
使用两个数组,分开记录底层绘制的雷和数字属性,以及顶层的是否绘制“盖子”以及旗帜这两个两件独立的事,
同时,Java 用new创建的数组会自动全填 0,
不像C++是随机值,因此state不用手动初始化,刚好与初始所有格子均盖上符合。
三、绘制:继承 JPanel + 重写 paint
3.1 为什么不用 getGraphics
getGraphics()拿画笔"画一次",但这样窗口一改变大小或出现其他操作时,画面就无法即时更新,而paint()方法则可以在需要重绘时被自动调用(窗口显示、改变大小、重新露出……都会)。所以改成重写 paint,把绘制的内容写进去,界面需要变化时自动重绘。
3.2 paint 的内容
publicvoidpaint(Graphicsg){super.paint(g);//先调用父类完成基础绘画工作for(inti=0;i<LINE;i++){//循环绘制网格g.drawLine(X0,Y0+i*40,(LINE-1)*SIZE+X0,Y0+i*SIZE);g.drawLine(X0+i*SIZE,Y0,X0+i*SIZE,(LINE-1)*SIZE+Y0);}if(start){//仅在第一次绘制时生效,也就是创建随机雷组randomThunder();thunderNumber();start=false;//布尔值变为false,此后不再调用}drawThunder(g);//绘制底层雷和数字}3.3 start 标记的作用
paint会被反复调用,而雷区则会在第一次绘制前就被确定,因此仅在第一次时start设为true设置雷区,此后设置雷区的代码将不再被调用,保证布雷和算数字只做一次,做完就把它设成false。
四、布雷与算数字
4.1 随机布雷(避免重复)
publicvoidrandomThunder(){intnum=0;//记录当前雷数while(num<LINE){//要放LINE 颗雷intx=(int)(Math.random()*thunderArr.length);//随机雷坐标inty=(int)(Math.random()*thunderArr.length);if(thunderArr[y][x]!=-1){//验证是否位置重复thunderArr[y][x]=-1;num++;//只有不重复时才增加计数}}}4.2 给雷周围填数字
publicvoidthunderNumber(){for(inti=0;i<thunderArr.length;i++){for(intj=0;j<thunderArr[0].length;j++){if(thunderArr[i][j]==-1){//判定地雷markThunder(j,i);//让雷周围8格数字+1}}}}publicvoidmarkThunder(intx,inty){//x=列,y=行if(x+1<thunderArr.length&&thunderArr[y][x+1]!=-1)thunderArr[y][x+1]+=1;if(x-1>=0&&thunderArr[y][x-1]!=-1)thunderArr[y][x-1]+=1;...其余6个方向...}关键:每一格的判定都有两个条件①不越界 ②相邻格不是雷(即!= -1)。
防止相邻的雷互相+1导致变为0,雷被抹掉
五、把每格画出来:drawThunder
采用的是"先画内容,再盖盖子"的思路:
publicvoiddrawThunder(Graphicsg){for(inti=0;i<thunderArr.length;i++){for(intj=0;j<thunderArr[0].length;j++){if(thunderArr[i][j]>0){//数字格g.setFont(newFont("Arial",Font.BOLD,24));g.drawString(thunderArr[i][j]+"",j*SIZE+X0+17,(i+1)*SIZE+Y0-17);//此处原本需要一个字符串,然而我们能读到的只有数组中的int类型,因此使用java中任何类型与string拼接都会转变为string,加上一个空白字符串转化为string}if(thunderArr[i][j]==-1){//雷:黑圆g.setColor(Color.black);g.fillOval(j*SIZE+X0+10,i*SIZE+Y0+10,SIZE-20,SIZE-20);}if(state[i][j]!=1){//没翻开则盖盖子g.setColor(Color.LIGHT_GRAY);g.fillRect(j*SIZE+X0+1,i*SIZE+Y0+1,SIZE-2,SIZE-2);}if(state[i][j]==2){//插旗 → 红矩形g.setColor(Color.red);g.fillRect(j*SIZE+X0+12,i*SIZE+Y0+12,SIZE-24,SIZE-24);}}}}- 数字和雷总是先画,然后没翻开的格子会被灰色盖子盖住,默认状态下看不见;
- 翻开后
state==1,不再画盖子,下面的数字/雷就露出来了,若插旗,则在盖子上再插旗; - 坐标换算:第 j 列、第 i 格左上角是
X0 + j*SIZE、Y0 + i*SIZE。
六、鼠标交互
6.1 注册监听器
类声明加了implements MouseListener,然后在窗口里把"自己"注册为监听器:
this.addMouseListener(this);(第一个this是被监听的面板,第二个this是负责处理的监听器——自己监听自己。)
6.2 坐标 → 行列换算
intx=(e.getX()-X0)/SIZE;// 列inty=(e.getY()-Y0)/SIZE;// 行整数除法直接得到第几格;再用e.getX() >= X0 && ...把网格外的点击挡掉,仅接受格子范围内的点击。
6.3 左键翻开、右键插旗、踩雷重开
publicvoidmousePressed(MouseEvente){intx=(e.getX()-X0)/SIZE;inty=(e.getY()-Y0)/SIZE;if(e.getX()>=X0&&e.getY()>=Y0&&x<thunderArr.length&&y<thunderArr.length){if(e.getButton()==MouseEvent.BUTTON1){//左键if(state[y][x]==0){if(thunderArr[y][x]==-1){//踩雷state[y][x]=1;// 展示踩中的雷repaint();JOptionPane.showMessageDialog(this,"失败,点击确认重新开始");restart();}else{openCell(y,x);//安全格 → 翻开}}check();}if(e.getButton()==MouseEvent.BUTTON3){//右键if(state[y][x]==0){state[y][x]=2;//插旗}elseif(state[y][x]==2){state[y][x]=0;//取消旗}check();}}repaint();//重新绘制}6.4 空白格自动展开:openCell(递归)
点开的格子如果是空白(周围没雷),应该把它周围的格子也翻开,一层层扩散:
publicvoidopenCell(introw,intcol){if(row<0||row>=thunderArr.length||col<0||col>=thunderArr[0].length)return;//越界停,仅在格子范围内递归if(state[row][col]!=0)return;//已翻开/已插旗也触发停止state[row][col]=1;//先翻开自己if(thunderArr[row][col]==0){//自己是空白,才向四周扩散openCell(row-1,col-1);openCell(row-1,col);openCell(row-1,col+1);openCell(row,col-1);openCell(row,col+1);openCell(row+1,col-1);openCell(row+1,col);openCell(row+1,col+1);}}这里用到了递归。边界和"已翻开"两个return
保证了不会无限递归。
6.5 失败与胜利
踩雷时弹窗提示并restart()重开:
publicvoidrestart(){for(inti=0;i<thunderArr.length;i++){for(intj=0;j<thunderArr[0].length;j++){thunderArr[i][j]=0;//清空雷数组state[i][j]=0;//清空状态数组}}start=true;//重置start状态,下次绘制时重新随机雷组repaint();}胜利判断:所有雷都插了旗、且所有非雷格都翻开了:
publicvoidcheck(){for(inti=0;i<LINE-1;i++){for(intj=0;j<LINE-1;j++){if(thunderArr[i][j]==-1){if(state[i][j]!=2)return;//若还有没插旗的雷则不触发操作}else{if(state[i][j]!=1)return;//有非雷格没翻开,不触发操作}}}JOptionPane.showMessageDialog(this,"恭喜你成功了!点击确认重新开始");//前面没有触发return说明所有格子验证通过restart();}七、错误记录
- 相邻雷被"抹掉":
markThunder一开始漏了!= -1,两颗雷相邻时,一颗会给另一颗+1,把它从-1变成0。加上判断才对。 - 空白展开会无限递归:一开始没加"已翻开就停"的判断,相邻空白互相调用,出现无限递归错误。
八、总结
本篇用 Swing 做了一个基本能够游玩的扫雷:二维数组存雷区、paint重绘保证画面正确、MouseListener处理左键翻开和右键插旗、递归实现空白自动展开,并添加了胜负判断。