13.1 移动迷宫——拼图游戏" class="reference-link">13.1 移动迷宫——拼图游戏

本例要实现的是一个九宫格的拼图游戏。当初笔者想到制作这个App是因为看到同事在Web上玩的拼图程序,回想到了小时候玩的九宫格拼图游戏,于是便决定在Android上实现这样一个九宫格拼图游戏。确定好目标之后,就需要设计自己的拼图游戏了。要知道,在这些练手的程序里,你可以主宰一切。你希望程序是什么样的,它就可以是什么样的,不需要考虑别人的感受,只要自己用的舒服就可以了,毕竟它不是一个成熟的商业项目。当然,你也可以在后期对这些练手的程序进行优化,听取其他用户对这个App的评价与建议,以此修改、发布你的App。

首先,要设计这个App应该有哪些页面。既然要拼图,就选取一张图片。如果只有一张图片,就太单调了,因此设计的第一个页面,就是一个选择要拼图的图片的页面,大概的页面式样就如图13.1所示。

一般玩的拼图游戏都是3×3九宫格的拼图游戏,但是这样的拼图调试时间比较长,同时也为了增加游戏的可玩性、可拓展性,笔者设计了一个选择难度的界面,即可以选择2×2(调试时使用)、3×3(正常模式)、4×4(高手模式)这三个难度。那么首先,就来实现这图13.1中的界面,其他的功能都先不管,将目标分段实现的方法,可以让我们在学习、练手的时候更有成就感,也更有动力继续下去。

13.1 移动迷宫——拼图游戏 - 图1 图13.1 选择图片界面

在开始之前,可以先创建一些常用的共通方法、共通资源,比如获取屏幕宽高、像素密度等功能,这些方法或者资源并不用每次都创建,你可以建一个代码仓库进行积累,这样每次使用的时候就可以很方便的拿来用了,当你积累得越来越多,再进行下架构的组织,一个Android开发框架就显露雏形了。

13.1.1 准备工作" class="reference-link">13.1.1 准备工作

13.1.1.1 获取屏幕相关属性

创建一个ScreenUtil用来帮助我们获取屏幕的宽高和像素密度等功能,代码如下所示。

  1. public class ScreenUtil {
  2.  
  3. /**
  4. *获取屏幕相关参数
  5. *
  6. * @param context context
  7. * @return DisplayMetrics屏幕宽高
  8. */
  9. public static DisplayMetrics getScreenSize(Context context) {
  10. DisplayMetrics metrics = new DisplayMetrics();
  11. WindowManager wm = (WindowManager) context.getSystemService(
  12. Context.WINDOW_SERVICE);
  13. Display display = wm.getDefaultDisplay();
  14. display.getMetrics(metrics);
  15. return metrics;
  16. }
  17.  
  18. /**
  19. *获取屏幕density
  20. *
  21. * @param context context
  22. * @return density屏幕density
  23. */
  24. public static float getDeviceDensity(Context context) {
  25. DisplayMetrics metrics = new DisplayMetrics();
  26. WindowManager wm = (WindowManager) context.getSystemService(
  27. Context.WINDOW_SERVICE);
  28. wm.getDefaultDisplay().getMetrics(metrics);
  29. return metrics.density;
  30. }
  31. }

这些方法基本在每个App中都会用到,所以非常适合作为工具类来使用。

13.1.1.2 美化按钮

通过定义shape和selector来对原生的按钮进行美化是一个非常重要的技巧。shape可以非常方便地实现流行的扁平化UI和圆角等效果,代码如下所示。

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <selector xmlns:android="http://schemas.android.com/apk/res/android">
  3.  
  4. <item android:state_pressed="true">
  5. <shape android:shape="rectangle">
  6.  
  7. <!--填充的颜色-->
  8. <solid android:color="#33444444" />
  9. <!--设置按钮的四个角为弧形-->
  10. <!-- android:radius弧形的半径-->
  11. <corners android:radius="5dip" />
  12.  
  13. <!-- padding:Button里面的文字与Button边界的间隔-->
  14. <padding
  15. android:bottom="10dp"
  16. android:left="10dp"
  17. android:right="10dp"
  18. android:top="10dp" />
  19. </shape>
  20. </item> <item>
  21. <shape android:shape="rectangle">
  22.  
  23. <!--填充的颜色-->
  24. <solid android:color="@color/title_text" />
  25. <!--设置按钮的四个角为弧形-->
  26. <!-- android:radius弧形的半径-->
  27. <corners android:radius="5dip" />
  28.  
  29. <!-- padding:Button里面的文字与Button边界的间隔-->
  30. <padding
  31. android:bottom="10dp"
  32. android:left="10dp"
  33. android:right="10dp"
  34. android:top="10dp" />
  35. </shape>
  36. </item>
  37. </selector>

