自动拼图游戏

在看了Java语言程序设计(基础篇)之后,萌生了想用javaFX来编个小游戏的想法,想了想就做打算做个拼图游戏,并实现自动拼图。

下面按我完成这个游戏的过程来说明,下面有些实现没有把全部代码贴上,贴太多我自己看着都难受,所以想了解细节的直接去看源码就好了,源码链接在文章末尾。

打乱图片

以3X3的拼图为例,将整张图片切割为9个大小相同的图块,将其视为一组数。
然后采用随机数,生成一组不含有重复数字的排列。如下图:
自动拼图游戏_第1张图片

这里需要注意的是,并非是任意打乱的排列都是可以恢复的,这其中的限制是,该排列的逆序数必须为偶数,即偶排列,该拼图才可解。具体解释可以参照以下两篇博客:

  • 拼图可解的充要条件
  • N-拼图游戏的可解性

如果有线性代数的基础的话,应该知道:

  1. 在n元标准排列中,偶排列和奇排列各占一半。
  2. 在n元排列中,任意两个元素对换,排列奇偶性改变。

下面为生成不含重复数字的偶排列的代码:
RandomArray.java

    //    生成num个不重复的逆序数为偶数的排列
    public static int[] getEvenPermutation(int num) {
        int[] ran = generateRandomArray(num);
        if (getNumberOfInversions(ran) % 2 != 0) {
            int temp = ran[0];         //如果不是偶排列则交换下标为0和1的数,当然其他也可以
            ran[0] = ran[1];
            ran[1] = temp;
        }    
        return ran;
    }
    //    生成num个不重复数
    public static int[] generateRandomArray(int num) {
        int[] array = new int[num];
        Random random = new Random();
        for (int i = 0; i < array.length -1; i++) {
            array[i] = random.nextInt(num -1);
            for (int j = 0; j < i; j++) {
                if (array[i] == array[j]) {
                    i--;
                    break;
                }
            }
        }    //这里把最大的数放在了排列的末尾,一开始是为了美观,后来发现这样好像可以降低搜索难度—_—!
        array[array.length - 1] = num - 1;  //这句删掉,再把上面for循环条件判断语句的减1去掉就可以完全随机
        return array;
    }
    //    求逆序数
    public static int getNumberOfInversions(int[] array) {
        int sum = 0;
        for (int i = 0; i < array.length - 1; i++) {
            for (int j = i + 1; j < array.length; j++) {
                if (array[i] > array[j]) {
                    sum++;
                }
            }
        }
        return sum;
    }

重组图片

得到随机排列之后,根据排列在图片对应位置将图块切割下来,再按顺序放到矩阵中即可。
这里用到一个Cell类,用于存储图片和各个坐标信息,基本上整个游戏都有用到这个类
Cell.java

public class Cell {

    private int x;                  //在nxn的矩阵中的x坐标
    private int y;                  //在nxn的矩阵中的y坐标
    private int validIndex;         //图块的应该在的正确位置
    private int currentIndex;       //图块的当前位置
    private ImageView ImageView;    //图块的图像

    public Cell(int x, int y, ImageView initialImageView,int validIndex,int currentIndex) {   
        this.x = x;
        this.y = y;
        this.ImageView = initialImageView;
        this.validIndex=validIndex;
        this.currentIndex=currentIndex;
    }

    public double getX() {
        return this.x;
    }

    public double getY() {
        return this.y;
    }

    public ImageView getImageView() {
        return this.ImageView;
    }

    public int getCurrentIndex() {
        return currentIndex;
    }

    public int getValidIndex() {
        return validIndex;
    }

    public void setCurrentIndex(int currentIndex) {
        this.currentIndex = currentIndex;
    }

    public void setImageView(ImageView imageView){
        this.ImageView=imageView;
    }

    public boolean isEmpty(){
        return this.ImageView==null;
    }

    public boolean isSolved(){
        return this.validIndex==this.currentIndex;
    }  
}

