使用java实现自动扫雷

写在前面

本项目已在github开源,链接https://github.com/QZero233/JavaAutoMinesweeper
本文的写作风格可能会有些奇怪,这是笔者的一次全新的尝试,后续会换回写blog的文风的

摘要

本文提出了一个全自动完成扫雷游戏的解决方案,这套方案使用了固定策略的图像分割以及求像素点RGB差值的方式来实现扫雷游戏的导入,使用一个简单的算法来实现游戏求解,使用Robot类来模拟点击,来落实最终的操作。我们使用了java语言进行实现和验证,并取得了较好的成果

引言

扫雷是一个很经典的游戏,这个游戏的规则很简单:玩家需要根据已经被扫过的区域,来判断地雷和安全区的位置,并最终排除出全部的地雷,具体游戏规则大家可以上网了解,这里不再赘述
理论上而言,扫雷游戏求解十分简单,只需要对所有的数字格子进行遍历,查看是否能确定某个地方一定是地雷,或者某个地方一定是安全的,进行标记和打开后,再重复此操作。这样的做法最后的结局有两种可能性:

  1. 排查出所有地雷,游戏结束
  2. 现有条件无法使我们再做出准确的判断,这也就是遇到了所谓的“死亡二选一”的情况。关于这种情况,本文会在后续工作部分讨论

在实践方面,这个问题的难点在于如何把扫雷游戏转化成一个矩阵,然后使用算法来求解。一般而言,程序能获取到的只有游戏的屏幕截图,挑战就在于如何识别这个截图。本文参考了一篇关于自动扫雷的文章【1】,借鉴了其中图像对比的方法,并提出了固定策略图像分割的方法,来实现游戏的导入

方法与代码实现

图像识别

总体思路是:

  1. 将整个扫雷游戏截图按照方格分割成一个一个的小图片
  2. 将每个小图片和每种方块的图片进行对比,得出这个位置的方块的类型
  3. 记录每个位置的方块类型,形成游戏矩阵
图片分割

这一步的目的就是把下面这张图变成
在这里插入图片描述
下面这样的一张张小图片
在这里插入图片描述
这一步的难点就在于,如何准确知道每个方块在图片上的坐标。这里我们是采取了一种比较偷懒的办法,那就是首先使用ps等软件测量最左边的一列的左边缘的x值,作为一个xOffset,同理,测量yOffset,然后再测量每个格子的大小blockSize,最终可以用公式 ( x O f f s e t + x ∗ b l o c k S i z e , y O f f s e t + y ∗ b l o c k S i z e ) (xOffset + x*blockSize,yOffset+y*blockSize) (xOffset+xblockSize,yOffset+yblockSize)计算出每个方块左上角的坐标
在这里插入图片描述
但由于每次缩放窗口后,这些参数都会发生变化,所以我们直接采取了把游戏窗口最大化的办法,这样可以尽可能减少变数。剩下的工作就是对这些参数进行微调,直到能获取一个让人满意的分割
在代码实现方面,我们是使用了Java的BufferedImage这个类,通过ImageIO.read来打开图片,使用ImageIO.write来保存图片,使用BufferedImage.getRGB和BufferedImage.setRGB来获取图片的每个像素点的RGB值。由于我们的目标是搭建一个原型系统,所以这种方式并不是最高效的实现方式,这也是后续优化工作的一个方向,分割部分的具体代码如下

    public static void segmentImage(BufferedImage game,ImageProfile profile) throws Exception{
        int blockSize=profile.getBlockSize();
        for(int x=0;x<profile.getRowNum();x++){
            for(int y=0;y<profile.getRowNum();y++){
                BufferedImage newImage=new BufferedImage(blockSize,blockSize,BufferedImage.TYPE_INT_ARGB);

                for(int i=0;i<blockSize;i++){
                    for(int j=0;j<blockSize;j++){
                        int color=game.getRGB(profile.getxOffset()+x*blockSize+i,profile.getyOffset()+y*blockSize+j);
                        newImage.setRGB(i,j,color);
                    }
                }
                ImageIO.write(newImage,"png",new File("segment/"+x+"-"+y+".png"));
            }
        }
    }