这样一个美化过的圆角按钮,就可以在后面的代码中直接引用。当然,你还可以在资源文件中定义String、Dimens、Style等,但是由于这只是一个练手项目,所以读者朋友们可以添加,也可以在程序中暂时使用Hard Code,等后期优化时再提取这些资源到资源文件中。下面就可以开始实行程序的初始界面了。

13.1.2 初始界面" class="reference-link">13.1.2 初始界面

初始界面非常简单,分别有以下几个部分组成:标题、选择难度的提示和展示图片区域。对于标题可以使用TextView,选择难度的提示可以使用PopupWindow,展示图片的区域可以使用一个GridView显示。有了这样的思路,再来实现就比较简单了,代码如下所示。

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent"
  5. android:background="@color/main_bg">
  6.  
  7. <LinearLayout
  8. android:id="@+id/ll_puzzle_main_spinner"
  9. android:layout_width="wrap_content"
  10. android:layout_height="wrap_content"
  11. android:layout_centerHorizontal="true"
  12. android:layout_margin="10dip">
  13.  
  14. <TextView
  15. android:layout_width="wrap_content"
  16. android:layout_height="wrap_content"
  17. android:layout_gravity="center"
  18. android:text="@string/puzzle_main_type"
  19. android:textColor="@color/main_text"
  20. android:textSize="@dimen/text_title" />
  21.  
  22. <TextView
  23. android:id="@+id/tv_puzzle_main_type_selected"
  24. android:layout_width="wrap_content"
  25. android:layout_height="wrap_content"
  26. android:layout_gravity="center"
  27. android:background="@drawable/textview_click"
  28. android:text="@string/puzzle_main_type_selected"
  29. android:textColor="@color/main_text"
  30. android:textSize="@dimen/text_title" />
  31. </LinearLayout>
  32.  
  33. <GridView
  34. android:id="@+id/gv_xpuzzle_main_pic_list"
  35. android:layout_width="wrap_content"
  36. android:layout_height="wrap_content"
  37. android:layout_below="@id/ll_puzzle_main_spinner"
  38. android:layout_centerHorizontal="true"
  39. android:layout_margin="@dimen/padding"
  40. android:gravity="center_horizontal"
  41. android:horizontalSpacing="@dimen/padding"
  42. android:numColumns="4"
  43. android:padding="@dimen/padding"
  44. android:verticalSpacing="@dimen/padding"/>
  45.  
  46. </RelativeLayout>

有了布局之后,先来实现点击选择难度的提示Popup。在主界面程序MainActivity中创建一个方法,用来显示Popup,代码如下所示。

  1. /**
  2. *显示popup window
  3. *
  4. * @param view popup window
  5. */
  6. private void popupShow(View view) {
  7. int density = (int) ScreenUtil.getDeviceDensity(this);
  8. //显示popup window
  9. mPopupWindow = new PopupWindow(mPopupView,
  10. 200 * density, 50 * density);
  11. mPopupWindow.setFocusable(true);
  12. mPopupWindow.setOutsideTouchable(true);
  13. //透明背景
  14. Drawable transpent = new ColorDrawable(Color.TRANSPARENT);
  15. mPopupWindow.setBackgroundDrawable(transpent);
  16. //获取位置
  17. int[] location = new int[2];
  18. view.getLocationOnScreen(location);
  19. mPopupWindow.showAtLocation(
  20. view,
  21. Gravity.NO_GRAVITY,
  22. location[0] - 40 * density,
  23. location[1] + 30 * density);
  24. }

使用PopupWindow没有什么太难的地方,只是有一点需要注意,就是PopupWindow的背景。如果不设置背景,有时候可能会导致PopupWindow出现一些比较奇怪的问题,所以这里给PopupWindow添加一个透明的背景。

下面就来创建用于展示图片的GridView。与ListView非常类似,第一步,先创建一个数据适配器,在getView()方法中动态生成一些ImageView用于展示要选择的图片,代码如下所示。

  1. @Override
  2. public View getView(int position, View convertView, ViewGroup arg2) {
  3. ImageView iv_pic_item = nul;
  4. int density = (int) ScreenUtil.getDeviceDensity(context);
  5. if (convertView == null) {
  6. iv_pic_item = new ImageView(context);
  7. //设置布局图片
  8. iv_pic_item.setLayoutParams(new GridView.LayoutParams(
  9. 80 * density,
  10. 100 * density));
  11. //设置显示比例类型
  12. iv_pic_item.setScaleType(ImageView.ScaleType.FIT_XY);
  13. } else {
  14. iv_pic_item = (ImageView) convertView;
  15. }
  16. iv_pic_item.setBackgroundColor(color.black);
  17. iv_pic_item.setImageBitmap(picList.get(position));
  18. return iv_pic_item;
  19. }

下面我们就可以在主界面程序MainActivity中来添加并展示这些图片了。这里定义一些固定的图片用于选择拼图的备选图,再添加一个按钮用于自定义图片,比如从相册选择或者通过拍照来设置拼图图片。

