一、整体思路
扫雷的核心其实就是一张二维表格:
- 用一个
LINE * LINE的二维数组保存数据,-1表示雷,0~8表示这个格子周围有几个雷; - 随机生成坐标往数组里放雷(练习1);
- 遍历每个非雷格子,数一数它周围 8 个方向有几个雷,把结果存进数组(练习2);
- 遍历数组,把雷画成红色圆,把数字画成蓝色文字(练习2)。
数据(数组)和显示(绘制)是分开的:先算数据,再照着数据画图,这也是游戏开发里很常见的"数据与视图分离"思想。
二、涉及的知识点
| 知识点 | 在本项目中的作用 |
|---|---|
JFrame窗口容器 | 默认边框布局,承载整个游戏界面 |
JPanel面板 | 中间区域的面板,同时作为画布 |
Graphics画笔 | 画线(网格)、画圆(雷)、画字符串(数字) |
| 二维数组 | 保存雷区数据,一维是行(Y)、二维是列(X) |
Math.random() | 生成随机坐标,实现随机布雷 |
边界处理Math.max / Math.min | 遍历周围格子时防止数组越界 |
三、关键代码讲解
publicclassminesweeper1{publicstaticclassMineSweeper{publicintLINE=10,X0=50,Y0=50,SIZE=30;// 行列数、网格原点、格子边长publicint[][]thunderArr=newint[LINE][LINE];// 雷区数据:-1表示雷,0~8表示周围雷数publicint[][]stateArr=newint[LINE][LINE];// 格子状态:0未翻开 1已翻开 2插旗publicGraphicsg;publicbooleangameOver=false;// 游戏是否结束publicintopenedCount=0;// 已翻开的非雷格子数量publicvoidinitUI(){// JFrame 默认是边框布局JFramejf=newJFrame();jf.setSize(800,800);jf.setTitle("扫雷游戏(左键翻开 / 右键插旗)");jf.setDefaultCloseOperation(3);// 退出进程jf.setLocationRelativeTo(null);// 居中显示JPaneljPanel=newJPanel();jPanel.setBackground(Color.GREEN);jf.add(jPanel,BorderLayout.CENTER);jf.setVisible(true);// 必须先让窗口可见,才能拿到画笔// 获取画笔g=jPanel.getGraphics();// 延时:等窗口真正显示完成,否则可能画不出东西try{Thread.sleep(100);}catch(InterruptedExceptione){thrownewRuntimeException(e);}// 绘制网格:画 LINE+1 条横线和竖线,围出 LINE * LINE 个格子drawGrid();setThunder();// 练习1:随机布雷setNumber();// 练习2:计算雷周围格子的数字drawCoverAll();// 把所有格子盖上"盖子",模拟真实扫雷的初始状态printArr();// 控制台打印雷区数据(作弊用的^-^,验证结果)// 鼠标监听器:处理左键翻开、右键插旗jPanel.addMouseListener(newMouseAdapter(){@OverridepublicvoidmouseClicked(MouseEvente){if(gameOver){return;// 游戏结束后不再响应}// 像素坐标换算成数组下标:列是x轴,行是y轴intcol=(e.getX()-X0)/SIZE;introw=(e.getY()-Y0)/SIZE;// 点在网格外面,直接忽略if(row<0||row>=LINE||col<0||col>=LINE){return;}// 右键:插旗 / 取消旗if(e.getButton()==MouseEvent.BUTTON3){toggleFlag(row,col);// 左键:翻开格子}elseif(e.getButton()==MouseEvent.BUTTON1){// 点到雷:游戏结束if(thunderArr[row][col]==-1){boom();}else{openCell(row,col);checkWin();}}}});}/** 画网格:LINE+1 条横线和竖线围出 LINE*LINE 个格子 */publicvoiddrawGrid(){for(inti=0;i<=LINE;i++){g.drawLine(X0,Y0+i*SIZE,X0+LINE*SIZE,Y0+i*SIZE);// 横线g.drawLine(X0+i*SIZE,Y0,X0+i*SIZE,Y0+LINE*SIZE);// 竖线}}/** 给所有格子盖上灰色"盖子",模拟真实扫雷的开局 */publicvoiddrawCoverAll(){for(inti=0;i<LINE;i++){for(intj=0;j<LINE;j++){drawCover(i,j);}}}/** 画一个格子的"盖子" */publicvoiddrawCover(introw,intcol){g.setColor(Color.GRAY);g.fillRect(X0+col*SIZE+1,Y0+row*SIZE+1,SIZE-2,SIZE-2);}/** * 练习1:随机布雷 * 随机生成坐标(x, y),如果该位置没有雷(不等于-1)就放一个雷 * 已有雷的位置跳过,保证恰好放 LINE 个雷 */publicvoidsetThunder(){intthunder=0;// 记录放了多少个雷while(thunder<LINE){intx=(int)(Math.random()*LINE);// 列:0 ~ LINE-1inty=(int)(Math.random()*LINE);// 行:0 ~ LINE-1if(thunderArr[y][x]!=-1){thunderArr[y][x]=-1;// -1 表示雷thunder++;}}}/** * 练习2:给每个不是雷的格子计算周围 8 个方向雷的数量 * 关键点:遍历周围格子时要防止数组越界 * 行的范围:Math.max(0, i-1) ~ Math.min(LINE-1, i+1) * 列的范围:Math.max(0, j-1) ~ Math.min(LINE-1, j+1) */publicvoidsetNumber(){for(inti=0;i<LINE;i++){// 行for(intj=0;j<LINE;j++){// 列if(thunderArr[i][j]==-1){continue;// 雷的格子不需要数字}intcount=0;// 记录周围雷的数量// 检查 (i, j) 周围一圈(包括自己,但自己不是雷,不影响计数)for(intr=Math.max(0,i-1);r<=Math.min(LINE-1,i+1);r++){for(intc=Math.max(0,j-1);c<=Math.min(LINE-1,j+1);c++){if(thunderArr[r][c]==-1){count++;}}}thunderArr[i][j]=count;}}}/** * 翻开一个格子(递归实现"空白扩散") * 如果翻开的格子是 0(周围没有雷),就把周围一圈也自动翻开, * 周围如果还是 0 就继续扩散——这就是真实扫雷里的"一键展开" */publicvoidopenCell(introw,intcol){// 越界或者已经翻开/插旗的格子,直接返回(递归的出口)if(row<0||row>=LINE||col<0||col>=LINE){return;}if(stateArr[row][col]!=0){return;// 0=未翻开才继续,已翻开(1)或插旗(2)都不动}// 翻开这个格子stateArr[row][col]=1;openedCount++;drawOpen(row,col);// 周围没有雷 -> 自动翻开周围一圈(递归扩散)if(thunderArr[row][col]==0){for(intr=Math.max(0,row-1);r<=Math.min(LINE-1,row+1);r++){for(intc=Math.max(0,col-1);c<=Math.min(LINE-1,col+1);c++){openCell(r,c);}}}}/** 把翻开的格子画出来:擦掉灰盖子,露出背景,有数字就画数字 */publicvoiddrawOpen(introw,intcol){// 先用背景色把"盖子"擦掉g.setColor(Color.GREEN);g.fillRect(X0+col*SIZE+1,Y0+row*SIZE+1,SIZE-2,SIZE-2);// 数字大于 0 才画(真实扫雷里 0 就是空白)if(thunderArr[row][col]>0){g.setColor(Color.BLUE);g.setFont(newFont("宋体",Font.BOLD,16));intpx=X0+col*SIZE+SIZE/2;// 格子中心的 xintpy=Y0+row*SIZE+SIZE/2;// 格子中心的 yg.drawString(thunderArr[row][col]+"",px-5,py+6);}}/** 右键:插旗 / 取消旗 */publicvoidtoggleFlag(introw,intcol){intpx=X0+col*SIZE+SIZE/2;// 格子中心intpy=Y0+row*SIZE+SIZE/2;if(stateArr[row][col]==0){// 未翻开 -> 插旗:画一面小红旗(旗杆 + 三角旗面)stateArr[row][col]=2;g.setColor(Color.BLACK);g.fillRect(px-1,py-8,2,16);// 旗杆g.setColor(Color.RED);g.fillPolygon(newint[]{px+1,px+9,px+1},// 三角旗面newint[]{py-8,py-4,py},3);}elseif(stateArr[row][col]==2){// 已插旗 -> 取消,重新盖上盖子stateArr[row][col]=0;drawCover(row,col);}// 已翻开的格子(1)右键无效}/** 点到雷了:翻开所有雷,显示游戏结束 */publicvoidboom(){gameOver=true;// 把所有的雷都显示出来:自己踩的画红色,其它的画黑色for(inti=0;i<LINE;i++){for(intj=0;j<LINE;j++){if(thunderArr[i][j]==-1){intpx=X0+j*SIZE+SIZE/2;intpy=Y0+i*SIZE+SIZE/2;g.setColor(Color.BLACK);g.fillOval(px-8,py-8,16,16);}}}g.setColor(Color.RED);g.setFont(newFont("宋体",Font.BOLD,30));g.drawString("砰!你踩到雷了,游戏结束",X0,Y0-15);}/** 判断是否获胜:翻开的非雷格子数 == 总格子数 - 雷数 */publicvoidcheckWin(){if(openedCount==LINE*LINE-LINE){gameOver=true;g.setColor(Color.RED);g.setFont(newFont("宋体",Font.BOLD,30));g.drawString("恭喜你,扫雷成功!",X0,Y0-15);}}// 测试:打印二维数组publicvoidprintArr(){for(inti=0;i<thunderArr.length;i++){// 调用二维长度for(intj=0;j<thunderArr[0].length;j++){System.out.print(thunderArr[i][j]+" ");}System.out.println();}System.out.println();}publicstaticvoidmain(String[]args){MineSweeperms=newMineSweeper();ms.initUI();}}}四、总结
这篇文章完整实现了一个"能玩"的扫雷,几个关键收获:
- 拿画笔的时机:
setVisible(true)之后再getGraphics(),并适当延时; - 网格要画 N+1 条线才围出 N 个格子;
- 随机布雷要先判断后放置,避免重复覆盖;
- 周围遍历用
Math.max/Math.min收紧边界,一行代码防越界; - 数组下标与屏幕坐标是行列互换的,绘制和鼠标点击的换算互为逆运算;
- 双数组设计:
thunderArr存答案、stateArr存进度,数据与视图分离; - 空白扩散用递归:出口(越界/已翻开)+ 单步翻开 + 周围递归,注意用状态数组防止死循环。