(注:在实际图像识别的时候,我们并不会把图像分割之后,再保存成文件再识别,事实上我们会直接获取像素点并进行比较,这个函数是用来调整xOffset等参数时使用的)

识别图片

图片识别部分参考了【1】中的做法,在完成图片分割之后,将分割好的图片,和提前打好了标记的图片进行比较,来判断这个分割好的图片所属的类别。具体比较方法为:将两张图片的对应位置的RGB值做差,再取绝对值并求和,得到一个差值,这个差值越小,就代表分割好的图片和打好标记的图片越像。如果某个类别的图片,比如数字1的图片,和游戏图片(即一个个分割好的图片)的差值最小,那么就可以确定这个游戏图片就是数字1
在实际操作中,还是会遇到几个问题:

  1. 带标签的图片和游戏图片大小不一致怎么办?
  2. RGB值的表示实际上是用int数字的低24位来表示的,这就会使得在求差值的过程中,红色天然的就具有很大的权重,这对于类别判断的影响是很大的
  3. win7的扫雷界面自带渐变,这使得准确的识别变得更加困难

对于这些问题,我们的解决办法是:

  1. 对图片进行缩放,这是目前尝试下来效果比较好的办法。之前还尝试过类似于卷积的操作,就是故意把标记图片做小,然后把它作为卷积窗口,不停的滑动,求差值,然后求和,最后把所有的求和累计起来,但是这种办法的效果并不理想。推测可能原因是:这种方法下,会出现大量的mismatch,会导致大量的大差值覆盖掉了小差值,使得最终结果差异不大
  2. 这里的思路是灰度化之后再求差值,这样可以平衡一下各个颜色的权重,实验下来结果确实比之前要好
    private static int getGrayAvg(int color){
        int red=(color & 0xff0000) >> 16;
        int green=(color & 0xff00) >> 8;
        int blue=color & 0x0000ff;
        return Math.round((red * 0.299f + green * 0.587f + blue * 0.114f));
    }
  1. 这个确实没办法了,只能是不停的增加样本量,来减少误判率。或者就换个思路,使用神经网络来解决,但是可能是因为数据量不够,实际测试下来神经网络的效果并不理想

对比图片部分的代码如下

    private static int getGrayAvg(int color){
        int red=(color & 0xff0000) >> 16;
        int green=(color & 0xff00) >> 8;
        int blue=color & 0x0000ff;
        return Math.round((red * 0.299f + green * 0.587f + blue * 0.114f));
    }

    public static double getLikelihood(BufferedImage game,BufferedImage target,int x,int y,int blockSize,int xOffset,int yOffset){
        double currentSum=0;

        //Only compare central parts 1/4 to 3/4
        for(int k=target.getWidth()/4;k<target.getWidth()*3/4;k++){
            for(int l=target.getHeight()/4;l<target.getHeight()*3/4;l++){
                //Origin x: x*blockSize + k
                //Origin y: y*blockSize + l
                //Target x: k
                //Target y: l

                int srcColor=game.getRGB(x*blockSize + k + xOffset,y*blockSize + l + yOffset);
                int targetColor=target.getRGB(k,l);

                //Get gray average
                srcColor=getGrayAvg(srcColor);
                targetColor=getGrayAvg(targetColor);


                int abs=Math.abs(srcColor-targetColor);
                currentSum+=abs;
            }
        }

        double averageSum=currentSum/(target.getWidth()*target.getHeight());

        return averageSum;
    }

后面就不断调用这个方法,对每个游戏图片,遍历所有的样本图片,来找返回值最小的那个,这样就可以最终形成游戏矩阵

游戏求解

游戏求解就略显简单了,我们事先约定:在游戏矩阵里,-1代表格子未打开,-2表示这个格子被插旗了(也就是一定有雷),0表示格子被打开了,但是没数字,其他数字就对应着游戏里格子上显示的数字
例如,下面这一个小区块
在这里插入图片描述