首先将所有要显示的图片保存到要传入数据适配器的List中,代码如下所示。

  1. mGvPicList = (GridView) findViewById(
  2. R.id.gv_xpuzzle_main_pic_list);
  3. //初始化Bitmap数据
  4. mResPicId = new int[]{
  5. R.drawable.pic1, R.drawable.pic2, R.drawable.pic3,
  6. R.drawable.pic4, R.drawable.pic5, R.drawable.pic6,
  7. R.drawable.pic7, R.drawable.pic8, R.drawable.pic9,
  8. R.drawable.pic10, R.drawable.pic11, R.drawable.pic12,
  9. R.drawable.pic13, R.drawable.pic14,
  10. R.drawable.pic15, R.mipmap.ic_launcher};
  11. Bitmap[] bitmaps = new Bitmap[mResPicId.length];
  12. for (int i = 0; i < bitmaps.length; i++) {
  13. bitmaps[i] = BitmapFactory.decodeResource(
  14. getResources(), mResPicId[i]);
  15. mPicList.add(bitmaps[i]);
  16. }

再通过数据适配器来将这些图片展示出来,代码如下所示。

  1. //数据适配器
  2. mGvPicList.setAdapter(new GridPicListAdapter(MainActivity.this, mPicList));

到目前为止,运行程序,显示效果如图13.2所示。

13.1 移动迷宫——拼图游戏 - 图2 图13.2 选择拼图图片界面

13.1.2.1 点击事件

不过现在还没有设置图片的点击功能,我们希望点击默认的图片后,进入拼图界面,而点击最后一个ic_launcher的图片后,能够弹出对话框让用户选择本地图库或者进行拍照来设置图片。先来看看如何选择本地图库。使用本地图库,需要借助系统Intent Action来实现,代码如下所示。

  1. //返回码:本地图库
  2. private static final int RESULT_IMAGE = 100;
  3. // IMAGE TYPE
  4. private static final String IMAGE_TYPE = "image/*";
  5. ……
  6. //本地图库
  7. Intent intent = new Intent(Intent.ACTION_PICK, null);
  8. intent.setDataAndType(
  9. MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
  10. IMAGE_TYPE);
  11. startActivityForResult(intent, RESULT_IMAGE);

选择相机拍照同样也是使用Intent Action,代码如下所示。

  1. // Temp照片路径
  2. public static String TEMP_IMAGE_PATH;
  3. TEMP_IMAGE_PATH =
  4. Environment.getExternalStorageDirectory().getPath() +
  5. "/temp.png";
  6. //返回码:相机
  7. private static final int RESULT_CAMERA = 200;
  8. ……
  9. //系统相机
  10. Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
  11. Uri photoUri = Uri.fromFile(new File(TEMP_IMAGE_PATH));
  12. intent.putExtra(
  13. MediaStore.EXTRA_OUTPUT,
  14. photoUri);
  15. startActivityForResult(intent, RESULT_CAMERA);

这里需要注意的是,由于要对ExternalStorage进行读写,所以必须在AndroidMainifest文件中申明所需要用到的权限,代码如下所示。

  1. <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

在调用系统本地图库或者相机拍照后,在程序主界面的onActivityResult()回调方法中来获取选择的图片、拍照的图片,代码如下所示。

  1. /**
  2. *调用图库相机回调方法
  3. */
  4. @Override
  5. protected void onActivityResult(int requestCode, int resultCode, Intent data) {
  6. super.onActivityResult(requestCode, resultCode, data);
  7. if (resultCode == RESULT_OK) {
  8. if (requestCode == RESULT_IMAGE && data != null) {
  9. //相册
  10. Cursor cursor = this.getContentResolver().query(
  11. data.getData(), null, null, null, null);
  12. cursor.moveToFirst();
  13. String imagePath = cursor.getString(
  14. cursor.getColumnIndex("_data"));
  15. Intent intent = new Intent(
  16. MainActivity.this,
  17. PuzzleMain.class);
  18. intent.putExtra("mPicPath ", imagePath);
  19. intent.putExtra("mType", mType);
  20. cursor.close();
  21. startActivity(intent);
  22. } else if (requestCode == RESULT_CAMERA) {
  23. //相机
  24. Intent intent = new Intent(
  25. MainActivity.this,
  26. PuzzleMain.class);
  27. intent.putExtra("mPicPath", TEMP_IMAGE_PATH);
  28. intent.putExtra("mType", mType);
  29. startActivity(intent);
  30. }
  31. }
  32. }

唯一比较复杂的是对选择图库图片的获取,需要通过ContentResolver来进行查询。不过,这些代码基本都可以作为通用代码集成到你的代码仓库中进行积累,并不需要每次使用都重新去想是如何实现的。最后,只要将选择的难度和选择图片的路径通过Intent传递到拼图界面即可。

