☰
Android消消乐Demo源码解析:三消判定、动画线程与Handler消息机制
2026/10/12 20:39:50 网站建设 项目流程

简介:Android开心消消乐代码实例详解是一份面向初、中级Android开发者的实战型PDF教程,完整呈现了消消乐游戏从设计到编码的实现路径,即便没有系统学过Java和Android,也能借此了解从环境搭建到成品落地的过程。内容涵盖游戏基本概念、8×8棋盘布局、TableLayout与LinearLayout的混合布局、XML与Java结合的按钮创建、OnClick监听事件、图片按钮与动画效果,以及消除判定和地图是否存在解等核心算法;对于按钮点击,采用滚动变量记录先后点击、判断相邻后交换,并通过行列扫描标记可消除方块,还讨论了更新顺序和十字消除等易错细节。作者结合真实开发经历,给出了代码片段、布局思路与性能取舍说明,对编写同类小游戏、完成课程设计或毕业设计都很有帮助。资源共1个文件,为378KB的PDF文档,已有3221人学习下载;体积小但知识密度高,适合随时查阅和对照练习。

1. 一个消消乐Demo最容易被低估的部分:不是消去判定,是动画与线程

把开心消消乐这类三消游戏拆开看,核心逻辑其实就三块:地图、消除判定、填充更新。真正难的反而是让这些过程"看得见"——做过Android游戏Demo的人都有体会,主线程一旦把结果一次性算完,你精心设计的渐隐动画就全成了摆设。这个Android开心消消乐代码实例正好踩中了这个坑,作者从只会一点C++到最终跑通整个流程,中间把线程、Handler消息机制、地图更新顺序都摸了一遍。适合两类人:想拿一个完整消消乐核心循环直接改造的开发者,以及刚接触Android游戏开发、想弄清按钮动态布局和三消判定怎么写的新手。接下来按我实际的调试习惯,把代码从头过一遍。

2. 动态布局与随机图块:为什么用代码生成64个ImageButton而不是写进XML

第一次写这个消消乐Android demo时,很多人会习惯性地在XML里复制粘贴64个按钮。等真写到第10个就会发现,每个按钮都要配坐标、样式、监听器,XML膨胀得没法维护。原作者的思路很直接:按钮部分全部用代码生成,XML只留一个外层容器。

2.1 initBtn与point:把坐标、View、样式ID打包进Tag