默认排列的最大数为空,即可移动块,寻找最短路径时也是如此,当拼图完成时右下角总为空
MainWindow.java

    private void initialize() {
        int[] ran = RandomArray.getEvenPermutation(ORDER * ORDER);
//        for(int a : ran){
//            System.out.print(a+" ");
//        }
        for (int i = 0; i < ran.length; i++) {
            ImageView imageBlock = new ImageView(image);

            int minX = ran[i] % ORDER;      //在图片上将对应位置上的图块切出来
            int minY = ran[i] / ORDER;
            Rectangle2D rectangle2D = new Rectangle2D(CELLSIZE * minX,
                    CELLSIZE * minY, CELLSIZE, CELLSIZE);
            imageBlock.setViewport(rectangle2D);
            //将排列的最大数设为空图块,即右下角的图块总为空
            if (ran[i] == ORDER * ORDER - 1) {
                imageBlock = null;
            }
            cellsList.add(new Cell(i % ORDER, i / ORDER, imageBlock,i,ran[i]));
        }

    }
    private void addMounseEventInCellAndRelocateImage() {
        for (int i = 0; i < cellsList.size(); i++) {
            Cell currentCell = cellsList.get(i);
            Node imageView = currentCell.getImageView();
            if (imageView == null) {
                continue;
            }
            addMounseEventInCell(imageView);
            relocateImage(currentCell, imageView);
        }
    }

    //重组图片
    private void relocateImage(Cell currentCell, Node imageView) {
        ImageView currentImageView = currentCell.getImageView();
        imageView.relocate(currentCell.getX() * CELLSIZE + offsetX, currentCell.getY() * CELLSIZE + offsetY);
        pane.getChildren().add(currentImageView);
    }

移动图片

本来一开始移动图块我也不知道怎么做,只是像交换数字一样交换图块,但是看着实在是不舒服,而且还有各种bug,后来在stackoverflow搜到一个问题Shifting tiles in a puzzle game,里面有位答主把整个游戏都实现了ヾ(o◕∀◕)ノ于是我之前写的都重写了一遍,包括上面的Cell类。
因为鼠标点击移动和自动移动有很多相似的地方,所以这里使用了一个Move类,让NormalMove类和AutoMove类分别继承它,
这里主要通过PathTransition类来实现移动效果,首先获得路径:
Move.java

    public Path getPath(Cell currentCell, Cell emptyCell) {
        Path path = new Path();
        path.getElements().add(new MoveToAbs(currentCell.getImageView(),
                currentCell.getX() * CELLSIZE + offsetX, currentCell.getY() * CELLSIZE + offsetY));
        path.getElements().add(new LineToAbs(currentCell.getImageView(),
                emptyCell.getX() * CELLSIZE + offsetX, emptyCell.getY() * CELLSIZE + offsetY));
        return path;
    }

再通过PathTransition来移动:

    public PathTransition getPathTransition(Cell currentCell, Path routine) {
        PathTransition pathTransition = new PathTransition();
        pathTransition.setDuration(Duration.millis(100));
        pathTransition.setNode(currentCell.getImageView());
        pathTransition.setPath(routine);
        pathTransition.setOrientation(PathTransition.OrientationType.NONE);
        pathTransition.setCycleCount(1);
        pathTransition.setAutoReverse(false);

        return pathTransition;
    }

在设置好动画后,就用一个函数调用:
NormalMove.java

    public void move(Node node) {
        //获取要移动的Cell
        Cell currentCell = getCurrentCell(cellsList, node);
        if (currentCell == null) {
            return;
        }
        //获取空Cell
        Cell emptyCell = findEmptyCell(cellsList);
        if (emptyCell == null) {
            return;
        }
        //因为只有与空Cell相邻的,才可以移动,所以坐标相差为1
        int steps = (int) (Math.abs(currentCell.getX() - emptyCell.getX())
                + Math.abs(currentCell.getY() - emptyCell.getY()));
        if (steps != 1) {
            return;
        }
        if (countBoard.getIsPause() || checkedSolved(cellsList)) {
            return;
        }

        Path path = getPath(currentCell, emptyCell);
        pathTransition = getPathTransition(currentCell, path);
        setPathTransition(currentCell, emptyCell);
        pathTransition.play();

    }

最后还需要给图块添加点击事件
MainWindow.java

   ////给图片添加点击事件
    private void addMounseEventInCell(Node imageView) {
        imageView.addEventFilter(MouseEvent.MOUSE_CLICKED, mouseEvent -> {
            NormalMove movement = new NormalMove(cellsList, countBoard, CELLSIZE, offsetX, offsetY);
            movement.move((Node) mouseEvent.getSource());
        });
    }

下面为效果图

自动拼图

自动拼图,顾名思义就是找到一条路径将打乱的排列恢复为正常的排列。