再处理完了比较复杂的本地图库和相机拍照两种方式之后,剩下的选择默认图片的点击事件就非常简单了,只要将选择的难度等级和图片的资源ID通过Intent传递到拼图界面即可,代码如下所示。

  1. // Item点击监听
  2. mGvPicList.setOnItemClickListener(new OnItemClickListener() {
  3.  
  4. @Override
  5. public void onItemClick(AdapterView<?> arg0, View view,
  6. int position, long arg3) {
  7. if (position == mResPicId.length - 1) {
  8. //选择本地图库、相机
  9. showDialogCustom();
  10. } else {
  11. //选择默认图片
  12. Intent intent = new Intent(
  13. MainActivity.this,
  14. PuzzleMain.class);
  15. intent.putExtra("picSelectedID", mResPicId[position]);
  16. intent.putExtra("mType", mType);
  17. startActivity(intent);
  18. }
  19. }
  20. });

13.1.3 拼图界面" class="reference-link">13.1.3 拼图界面

拼图界面是游戏的主界面,我们将用户选择的图片根据选择的难度进行分割,并抽出一张图片用空白块代替,同时随机打乱顺序,作为拼图的初始游戏界面。当用户点击空白块周围上下左右相邻的图片时,可以移动该图片,当整个图片的位置都与原始图片一致时,拼图完成。

通过上面的分析,同样是先来实现界面的布局,简单的设计如图13.3所示。

13.1 移动迷宫——拼图游戏 - 图3 图13.3 拼图界面

在图13.3所示的拼图界面中,我们将功能划分为以下几个部分:计时、记步提示,显示原图、重新开始游戏、返回按钮和拼图主界面。这个布局与程序主界面的布局类似,上面的计时、记步,可以使用TextView,并通过Handler来进行刷新显示。中间同样可以使用GridView来展示待拼图的界面,并完成点击的逻辑。而下面三个按钮,更加简单了,整个布局,代码如下所示。

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:id="@+id/rl_puzzle_main_main_layout"
  4. android:layout_width="match_parent"
  5. android:layout_height="match_parent"
  6. android:background="@color/main_bg">
  7.  
  8. <LinearLayout
  9. android:id="@+id/ll_puzzle_main_spinner"
  10. android:layout_width="match_parent"
  11. android:layout_height="wrap_content"
  12. android:layout_margin="@dimen/padding"
  13. android:gravity="center_horizontal">
  14.  
  15. <TextView
  16. android:layout_width="wrap_content"
  17. android:layout_height="wrap_content"
  18. android:layout_gravity="center"
  19. android:text="@string/puzzle_main_steps"
  20. android:textColor="@color/title_text"
  21. android:textSize="@dimen/text_title" />
  22.  
  23. <TextView
  24. android:id="@+id/tv_puzzle_main_counts"
  25. android:layout_width="wrap_content"
  26. android:layout_height="wrap_content"
  27. android:layout_gravity="center"
  28. android:gravity="center"
  29. android:paddingRight="50dip"
  30. android:text="1"
  31. android:textColor="@color/title_text"
  32. android:textSize="@dimen/text_title" />
  33.  
  34. <TextView
  35. android:layout_width="wrap_content"
  36. android:layout_height="wrap_content"
  37. android:layout_gravity="center"
  38. android:text="@string/puzzle_main_time"
  39. android:textColor="@color/title_text"
  40. android:textSize="@dimen/text_title" />
  41.  
  42. <TextView
  43. android:id="@+id/tv_puzzle_main_time"
  44. android:layout_width="wrap_content"
  45. android:layout_height="wrap_content"
  46. android:layout_gravity="center"
  47. android:gravity="center"
  48. android:text="1"
  49. android:textColor="@color/title_text"
  50. android:textSize="@dimen/text_title" />
  51. </LinearLayout>
  52.  
  53. <LinearLayout
  54. android:id="@+id/ll_puzzle_main_btns"
  55. android:layout_width="wrap_content"
  56. android:layout_height="wrap_content"
  57. android:layout_alignParentBottom="true"
  58. android:layout_centerHorizontal="true"
  59. android:layout_margin="@dimen/padding">
  60.  
  61. <Button
  62. android:id="@+id/btn_puzzle_main_img"
  63. style="@style/btn_style"
  64. android:layout_width="wrap_content"
  65. android:layout_height="wrap_content"
  66. android:layout_margin="@dimen/padding"
  67. android:background="@drawable/white_button"
  68. android:text="@string/puzzle_main_img" />
  69.  
  70. <Button
  71. android:id="@+id/btn_puzzle_main_restart"
  72. style="@style/btn_style"
  73. android:layout_width="wrap_content"
  74. android:layout_height="wrap_content"
  75. android:layout_margin="@dimen/padding"
  76. android:background="@drawable/white_button"
  77. android:text="@string/puzzle_main_reset" />
  78.  
  79. <Button
  80. android:id="@+id/btn_puzzle_main_back"
  81. style="@style/btn_style"
  82. android:layout_width="wrap_content"
  83. android:layout_height="wrap_content"
  84. android:layout_margin="@dimen/padding"
  85. android:background="@drawable/white_button"
  86. android:text="@string/puzzle_main_back" />
  87. </LinearLayout>
  88.  
  89. <GridView
  90. android:id="@+id/gv_puzzle_main_detail"
  91. android:layout_width="wrap_content"
  92. android:layout_height="wrap_content"
  93. android:layout_above="@id/ll_puzzle_main_btns"
  94. android:layout_below="@id/ll_puzzle_main_spinner"
  95. android:layout_centerInParent="true"
  96. android:layout_margin="@dimen/padding" />
  97.  
  98. </RelativeLayout>