化为游戏矩阵就是
[
[-1,-1,-1],
[2,2,2],
[0,1,-2]
]

有了前面的工作后,求解就很简单了,大致思路是模仿了人类玩扫雷时的思维方式,就是根据已知信息去寻找一定可以确定是雷,或者一定可以确定不是雷的地方,然后进行标记或是打开。如果遍历了整个游戏,仍然无法确定,那就遇到了所谓的“死亡二选一”的情况,这个问题会留到后面的 后续工作 部分再来讨论
思路很简单,那直接贴代码了

    public static List<Action> getSolution(int[][] game, int rowNum){

        List<Action> result=new ArrayList<>();

        int[][] directions={
                {1,1},
                {1,-1},
                {-1,1},
                {-1,-1},
                {1,0},
                {-1,0},
                {0,1},
                {0,-1}
        };
        for(int i=0;i<rowNum;i++){
            for(int j=0;j<rowNum;j++){

                int current=game[i][j];
                if(current<=0)
                    continue;

                int covered=0;
                int flagged=0;
                int uncovered=0;
                for(int k=0;k<8;k++){
                    int nX=i+directions[k][0];
                    int nY=j+directions[k][1];

                    if(nX < 0 || nX >=rowNum || nY < 0 || nY >=rowNum)
                        continue;

                    if(game[nX][nY]==-1)
                        covered++;
                    else if(game[nX][nY]==-2)
                        flagged++;
                    else
                        uncovered++;
                }

                if(flagged==current && current!=0){
                    //Can open every neighbour

                    for(int k=0;k<8;k++){
                        int nX=i+directions[k][0];
                        int nY=j+directions[k][1];

                        if(nX < 0 || nX >=rowNum || nY < 0 || nY >=rowNum)
                            continue;

                        if(game[nX][nY]==-1 && !result.contains(new Action(nX,nY, Action.Type.OPEN))){
                            result.add(new Action(nX,nY, Action.Type.OPEN));
//                            System.out.println("Open "+nX+","+nY);
                        }
                    }

                }else if(flagged==current-1 && flagged+covered==current){
                    //Can mark a bomb

                    for(int k=0;k<8;k++){
                        int nX=i+directions[k][0];
                        int nY=j+directions[k][1];

                        if(nX < 0 || nX >=rowNum || nY < 0 || nY >=rowNum)
                            continue;

                        if(game[nX][nY]==-1 && !result.contains(new Action(nX,nY, Action.Type.MARK))){
                            result.add(new Action(nX,nY, Action.Type.MARK));
//                            System.out.println("Mark "+nX+","+nY);
                        }
                    }
                }

            }
        }

        return result;
    }

模拟操作

模拟操作主要是使用了Java中的Robot类,我们只需要模拟两个动作,即把鼠标指针移动到指定位置,然后左键或者右键即可
这个坐标就是目标格子在截屏中的坐标,使用之前的公式就可以轻松的算出来,在实际操作中,由于需要点击,而算出来的坐标是格子左上角的坐标,所以还需要往最终结果上加上一个偏移量,这里取得偏移量是blockSize/2,即最终坐标是
( x O f f s e t + x ∗ b l o c k S i z e + b l o c k S i z e / 2 , y O f f s e t + y ∗ b l o c k S i z e + b l o c k S i z e / 2 ) (xOffset + x*blockSize + blockSize/2,yOffset+y*blockSize+blockSize/2) (xOffset+xblockSize+blockSize/2,yOffset+yblockSize+blockSize/2)
具体代码如下

                    for(Action action:actions){
                        Coordinate coordinate=ImageUtils.getCoordinate(profile,action.getX(),action.getY());
                        robot.mouseMove(coordinate.getX(),coordinate.getY());

                        if(action.getType()== Action.Type.OPEN){
                            robot.mousePress(InputEvent.BUTTON1_MASK);
                            robot.mouseRelease(InputEvent.BUTTON1_MASK);
                        }else{
                            robot.mousePress(InputEvent.BUTTON3_MASK);
                            robot.mouseRelease(InputEvent.BUTTON3_MASK);
                        }

                        Thread.sleep(100);
                    }

                    robot.mouseMove(0,0);