private void initBtn(int i, int j) { // getStyle()随机返回一个图块样式ID,这里直接赋给成员变量id btn[8 * i + j].setBackgroundDrawable( this.getResources().getDrawable(getStyle())); point p = new point(i, j, btn[8 * i + j], id); btn[8 * i + j].setTag(p); map[i][j] = num; }

这里有个容易被忽略的设计:point对象把行列坐标、按钮View引用、样式ID打包后,通过setTag()挂到了按钮上。这样在点击事件里,只要v.getTag()就能拿到按钮的全部信息,不用再通过数组下标反推坐标。

getStyle()返回的是1到7之间的随机数对应的drawable资源ID,同时把num记录到成员变量,map[i][j] = num把这个随机图块编号存进地图数组。这里map是int[8][8],存的是图案编号,不是View引用。

2.2 getStyle随机图块与btn.xml三态selector

private int getStyle() { // Math.random()*7 得到0.0~6.999,加1后强转int,范围1~7 num = (int)(1 + Math.random() * (7 - 1 + 1)); switch (num) { case 1: return id = R.drawable.btn1; case 2: return id = R.drawable.btn2; // 3~6省略,结构相同 case 7: return id = R.drawable.btn7; default: return id = 0; } }

随机范围是1到7,对应7种不同的图块样式。注意这里的id是成员变量,initBtn里先调getStyle()拿到随机样式ID,随后把这个id存进point对象。这样点击交换时,直接交换两个point里的id就能同步更新按钮背景,不用再重新解析地图。

按钮的三态样式写在btn1.xml里,本质是<selector>:

<?xml version="1.0" encoding="UTF-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@drawable/a1_2" android:state_pressed="true"/> <item android:drawable="@drawable/a1" android:state_focused="false" android:state_pressed="false"/> <item android:drawable="@drawable/a1_1" android:state_focused="true"/> <item android:drawable="@drawable/a1" android:state_focused="false"/> </selector>

四个<item>按优先级排列:按下态state_pressed="true"最优先,其次是焦点态,最后是普通态。这个Demo实际只用了按下和普通两种状态,焦点态是键盘操作场景预留的。写selector时有个习惯:把默认态放在最后一条,否则前面的条件不满足时匹配不到图。

2.3 代码生成布局:LinearLayout套TableLayout,8行8列怎么拼

LinearLayout vlayout = (LinearLayout) findViewById(R.id.vlayout); TableLayout tlayout = new TableLayout(this); TableRow row[] = new TableRow[8]; for (int i = 0; i < 8; i++) { row[i] = new TableRow(this); row[i].setGravity(Gravity.CENTER); for (int j = 0; j < 8; j++) { btn[8 * i + j] = new ImageButton(this); btn[8 * i + j].setLayoutParams(new TableRow.LayoutParams(38, 38)); initBtn(i, j); btn[8 * i + j].setOnClickListener(listener); row[i].addView(btn[8 * i + j]); } tlayout.addView(row[i]); } vlayout.addView(tlayout);

vlayout是main.xml里预定义的LinearLayout容器,tlayout是代码new出来的TableLayout。每个按钮的布局参数是38x38像素,这个尺寸在当时的模拟器上刚好,如果改成dp单位适配性会更好。按钮数组用8 * i + j做下标,i是行、j是列,保证btn[0]到btn[63]对应地图的(0,0)到(7,7)。

这里有个细节:main.xml里其实只有一个空LinearLayout和一个标题TextView,整个8x8按钮区域全是代码生成的。好处是可以随时改网格大小,比如把8改成10,只动循环边界就行。缺点也很明显——XML里看不到完整的UI结构,后续要加布局预览只能靠跑模拟器。

3. 消去判定与地图更新:find()双扫描、mark数组编码与更新顺序

三消游戏的核心是find判定。这个Demo的算法是O(2*n^2)的原始双扫描,代码里没有做任何优化,作者甚至故意不优化——因为每轮消去之间需要停顿来播动画,算太快反而要额外sleep。

3.1 横向纵向双扫描:count计数与标记写入

先看横向扫描部分的原始代码:

// heng:横向扫描 for (int i = 0; i < 8; i++) { int count = 1; for (int j = 0; j < 7; j++) { if (map[i][j] == map[i][j + 1]) { count++; if (count == 3) { flag = true; mark[i][j - 1] = 1; mark[i][j] = 1; mark[i][j + 1] = 1; score += 15; } else if (count > 3) { mark[i][j + 1] = 1; score += 5; } } else { count = 1; } } }

逻辑是:逐行比较当前格和右边一格,相同则count递增。当count到达3时,说明当前位置(i,j)和它左边两个(j-1、j)以及当前j+1构成三连,把这四个位置全部标记为1。注意mark值1的含义是:该位置需要被上方一格图案替换。count>3时,新多出的那个格子同样标记为1,分数只加5分,因为是长消除的增量部分。

这里有个边界:j-1只有count==3时才访问,而count==3意味着j>=2,所以不会越界。横向扫描只负责标记,不直接更新按钮。

3.2 mark数组编码设计:1代表上移一格,n代表上移n格

纵向扫描比横向复杂得多,因为涉及掉落高度:

// shu:竖向扫描 for (int j = 0; j < 8; j++) { int count = 1; for (int i = 0; i < 7; i++) { if (map[i][j] == map[i + 1][j]) { count++; if (count == 3) { flag = true; if (mark[i][j] == 1) score += 10; else score += 15; mark[i - 1][j] = 3; mark[i][j] = 3; mark[i + 1][j] = 3; // 上方预留位置标记为2,表示需要生成新图块 for (int k = i - 5; k >= 0; k--) { mark[k][j] = 2; } } else if (count > 3) { mark[i + 1][j] = count; for (int k = 1; k < count; k++) { mark[i - k + 1][j]++; } if (i - 2 * count + 2 >= 0) { mark[i - 2 * count + 2][j] = 0; } score += 5; } } else { count = 1; } } }

mark数组不再只是布尔量,而是int编码:

  • mark=1:位置被左侧或上方一格图案替换(横向消去场景)
  • mark=3:竖向三连消去,位置被上方第三格图案替换
  • mark=2:消去后上方没有足够图案可掉落,需要重新随机生成新图块
  • mark=n(n>3):长消除时,位置被上方第n格图案替换

为什么先把横向扫描跑完再跑纵向?因为十字形或T字形消去时,横向和纵向扫描会重叠标记同一个位置。更新时以纵向为准——掉落方向是垂直的,纵向的替换关系必须覆盖横向的残留标记。所以横向先写、纵向后写,纵向的mark赋值会自然覆盖横向的值。

3.3 updateState()更新顺序:为什么从上往下扫

private void updateState() { // 先恢复所有按钮透明度 for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) { btn[8 * i + j].getBackground().setAlpha(255); } } for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) { if (mark[i][j] == 1) { // 上移一格:当前格被上方格替换 for (int k = i; k > 0; k--) { updateBtn(k, j, k - 1, j); } updateBtn(0, j); } else if (mark[i][j] >= 3) { // 上移n格:判断上方是否有足够图案 if (i - mark[i][j] >= 0) { updateBtn(i, j, i - mark[i][j], j); updateBtn(i - mark[i][j], j); } else { updateBtn(i, j); } } else if (mark[i][j] == 2) { // 掉落来源为空,重新生成 updateBtn(i, j); } } } }

updateBtn有两个重载:一个接收四个坐标参数,把一个位置的图案复制到另一个位置;一个接收两个坐标,调用getStyle()随机生成新图案填充。这两个重载分别对应"掉落"和"新生成"两种场景。

更新顺序强制从上往下,这是这个Demo里最容易翻车的地方。如果从下往上扫,下面图案更新后可能破坏上方还没处理的图案,但mark数组里还留着上方需要消去的索引,就会错误地多消掉几个方块。反过来,上面先更新不会影响下面已经标记好的消去关系。

mark[i][j]>=3分支里有个边界判断:i - mark[i][j] >= 0,意思是上方第n格是否还在网格范围内。如果超出,说明没有足够的掉落来源,直接生成新图块。

4. 可行解检测与死局处理:交换试探、十字架区域与地图重置

消消乐每轮消去后,地图上剩余方块可能再也凑不出三连——这就是死局。不处理的话玩家只能干瞪眼。这个Demo用check()函数在每轮结束后检测是否存在至少一步可行交换。

4.1 为什么每次消去后都要check:无解死局直接卡死

while (find()) { updateState(); }

这段在onCreate里,作用是初始化时反复消除直到地图稳定(没有可消去的三连)。但如果初始地图本身就无解,while循环结束后游戏一开始就是死局。

对死局的处理逻辑在run()和handleMessage里:每次点击交换后,如果消去了方块但掉落完成后没有新的可消去组合,并且check()返回false,就通过Toast提示"已自动生成新地图",然后重新初始化所有按钮。

这里要注意check()和find()的区别:find()扫描的是当前地图,看有没有现成的三连;check()是模拟所有可能的相邻交换,看交换后能否产生三连。前者是"当前状态",后者是"下一步可能性"。

4.2 check(i,j)十字架区域判断与双重循环试探

private boolean check(int i, int j) { int count = 1; // 先检查纵向:上下各数相同图案数量 if (i >= 1 && map[i - 1][j] == map[i][j]) { count++; if (i >= 2 && map[i - 2][j] == map[i][j]) { count++; } } if (count >= 3) return true; if (i + 1 < 8 && map[i + 1][j] == map[i][j]) { count++; if (i + 2 < 8 && map[i + 2][j] == map[i][j]) { count++; } } if (count >= 3) return true; // 再检查横向:左右各数 count = 1; if (j >= 1 && map[i][j - 1] == map[i][j]) { count++; if (j >= 2 && map[i][j - 2] == map[i][j]) { count++; } } if (count >= 3) return true; if (j + 1 < 8 && map[i][j + 1] == map[i][j]) { count++; if (j + 2 < 8 && map[i][j + 2] == map[i][j]) { count++; } } return count >= 3; }

check(i,j)只检查某个点所在的十字架区域——也就是它上下左右四个方向上,相同图案的连续数量。这个检查是"局部"的,不扫描全图,所以很快。

整体check()的调用方式才是关键:先把所有相邻方块两两交换,每交换一次调用check()看交换的两个点是否会形成三连。注意只调用这两个点的check(),因为交换只会影响这两个点周围区域。

private boolean check() { // heng:横向相邻交换试探 for (int i = 0; i < 8; i++) { for (int j = 0; j < 7; j++) { swapMap(i, j, i, j + 1); if (check(i, j) || check(i, j + 1)) { swapMap(i, j, i, j + 1); return true; } swapMap(i, j, i, j + 1); } } // shu:纵向相邻交换试探 for (int j = 0; j < 8; j++) { for (int i = 0; i < 7; i++) { swapMap(i, j, i + 1, j); if (check(i, j) || check(i + 1, j)) { swapMap(i, j, i + 1, j); return true; } swapMap(i, j, i + 1, j); } } return false; }

每轮交换后必须swapMap换回来,也就是"交换→检查→恢复现场"。最坏情况下的复杂度是2*(n^2)28,n=8时一共1024次交换测试,每次只检查局部十字区域,模拟器上运行毫无压力。

4.3 flag区分两种"无解":用户点错还是地图死局

在run()里,三种分支对应三种结果:

  • find()为true:当前交换产生了可消去的三连,执行渐隐和更新动画
  • flag==true但find()为false:用户交换的这对按钮无法消去,把地图和按钮图片都换回来
  • flag==false且check()为false:消去过程结束,但地图已经没有可行解,重新生成地图

用flag区分这两种"无解"很关键:前者是用户操作无效,不需要重新生成地图;后者才是真正的死局,需要重置。

5. 避坑清单:调试消消乐时踩过的五个真实问题

5.1 现象:同一个Thread实例重复start导致崩溃

运行到第二步消去时,logcat直接抛IllegalThreadStateException。原因在于handleMessage的case 1里调用了thread.start(),而thread是在onCreate里new出来的同一个实例。第一次点击时线程启动并跑完了run(),线程进入TERMINATED状态,第二次start()就会崩。

解决方式有两种:一是在每次start()之前检查线程状态,二是放弃复用Thread实例,改成每次new Thread(MainActivity.this).start()。更稳妥的做法是用Handler.postDelayed串起整个动画时序,后面第6章会讲。

5.2 现象:十字交叉位置被漏消

如果地图上出现十字形——同一行和同一列都有三个相同图案,横向扫描先标记了交叉点,纵向扫描又覆盖了它。更新时以纵向为准本来没错,但横向消去的那三个方块里,有两个不在纵向覆盖范围内,就会被漏掉。

解决方式是用mark记录时遵循"先横后纵、纵覆盖横"的顺序,同时更新分数时判断mark[i][j]是否已经被横向标记过,被标记过就少加5分,避免十字交叉点重复计分。

5.3 现象:从下往上更新导致图案错位

updateState()里如果反向扫描,先更新了底部的按钮,底部图案移动后,上方未处理的图案引用的可能已经是移动后的旧值,导致多个位置被错误消去。这就是注释里写的"上面图案的消去不会破坏下面,但下面更新会破坏上面"。解决方式很简单:更新循环必须从上往下、从左往右。

5.4 现象:无解地图进入死循环

初始化时while(find()) updateState()不停消除,消完发现没有可行解,但check()因为边界判断漏判返回了false,导致游戏一开始就没法玩。

问题出在check(i,j)的边界条件是严格i>=2、j>=2才检查第二个上方/左方格,但如果三连恰好出现在第0和第1行之间,比如(0,1)、(1,1)、(2,1)交换后,检查(0,1)时上方没有格子,(1,1)检查时i>=1且(0,1)相同、(2,1)相同,这个条件下(1,1)会返回true。所以不是漏判,而是初始地图生成时随机性太差,7种图案在一个8x8地图上很容易出现无解分布。解决方式是在onCreate里循环while(find()) updateState()后再加一层while(!check())重新生成稀土地图。

5.5 现象:swapImage后按钮背景与map数据不一致

点击相邻按钮时,listener里先swapMap交换了map数组的数据,再swapImage交换按钮背景图。如果之后find()判断无解,run()里会再次swapMap把地图换回来,同时sendEmptyMessage(2)通知主线程执行swapImage。

但handleMessagecase 2里重置了p1、p2为(-2,-2),如果玩家在动画播放期间又快速点击了其他按钮,onClick会用新的p1去和旧的p2做相邻判断,导致交换逻辑错乱。解决方式是在动画播放期间加一个布尔锁,或者把p1、p2的置空放到handleMessage执行完之后。

6. 多线程动画的验证方法:用Log追踪消息序列

这个Demo的动画链路是:点击交换 →thread.start()→find()判断 → 10次渐隐消息(每次间隔30ms)→ 停顿100ms → 更新地图消息 → 再次thread.start()循环。

调试时最有效的验证手段是插桩打Log。在run()的每个分支入口加Log.d(TAG, "run: find=" + find()),在handleMessage的每个case里加Log.d(TAG, "msg: " + msg.what)。跑一轮之后看日志顺序:理想序列应该是msg: 0连续10次,中间间隔约30ms,然后msg: 1,再回到run: find=...。如果看到msg: 1之后直接msg: 3,说明消去后掉落没有产生新的可消去组合,直接走到了重新生成地图。

还可以在hideBtn()里打印每次Alpha值:Log.d(TAG, "alpha=" + alpha)。正常情况是从255递减到5附近,每步减少25。如果发现Alpha没有递减,或者一次跳到了0,说明主线程消息堆积,10条渐隐消息被合并执行了——这是Thread.sleep精度不足导致的,可以改用SystemClock.sleep或Handler.postDelayed来修正。

调试这个Demo时我还养成一个习惯:每次改完逻辑,强制走一遍"点击可消去按钮→观察渐隐→观察掉落→观察自动消去→确认无死局"完整流程。特别是Thread.start()这一步,只允许一个线程实例存活,否则必崩。从那以后我每次写Android动画链路,都先把消息序列的Log打印格式定好再动手。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询