在完成界面布局后,我们继续来创建游戏的实现逻辑。

13.1.3.1 拼图算法

大家可不要小看了这个简单的拼图游戏,它的算法可是人工智能领域的一个非常著名的算法——N Puzzle问题。随机交换图片的位置之后,生成的拼图游戏很多都是无解的,经测试,这个比例高达50%左右,所以必须先判断生成的拼图是否有解。

我们将图片暂时用数字来代替,这样整个拼图游戏就变成了一个数字矩阵,假设随机得到了这个矩阵,其中X用来代表空格的图片,如图13.4所示。

13.1 移动迷宫——拼图游戏 - 图4 图13.4 拼图矩阵

对图片拼图的还原,与还原图13.4中的数字矩阵,其实是一个道理。现在,将这个矩阵写成一维数组的形式,并将其记为序列A,如下所示。

  1. A = {12, 1, 10, 2, 7, 11, 4, 14, 5, X, 9, 15, 8, 13, 6, 3}

再定义一个“倒置变量值”的算法——T。Ti表示序列A中位于第i位之后,比Ai小的元素的个数(不算X)。例如对上面的序列A中的每个元素进行“倒置变量值”的计算,其结果如下所示。

  1. 11, 0, 8, 0, 4, 6, 1, 6, 1, 3, 4, 2, 2, 1

最后,求得所有“倒置变量值”的和SumT = 49。在N Puzzle算法中,使用如下两个原则来判断一个N Puzzle问题是否有解。

  • 如果序列A的宽度为奇数,那么每个可解的问题所定义的“倒置变量值”的和——SumT必须是偶数。
  • 如果序列A的宽度为偶数,那么当空格X位于从下往上数的奇数行中时,定义的“倒置变量值”的和——SumT必须是偶数;当空格X位于从下往上数的偶数行中时,定义的“倒置变量值”的和——SumT必须是奇数。

关于N Puzzle问题的证明,不是本实例的重点,所以这里就不详细证明了,感兴趣的读者可以去网上搜索相关的证明方法。

当我们知道了这样一个判断是否有解的算法后,就可以通过算法来生成一定有解的拼图游戏了。

13.1.3.2 图片Item实体类

在开始编写具体的图片处理逻辑前,需要将每个分割后的图片抽象为一个实体类。在这个实体类中封装一些关于这个Item的基本属性,比如对这个Item的编号(与算法中的编号类似),这个Item所要显示图片的ID等,代码如下所示。

  1. /**
  2. *拼图Item逻辑实体类:封装逻辑相关属性
  3. *
  4. * @author xys
  5. */
  6. public class ItemBean {
  7.  
  8. // Item的ID
  9. private int mItemId;
  10. // Bitmap的ID
  11. private int mBitmapId;
  12. // mBitmap
  13. private Bitmap mBitmap;
  14.  
  15. public ItemBean() {
  16. }
  17.  
  18. public ItemBean(int mItemId, int mBitmapId, Bitmap mBitmap) {
  19. this.mItemId = mItemId;
  20. this.mBitmapId = mBitmapId;
  21. this.mBitmap = mBitmap;
  22. }
  23. ……
  24. getset方法
  25. ……
  26. }

在后面在程序中,我们就可以以面向对象的方式来操纵每个小的拼图Item。

13.1.3.3 图片工具