自动拼图游戏_第2张图片
其本质就是八数码问题,其扩展有15数码,24数码。解法有多种,比如BFS,DFS,A*。参考如下:

  1. 八数码的八境界
  2. 八数码(八境界)

这里我用的是IDA*+曼哈顿距离,IDA*在最初始的时候设置一个阈值(初始状态的最短走步),迭代加深的过程中会判断一下,如果超过这个阈值了就进行剪枝。当前调用深度+当前的曼哈顿距离<=最初的曼哈顿距离则可以继续。
以下为IDA*的代码,我将其打包成一个类,输入为一个数组,输出为最短路径
IDAStar.java

public class IDAStar {

    private static final int MAXSTEP = 200;                         //最大步数
    private static final int[] directionX = {1, 0, -1, 0};          //分别对应下右上左
    private static final int[] directionY = {0, 1, 0, -1};

    private static final char[] direction = {'d', 'r', 'u', 'l'};   //d=0,r=1,u=2,l=3
    private static final int[] oppositeDirection = {2, 3, 0, 1};    //drul的反方向为uldr,分别对应2301

    private int[][] tile;               //存放排列的矩阵
    private int ORDER;                  //游戏的难度,即矩阵的阶数
    private int upper = 0;              //记录最初的代价,即初始矩阵的曼哈顿距离
    private boolean pass;               //记录是否找到路径
    private int pathOfLength;           //路径长度
    private StringBuilder routine;      //路径

    public IDAStar(int[] array) {

        ORDER = (int) Math.sqrt(array.length);
        pathOfLength = 0;
        initializeTile(array);
    }

    /**
     *
     * @param depth             //函数调用深度
     * @param row               //空格子所在行数,这里对应排列中的最大数
     * @param col               //空格子所在列数
     * @param est               //代价,即曼哈顿距离
     * @param preDirection      //上一个方向,避免走回头路
     */
    public void IDAS(int depth, int row, int col, int est, int preDirection) {
        int length = ORDER * ORDER;

        if (est == 0 || this.pass) {
            this.pathOfLength = depth;
            this.pass = true;
            return;
        }
        for (int i = 0; i < 4; i++) {
            if (i != preDirection) {                //不走回头路
                int newRow = row + directionX[i];
                int newCol = col + directionY[i];
                int preMht = 0, nextMht = 0, temp = 0;

                if (isValid(newRow, newCol)) {      //判断移动是否有效
                    temp = tile[newRow][newCol];
                    int tx = temp / tile.length;
                    int ty = temp % tile.length;

                    preMht = getManhattanDistance(newRow, newCol, tx, ty);   //未移动前,被移动数的曼哈顿距离
                    nextMht = getManhattanDistance(row, col, tx, ty);         //移动后,被移动数的曼哈顿距离
                    int h = est + nextMht - preMht + 1 ;                       //移动后的曼哈顿距离
                    if (depth + h<= upper) {         //当前调用深度+移动后的曼哈顿距离<=最初的曼哈顿距离则接着走
                        tile[row][col] = temp;
                        tile[newRow][newCol] = length - 1;

                        routine.append(direction[i]);
                        routine.setCharAt(depth, direction[i]);

                        IDAS(depth + 1, newRow, newCol, est + nextMht - preMht, oppositeDirection[i]);
                        tile[row][col] = length - 1;
                        tile[newRow][newCol] = temp;
                        if (pass) {
                            return;
                        }
                    }
                }
            }
        }

    }

    private boolean isValid(int row, int col) {
        return row >= 0 && row < ORDER && col >= 0 && col < ORDER;
    }

    //代价函数
    private int heuristic(int[][] tile) {
        int manhattanDistance = 0;
        int length = tile.length * tile.length - 1;
        for (int i = 0; i < tile.length; i++) {
            for (int j = 0; j < tile[i].length; j++) {
                if (tile[i][j] != length) {
                    int tx = tile[i][j] / tile.length;
                    int ty = tile[i][j] % tile.length;

                    manhattanDistance += getManhattanDistance(i, j, tx, ty);
                }
            }
        }
        return manhattanDistance;
    }

