在看了Java语言程序设计(基础篇)之后,萌生了想用javaFX来编个小游戏的想法,想了想就做打算做个拼图游戏,并实现自动拼图。
下面按我完成这个游戏的过程来说明,下面有些实现没有把全部代码贴上,贴太多我自己看着都难受,所以想了解细节的直接去看源码就好了,源码链接在文章末尾。
以3X3的拼图为例,将整张图片切割为9个大小相同的图块,将其视为一组数。
然后采用随机数,生成一组不含有重复数字的排列。如下图:
这里需要注意的是,并非是任意打乱的排列都是可以恢复的,这其中的限制是,该排列的逆序数必须为偶数,即偶排列,该拼图才可解。具体解释可以参照以下两篇博客:
如果有线性代数的基础的话,应该知道:
下面为生成不含重复数字的偶排列的代码:
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());
});
}
下面为效果图
自动拼图,顾名思义就是找到一条路径将打乱的排列恢复为正常的排列。
其本质就是八数码问题,其扩展有15数码,24数码。解法有多种,比如BFS,DFS,A*。参考如下:
这里我用的是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,随着阶数的增长,搜索空间呈爆炸式增长,情况大致如下:
当然上面用的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
此外还有一些我完成游戏时参考的材料:
第一次写博客,如有问题欢迎指正~( ̄▽ ̄)~*