在了解了算法和抽象出实体类后,我们继续完成拼图游戏。从程序主界面上通过Intent将要进行拼图的图片资源ID和难度Type传递到了拼图的主界面。在获取到图片后,需要对这个图片进行一下处理,将图片调整到合适的大小并进行分割,因此创建一个ImagesUtil工具类,代码如下所示。

  1. /**
  2. *图像工具类:实现图像的分割与自适应
  3. *
  4. * @author xys
  5. */
  6. public class ImagesUtil {
  7.  
  8. public ItemBean itemBean;
  9.  
  10. /**
  11. *切图、初始状态(正常顺序)
  12. *
  13. * @param type 游戏种类
  14. * @param picSelected选择的图片
  15. * @param context context
  16. */
  17. public void createInitBitmaps(int type, Bitmap picSelected, Context context) {
  18. Bitmap bitmap = null;
  19. List<Bitmap> bitmapItems = new ArrayList<Bitmap>();
  20. //每个Item的宽高
  21. int itemWidth = picSelected.getWidth() / type;
  22. int itemHeight = picSelected.getHeight() / type;
  23. for (int i = 1; i <= type; i++) {
  24. for (int j = 1; j <= type; j++) {
  25. bitmap = Bitmap.createBitmap(
  26. picSelected,
  27. (j - 1) * itemWidth,
  28. (i - 1) * itemHeight,
  29. itemWidth,
  30. itemHeight);
  31. bitmapItems.add(bitmap);
  32. itemBean = new ItemBean(
  33. (i - 1) * type + j,
  34. (i - 1) * type + j,
  35. bitmap);
  36. GameUtil.mItemBeans.add(itemBean);
  37. }
  38. }
  39. //保存最后一个图片在拼图完成时填充
  40. PuzzleMain.mLastBitmap = bitmapItems.get(type * type - 1);
  41. //设置最后一个为空Item
  42. bitmapItems.remove(type * type - 1);
  43. GameUtil.mItemBeans.remove(type * type - 1);
  44. Bitmap blankBitmap = BitmapFactory.decodeResource(
  45. context.getResources(), R.drawable.blank);
  46. blankBitmap = Bitmap.createBitmap(
  47. blankBitmap, 0, 0, itemWidth, itemHeight);
  48.  
  49. bitmapItems.add(blankBitmap);
  50. GameUtil.mItemBeans.add(new ItemBean(type * type, 0, blankBitmap));
  51. GameUtil.mBlankItemBean = GameUtil.mItemBeans.get(type * type - 1);
  52. }
  53.  
  54. /**
  55. *处理图片放大、缩小到合适位置
  56. *
  57. * @param newWidth 缩放后Width
  58. * @param newHeight缩放后Height
  59. * @param bitmap bitmap
  60. * @return bitmap
  61. */
  62. public Bitmap resizeBitmap(float newWidth, float newHeight, Bitmap bitmap) {
  63. Matrix matrix = new Matrix();
  64. matrix.postScale(
  65. newWidth / bitmap.getWidth(),
  66. newHeight / bitmap.getHeight());
  67. Bitmap newBitmap = Bitmap.createBitmap(
  68. bitmap, 0, 0,
  69. bitmap.getWidth(),
  70. bitmap.getHeight(),
  71. matrix, true);
  72. return newBitmap;
  73. }
  74. }

通过这两步处理,就把图片分割成了N×N个小的Item,并将最后一个图片剔除,用于显示要移动的空格图片(即数字X)。最后,通过类似主界面上的GridView的数据适配器,将这些图片通过数据适配器,添加到拼图游戏的GridView中显示出来。

接下来,我们就要打乱顺序,生成真正用于拼图的游戏界面。

13.1.3.4 生成游戏

前面我们获取了按顺序分割的图片,下面通过一个循环来随机交换Item中的图片,从而生成杂乱的拼图游戏。前面说了,随机生成的拼图游戏有将近50%都是无解的,所以还需要判断当前生成的游戏是否有解,这个算法已经在前面的学习中讲解过了,即倒置和算法。下面对这些方法进行逐个分析。

首先是生成随机的Item,代码如下所示。

  1. /**
  2. *生成随机的Item
  3. */
  4. public static void getPuzzleGenerator() {
  5. int index = 0;
  6. //随机打乱顺序
  7. for (int i = 0; i < mItemBeans.size(); i++) {
  8. index = (int) (Math.random() *
  9. PuzzleMain.TYPE * PuzzleMain.TYPE);
  10. swapItems(mItemBeans.get(index), GameUtil.mBlankItemBean);
  11. }
  12. List<Integer> data = new ArrayList<Integer>();
  13.  
  14. for (int i = 0; i < mItemBeans.size(); i++) {
  15. data.add(mItemBeans.get(i).getBitmapId());
  16. }
  17. //判断生成是否有解
  18. if (canSolve(data)) {
  19. return;
  20. } else {
  21. getPuzzleGenerator();
  22. }
  23. }