求解完成后,鼠标会移动到屏幕左上角来表示求解结束

其余技术细节
  1. 全局快捷键
    为了便于控制,我们设置了一个全局快捷键,这个可以参考【2】,这里不再赘述了
  2. 自动截图
    这个功能是参考了【3】,具体实现代码也很简单,代码如下
Dimension d = Toolkit.getDefaultToolkit().getScreenSize();
Robot robot=new Robot();
BufferedImage screenshot = robot.createScreenCapture(new
                           Rectangle(0, 0, (int) d.getWidth(), (int) d.getHeight()));

实验结果

由于识别准确率的问题,所以还是存在是不是点到地雷的情况,但是较少发生
测试中最大的问题还是“死亡二选一”的问题,这使得游戏无法自动求解,需要人为干预,后续会针对这个问题进行优化
目前测试下来,这个程序可以很好的完成中等难度的扫雷,在需要人为干预死亡二选一的情况下,程序能做到最快在70秒完成中等难度的扫雷游戏
下面是程序工作时的gif图以及最终的游戏结果
(下面的计时器显示,我确实没开倍速播放,如果优化一下,程序应该是能在更短时间内解决游戏的,最后gif停下来的地方就是遇到死亡二选一了)
在这里插入图片描述
在这里插入图片描述

后续工作

下面将针对遇到的问题进行一些讨论

  1. 识别精度问题,存在误判的情况
    关于这个问题,目前有两个方向
    一个是换战场,尝试win10的扫雷,文章【1】里面也提到了,win10的扫雷方块没有渐变色,识别起来应该会更简单
    另一个就是疯狂扩大样本量,但是这样会增大图像对比时的时间成本,如果还要用差值法来进行图片对比,那可能需要再进行一些优化,例如尝试使用cuda来进行并行计算
    当然,样本量大了之后,也可以尝试使用卷积神经网络来做分类,这也是一个可选的方向
  2. 随着blockSize变小,精准切割存在困难
    当前的切割采取的是固定策略,其中的参数不一定是完美的,事实上,每个小块都可能会有1-2个像素的误差,当blockSize较大时,一般而言格子数量也较少,所以误差不那么明显;但如果blockSize较小,例如24x24的游戏中,blockSize测下来只有38,那么一方面,格子数量变多了,误差的累积效应增大,另一方面,各自本身较小,误差更明显,所以这种分割的结果就是很不理想
    目前计划采取【1】中的办法,使用边界线等其他特征来判断格子的边界,从而进行切割
  3. 如何解决死亡二选一
    这个暂时还没进行文献调研,还不能给出一个比较完美的方法,目前仅有一些符合直觉的思路:
    其一是,瞎点,但是凭借个人经验,这样挺容易点到雷的…
    其二是,找到可能的情况数量最少的地方,例如,只需要做二选一,肯定可以确认一个是雷,一个不是雷的地方,然后跑随机数。这就纯看运气了…但如果是做速通的话,这个思路或许是最高效的
  4. 算法仍然存在优化空间
    虽然这个算法是 O ( n 2 ) O(n^2) O(n2)的,但是扫雷游戏的规模一般不大,所以求解的时间开销几乎可以忽略不计,开销的大头在图像识别和游戏矩阵的构建
    在算法层面,其实是可以减少一下图像识别的次数的,例如,在标记完雷和打开完一定不是雷的格子之后,就根据程序已知的信息,再判断一下,还能不能找到可以确定是雷或者不是雷的地方,如果实在找不到了,再截图+图像识别,来获取最新的游戏状态

参考文献

【1】https://pangruitao.com/post/3058
【2】https://zhuanlan.zhihu.com/p/446086846
【3】https://cloud.tencent.com/developer/article/1669635

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值