    //曼哈顿距离
    private int getManhattanDistance(int x1, int y1, int x2, int y2) {
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    }
    //初始化矩阵
    private void initializeTile(int[] array) {
        this.tile = new int[ORDER][ORDER];
        for (int i = 0; i < array.length; i++) {
            this.tile[i / ORDER][i % ORDER] = array[i];
        }
    }
    //获得路径
    public String getPath() {
        return routine.substring(0, pathOfLength);
    }

    public void init() {
        int length = ORDER * ORDER;
        int startRow = ORDER - 1;
        int startCol = ORDER - 1;

        for (int i = 0; i < length; i++) {          //找到空Cell的位置,即开始可以移动的位置,这里对应排列最大数
            if (this.tile[i / ORDER][i % ORDER] == length - 1) {
                startRow = i / ORDER;
                startCol = i % ORDER;
                break;
            }
        }
        routine = new StringBuilder();

        int cost = heuristic(this.tile);
        this.upper = Math.min(MAXSTEP, cost + 1);
        while (!this.pass) {
            IDAS(0, startRow, startCol, cost, -1);
            this.upper = Math.min(upper + 1, MAXSTEP);
        }
    }
}

在找到路径后,就可以用动画根据路径来顺序播放动画,这样就实现了自动拼图的效果。
首先要解决单步移动的问题,这个和鼠标点击移动相似,只是获得移动图块的方式不同。
AutoMove.java

    public void move(char nextDirection) {
        Cell emptyCell = findEmptyCell(cellsList);
        if (emptyCell == null) {
            return;
        }

        int emptyCellIndex = emptyCell.getValidIndex();
        Cell currentCell = getCurrentCell(emptyCellIndex, nextDirection);
        if (currentCell == null) {
            return;
        }

        Path path = getPath(currentCell, emptyCell);
        pathTransition = getPathTransition(currentCell, path);
        setPathTransition(currentCell, emptyCell);
        pathTransition.play();
    }

接着只需要按顺序调用即可
AutoBoard.java

        //自动拼图实际上就是播放动画,在获得路径后,按路径进行移动
        EventHandler eventHandler = e -> {
            movement.move(iDAStar.getPath().charAt(directionIndex));
            directionIndex++;
        };
        btAutoPuzzle.setOnMouseClicked(e -> {
            if (!isMove && iDAStar.getPath().length()>0) {  //获得路径后未移动拼图且路径长度大于0
                Timeline animation = new Timeline(new KeyFrame(Duration.millis(300), eventHandler));
                animation.setCycleCount(iDAStar.getPath().length());    //设置timeline动画的轮数为路径长度
                animation.play();
                btAutoPuzzle.setDisable(true);
                btGetPath.setDisable(true);
            }
        });

下面为效果图:

这里比较遗憾的是这个算法并不能解决5X5的拼图,一开始我测试了3X3,4X4,发现结果都能秒出,于是我愉快的干别的去了。不过还是too young,随着阶数的增长,搜索空间呈爆炸式增长,情况大致如下:

自动拼图游戏_第3张图片
当然上面用的BFS,这种暴力搜索算法性能很差,而且运算速度相对来说算慢的,然而就算IDA*+曼哈顿距离可以剪枝大量的搜索节点,但对于5X5来说,这样做显然还是不够的┐(─__─)┌ 。

因为时间和能力的问题,我没继续探索,如果你有兴趣,下面有些参考资料:
1. Solving the 24 Puzzle with Instance Dependent Pattern Databases(pdf)
2. Finding Optimal Solutions to the Twenty-Four Puzzle(pdf)
3. Heuristics for sliding-tile puzzles(ppt)

PS:还有一个问题是对于4X4的拼图,有些情况这个算法并不能秒出,有些要2秒3秒,有些则更长,这时整个界面就会卡住。因为程序默认只有一个主线程,当线程去执行算法时,用户界面自然无法响应。改进的想法是在调用算法的时候分个线程给它,同时要顾及线程间交互的问题,卡住的问题应该能解决。这个以后有空再来做啦,所以如果在5X5拼图上调用算法,游戏一定卡死,因为根本不能短时间算出来。

此外还实现了记录游戏成绩的功能以及加了些CSS样式让界面好看点,这些就不在赘述了。想了解实现细节的直接看源码吧
游戏的源码在这里—->puzzle
此外还有一些我完成游戏时参考的材料:

  • 使用JavaFx实现拼图游戏
  • IDAStar

第一次写博客,如有问题欢迎指正~( ̄▽ ̄)~*


你可能感兴趣的:(javaFX)