在这个方法中,需要用到另外三个方法,即swapItems()——交换图片,canSolve()——判断是否有解,getInversions()——计算倒置和,代码分别如下所示。

  1. /**
  2. *交换空格与点击Item的位置
  3. *
  4. * @param from 交换图
  5. * @param blank空白图
  6. */
  7. public static void swapItems(ItemBean from, ItemBean blank) {
  8. ItemBean tempItemBean = new ItemBean();
  9. //交换BitmapId
  10. tempItemBean.setBitmapId(from.getBitmapId());
  11. from.setBitmapId(blank.getBitmapId());
  12. blank.setBitmapId(tempItemBean.getBitmapId());
  13. //交换Bitmap
  14. tempItemBean.setBitmap(from.getBitmap());
  15. from.setBitmap(blank.getBitmap());
  16. blank.setBitmap(tempItemBean.getBitmap());
  17. //设置新的Blank
  18. GameUtil.mBlankItemBean = from;
  19. }
  20.  
  21. /**
  22.  
  23. *该数据是否有解
  24. *
  25. * @param data拼图数组数据
  26. * @return该数据是否有解
  27. */
  28. public static boolean canSolve(List<Integer> data) {
  29. //获取空格ID
  30. int blankId = GameUtil.mBlankItemBean.getItemId();
  31. //可行性原则
  32. if (data.size() % 2 == 1) {
  33. return getInversions(data) % 2 == 0;
  34. } else {
  35. //从下往上数,空格位于奇数行
  36. if (((blankId - 1) / PuzzleMain.TYPE) % 2 == 1) {
  37. return getInversions(data) % 2 == 0;
  38. } else {
  39. //从下往上数,空位位于偶数行
  40. return getInversions(data) % 2 == 1;
  41. }
  42. }
  43. }
  44.  
  45. /**
  46. *计算倒置和算法
  47. *
  48. * @param data拼图数组数据
  49. * @return该序列的倒置和
  50. */
  51. public static int getInversions(List<Integer> data) {
  52. int inversions = 0;
  53. int inversionCount = 0;
  54. for (int i = 0; i < data.size(); i++) {
  55. for (int j = i + 1; j < data.size(); j++) {
  56. int index = data.get(i);
  57. if (data.get(j) != 0 && data.get(j) < index) {
  58. inversionCount++;
  59. }
  60. }
  61. inversions += inversionCount;
  62. inversionCount = 0;
  63. }
  64. return inversions;
  65. }

swapItems()方法与交换任意两个变量的数的方法一样。canSolve()方法正是通过计算给定序列的倒置并通过在算法分析中所说的两个原则来进行判断是否有解。

13.1.3.5 移动图片

生成好游戏后,就可以给GridView的每个Item来响应点击事件了。当点击的图片是空格图片上下左右的图片时,就可以交换两个Item,实现移动的效果。如果点击的是其他图片,自然是不能移动。因此,创建一个方法来判断当前点击的Item能否移动,代码如下所示。

  1. /**
  2. *判断点击的Item是否可移动
  3. *
  4. * @param position position
  5. * @return能否移动
  6. */
  7. public static boolean isMoveable(int position) {
  8. int type = PuzzleMain.TYPE;
  9. //获取空格Item
  10. int blankId = GameUtil.mBlankItemBean.getItemId() - 1;
  11. //不同行相差为type
  12. if (Math.abs(blankId - position) == type) {
  13. return true;
  14. }
  15. //相同行相差为1
  16. if ((blankId / type == position / type) &&
  17. Math.abs(blankId - position) == 1) {
  18. return true;
  19. }
  20. return false;
  21. }

如果判断可以移动,则使用swapItems()方法进行交换。每次交换后,还需要对当前游戏进行判断。判断是否已经还原成功,即当前图片Item的ID与初始状态下图片的ID是否相同,代码如下所示。

  1. /**
  2. *是否拼图成功
  3. *
  4. * @return是否拼图成功
  5. */
  6. public static boolean isSuccess() {
  7. for (ItemBean tempBean : GameUtil.mItemBeans) {
  8. if (tempBean.getBitmapId() != 0 &&
  9. (tempBean.getItemId()) == tempBean.getBitmapId()) {
  10. continue;
  11. } else if (tempBean.getBitmapId() == 0 &&
  12. tempBean.getItemId() == PuzzleMain.TYPE * PuzzleMain.TYPE) {
  13. continue;
  14. } else {
  15. return false;
  16. }
  17. }
  18. return true;
  19. }

最后,如果返回true即表示还原成功,那么将最后缺失的那一块图片补齐,并提示“拼图成功!”。

到此为止,整个拼图游戏的核心逻辑就全部完成了。这时候再来添加计时、记步的功能,就非常简单了。在生成游戏界面后,通过Timer来进行计时,每隔一秒,刷新一下用于计时的TextView,代码如下所示。

  1. //启用计时器
  2. mTimer = new Timer(true);
  3. //计时器线程
  4. mTimerTask = new TimerTask() {
  5. @Override
  6. public void run() {
  7. Message msg = new Message();
  8. msg.what = 1;
  9. mHandler.sendMessage(msg);
  10. }
  11. };
  12. //每1000ms执行延迟0s
  13. mTimer.schedule(mTimerTask, 0, 1000);

记步就更简单了,只需要在GridView的点击事件中,增加一个变量来记录有效点击的次数即可,代码如下所示。

  1. //判断是否可移动
  2. if (GameUtil.isMoveable(position)) {
  3. ……
  4. //更新步数
  5. COUNT_INDEX++;
  6. mTvPuzzleMainCounts.setText("" + COUNT_INDEX);
  7. ……
  8. }

界面下面的三个按钮,重置和返回都非常简单。重置只要重新调用生成游戏的方法即可,而返回只需要finish()即可。那“原图”按钮呢?由于界面已经没有剩余的空间来显示原图供玩家参考了,所以通过浮动显示的方式来展示原图,动态创建一个ImageView加入到游戏界面中,并将其Visibility设置为View.GONE。当点击“显示原图”按钮时用一个动画的形式,将原图展示出来,整个过程代码如下所示。

  1. /**
  2. *添加显示原图的View
  3. */
  4. private void addImgView() {
  5. RelativeLayout relativeLayout = (RelativeLayout) findViewById(
  6. R.id.rl_puzzle_main_main_layout);
  7. mImageView = new ImageView(PuzzleMain.this);
  8. mImageView.setImageBitmap(mPicSelected);
  9. int x = (int) (mPicSelected.getWidth() * 0.9F);
  10. int y = (int) (mPicSelected.getHeight() * 0.9F);
  11. LayoutParams params = new LayoutParams(x, y);
  12. params.addRule(RelativeLayout.CENTER_IN_PARENT);
  13. mImageView.setLayoutParams(params);
  14. relativeLayout.addView(mImageView);
  15. mImageView.setVisibility(View.GONE);
  16. }
  17.  
  18. /**
  19. * Button点击事件
  20. */
  21. @Override
  22. public void onClick(View v) {
  23. switch (v.getId()) {
  24. //返回按钮点击事件
  25. case R.id.btn_puzzle_main_back:
  26. PuzzleMain.this.finish();
  27. break;
  28. //显示原图按钮点击事件
  29. case R.id.btn_puzzle_main_img:
  30. Animation animShow = AnimationUtils.loadAnimation(
  31. PuzzleMain.this, R.anim.image_show_anim);
  32. Animation animHide = AnimationUtils.loadAnimation(
  33. PuzzleMain.this, R.anim.image_hide_anim);
  34. if (mIsShowImg) {
  35. mImageView.startAnimation(animHide);
  36. mImageView.setVisibility(View.GONE);
  37. mIsShowImg = false;
  38. } else {
  39. mImageView.startAnimation(animShow);
  40. mImageView.setVisibility(View.VISIBLE);
  41. mIsShowImg = true;
  42. }
  43. break;
  44. //重置按钮点击事件
  45. case R.id.btn_puzzle_main_restart:
  46. cleanConfig();
  47. generateGame();
  48. recreateData();
  49. //通知GridView更改UI
  50. mTvPuzzleMainCounts.setText("" + COUNT_INDEX);
  51. mAdapter.notifyDataSetChanged();
  52. mGvPuzzleMainDetail.setEnabled(true);
  53. break;
  54. default:
  55. break;
  56. }
  57. }

13.1.4 效果预览与功能进阶" class="reference-link">13.1.4 效果预览与功能进阶

通过前面对整个拼图游戏的逻辑分析与编码,相信读者朋友们对这样一个小App的实现过程已经有了一定的认识。但是,无论怎么看、怎么听他人的实现过程,都不如自己真真正正地去实现一次,在实现的过程中,很多你觉得非常简单的问题,也许并没有你想的那么简单。下面来看看最后实现的程序运行效果,如图13.5到图13.9所示。

13.1 移动迷宫——拼图游戏 - 图5 13.1 移动迷宫——拼图游戏 - 图6
图13.5 程序运行效果 图13.6 本地图库与相机
13.1 移动迷宫——拼图游戏 - 图7 13.1 移动迷宫——拼图游戏 - 图8 13.1 移动迷宫——拼图游戏 - 图9
图13.7 拼图主界面 图13.8 显示原图 图13.9 拼图成功

下面总结一下这个App的实现过程。实现这个小游戏,使用到了很多知识点,从XML绘图到Intent Action,从面向对象的设计到各种数据结构的使用,从拼图算法的分析到算法的实现,从动态布局到Handler刷新UI,从Bitmap的创建到分割等。

代码实现的过程必然是充满坎坷的,但是完成后的成就感,也是最值得人期待的。曾经有一位网友给笔者发邮件,说通过看了笔者的博客完成了这样一个拼图游戏给他的孩子玩,他的孩子非常喜欢,我想,这便是对自己努力的最好奖励了。

当然,这个小的例子还只是一个简单练手的例子,离商业化的项目还差得很远,大家可以在这个例子的基础上来优化整个项目。比如增加自动解题的算法,可以通过A*算法来实现,再比如增加SNS功能,添加多人对抗,成绩记录等功能。另外,还可以开发一些新的拼图模式,创新玩法等。这样,离一个商业化项目就越来越近了。