简介:面向安卓开发初学者与进阶者,这份文档以开心消消乐游戏为实例,从零讲解如何用Java与安卓SDK实现简易消除玩法。内容覆盖动态生成8×8按钮网格、按钮按下普通与焦点三种状态的样式配置、点击事件中滚动变量记录与相邻交换逻辑,以及通过二维数组标记实现行列三连消除判断、消去后按标记更新图案和地图无解时的重排方案。文档还介绍了按钮通过随机样式标识与坐标对象存储供点击判断调用等实现细节。作者结合自身项目经历强调先扫横行再扫竖列、更新时从上往下的顺序,并给出最坏时间复杂度分析,帮助读者避开常见坑点。压缩包仅1个PDF文件,大小378KB,内容精炼适合快速阅读。目前已有3221人学习下载,适合想通过完整代码实例理解安卓交互与算法结合的开发者参考。
1. 开局第一问:Android 开心消消乐代码实例,到底能拆出什么值钱的东西
很多人搜“Android开心消消乐代码实例详解”是想找个现成工程改改就能上线,但真正拿到一套消消乐源码后,卡住他们的往往不是“不会写”,而是“不知道从哪下手”:消除判定写在哪、特效怎么挂上去、步数扣减和过关条件是谁在驱动。这套游戏表面上是“三消”两个字,背后其实是格子地图、交换逻辑、消除检测、下落填充、连击判定、动画时序和状态机的一整套组合。你把这套代码读透一次,等于把 2D 网格游戏最常见的骨架手搓了一遍,以后去做其他棋盘类、连线类项目,迁移成本会低很多。
这篇笔记我会顺着一个典型消消乐工程的代码结构往下拆:先交代清楚每个核心类在干什么、模块之间怎么调用,再把消除判定、下落填补、连击与动画这些最容易翻车的环节按真实工程里的写法一步步过代码。新手能照着把工程跑起来并看懂主流程,熟手可以在我标出的边界条件和参数调优点上直接拿去改。最后会用一整章写我实际调试中踩过的坑——这些坑网上很少有人在代码注释里给你标出来,但十有八九你会撞上。
2. 拿到工程先别跑:先分清“游戏逻辑”和“界面表现”两条线
2.1 一个最小消消乐工程的目录结构与类职责划分
我见过的消消乐实例工程,无论作者怎么命名,核心类跑不出下面这七个。拿到代码后先对着目录确认一遍,别急着点 Run。
| 类名(常见命名) | 职责 | 对应游戏阶段 |
|---|---|---|
| GameActivity | 入口 Activity,承载 SurfaceView/Fragment,管生命周期 | 启动 |
| GameView / GameSurfaceView | 绘制网格、绘制棋子、接收触摸事件 | 界面表现 |
| GameEngine / GameLogic | 存二维数组、检测消除、处理下落、生成新棋子 | 游戏逻辑 |
| GridCell / Piece | 单个格子或棋子的数据模型,持有类型、坐标、状态 | 数据 |
| AnimatorHelper / EffectManager | 播放消除动画、下落动画、特效 | 界面表现 |
| AudioManager | 播放消除音效、点击音效、过关音效 | 界面表现 |
| LevelManager / GameState | 管理关卡步数、目标分数、游戏状态流转 | 流程控制 |
这里有一个很多初学者看不明白的点:为什么要把 GameEngine 和 GameView 拆开?原因很简单,消消乐的调试难点几乎都在逻辑层——消除算法、下落算法、连击结算——这些跟绘制完全解耦之后,你才能脱离界面单独写单元测试,也能在出 bug 时用 Log 输出二维数组状态而不是盯着屏幕猜。我在做模拟项目X时,第一版就是把逻辑和绘制全写在 View 里,结果排查一个“不该消除却消除了”的问题花了两个晚上,后来拆成双线程模型,同类问题半小时定位。
逻辑层和表现层的通信方式,常见做法是引擎层维护一个二维数组,每次操作完成后通过回调或者接口把“待播放动画的格子列表”抛给 View 层,View 层收到后执行动画,动画结束再回调用引擎继续下一步。工程里通常会有一个简单的观察者接口,比如onEliminate(int row, int col, int type)、onFall(int fromRow, int fromCol, int toRow, int toCol),你在读代码时优先找这三个回调,找到就说明工程的结构是健康的。
2.2 为什么说二维数组是整套代码的地基
消消乐的地图本质是一张int[][]二维表,行和列对应屏幕上的网格,数组里存的值代表棋子类型,比如 0 代表红色方块、1 代表黄色方块、2 代表蓝色方块、3 代表绿色方块。你后续所有操作的共同前提,都是先把屏幕坐标换算成格子坐标。
// 将触摸点的像素坐标换算成网格坐标 public int[] pixelToGrid(float x, float y) { int row = (int) (y / cellHeight); int col = (int) (x / cellWidth); if (row < 0 || row >= rows || col < 0 || col >= cols) { return null; // 点击超出棋盘范围 } return new int[]{row, col}; }逻辑说明:cellHeight和cellWidth是每个格子的像素尺寸,由棋盘总高度除以行数、总宽度除以列数得到。换算这一步出错,后面一切交互都会偏移。参数说明:这里有一个边界条件容易被忽略——当触摸点在棋盘边缘的半格区域内时,int强转后可能算出越界的 row 或 col,所以必须做边界判断。我一般习惯在棋盘四周留出 10dp 的 padding,换算时先减掉 padding 再做除法,这样手指滑到边缘时不会触发误操作。
交换两个格子时,你交换的其实是二维数组里的值,不是屏幕上的图片。正确流程是先改数组,再用新数组状态刷新 UI,而不是先移动图片再回头改数据,否则动画播放到一半时逻辑层和数据层就对不上了。数据结构对了,整个游戏就稳了一半。
2.3 交换、匹配、消除、下落:代理消消乐的四步主循环怎么写
// 玩家滑动后执行的主流程,这是整个游戏的心脏 public void onPlayerSwipe(int fromRow, int fromCol, int toRow, int toCol) { // 1. 交换两个格子(校验是否相邻) if (!isAdjacent(fromRow, fromCol, toRow, toCol)) return; swap(fromRow, fromCol, toRow, toCol); // 2. 检测是否有可消除的匹配 List<int[]> matches = findMatches(); if (matches.isEmpty()) { // 没有匹配,交换无效,换回来 swap(toRow, toCol, fromRow, fromCol); return; } // 3. 标记消除并播放动画 eliminate(matches); // 4. 格子下落填补,并持续检测直到棋盘稳定 while (true) { collapse(); matches = findMatches(); if (matches.isEmpty()) { break; } eliminate(matches); } }逻辑说明:这一段代码是按“消除四步曲”组织的——交换、检测、消除、下落,而且下落之后要重新检测,因为下落的棋子可能和相邻棋子形成新的匹配,也就是连击。参数说明:isAdjacent判断两格是否上下左右相邻,对角相邻不算有效交换。大部分消消乐对无效交换的处理就是无脑换回来,玩家在视觉上只会看到轻微的“弹回”效果,这符合操作预期。
真正要注意的是第 4 步的循环写法。有些工程会把collapse和findMatches写成递归调用,结果下落动画还没播完就开始下一次检测,导致棋盘画面和逻辑数据脱节。我推荐的做法是——动画播放用异步回调,但循环检测用同步代码维持逻辑先行,“视觉表现可以慢一步,逻辑数据必须即时一致”。这也是区分消消乐工程老练与否的一个关键点。
3. 消除判定算法:三种写法横向对比,选最稳的那种
3.1 逐行扫描法:最容易读懂、最适合新手快速上手
// 逐行扫描横向匹配,再逐列扫描纵向匹配 public List<int[]> findMatches() { List<int[]> matches = new ArrayList<>(); // 横向扫描:遍历每一行,统计连续相同棋子的个数 for (int row = 0; row < rows; row++) { int count = 1; for (int col = 1; col < cols; col++) { if (board[row][col] == board[row][col - 1]) { count++; } else { if (count >= 3) { for (int k = col - count; k < col; k++) { matches.add(new int[]{row, k}); } } count = 1; } } if (count >= 3) { for (int k = cols - count; k < cols; k++) { matches.add(new int[]{row, k}); } } } // 纵向扫描:逻辑同上,只是把行列互换 for (int col = 0; col < cols; col++) { int count = 1; for (int row = 1; row < rows; row++) { if (board[row][col] == board[row - 1][col]) { count++; } else { if (count >= 3) { for (int k = row - count; k < row; k++) { matches.add(new int[]{k, col}); } } count = 1; } } if (count >= 3) { for (int k = rows - count; k < rows; k++) { matches.add(new int[]{k, col}); } } } return matches; }逻辑说明:横向扫描的做法是以“行”为遍历单元,从左到右统计连续相同棋子的数量,一旦数量达到 3 就把这段连续的格子加入匹配列表;纵向扫描同理,只是将行列互换。这种算法的时间复杂度是 O(rows×cols),对标准 8×8 或 9×9 的棋盘毫无压力。参数说明:count >= 3里的 3 就是“三消”的阈值,你可以把它抽成常量MATCH_THRESHOLD,后续做 4 消、5 消特殊棋子时直接改这一处就行。
逐行扫描法最大的优点是好查错——你打印出 board 数组,照着坐标一行行对,能找到任何异常。它的缺点是重复匹配会重复添加同一个格子。比如一个 L 型消除同时满足横向 3 连和纵向 3 连,交叉点的格子会被加进列表两次,后续消除时需要对匹配列表去重。
3.2 洪泛填充法:处理不规则消除和高阶棋子的进阶选择
// 用颜色填充的思路做连通区域匹配 public void floodFill(int row, int col, int type, boolean[][] visited, List<int[]> area) { if (row < 0 || row >= rows || col < 0 || col >= cols) return; if (visited[row][col]) return; if (board[row][col] != type) return; visited[row][col] = true; area.add(new int[]{row, col}); floodFill(row - 1, col, type, visited, area); // 上 floodFill(row + 1, col, type, visited, area); // 下 floodFill(row, col - 1, type, visited, area); // 左 floodFill(row, col + 1, type, visited, area); // 右 }逻辑说明:这个算法从任意未访问的格子出发,朝上下左右四个方向递归扩散,把所有相同类型的格子圈成一个连通区域。这样得到的不是“一行三连”,而是一整块同色区域。参数说明:visited二维布尔数组是防止重复访问的关键,递归深度在 8×8 棋盘上最多几十层,不会触发栈溢出。但要注意,这种方法会把所有相通的同色格子全部圈进来,比如一个 2×2 的同色方块也会被识别成“连通区域”,所以你在判定消除时要加面积条件,通常是area.size() >= 3。
洪泛填充法的优势在于它能自然处理 T 型、L 型、田字型的多种匹配形态,而逐行扫描法对这类形状需要额外合并逻辑。代价是递归写法可读性稍差、边界情况需要多测试。对普通三消来说,洪泛填充的“过度识别”问题有点大——比如 2×2 方块你不想消除它,但洪泛会判定为连通区域。这时可以加限制:面积大于 3 的不算(排除田字),或者只统计每个方向连续长度。我自己的取舍是:只有要做特殊棋子、全屏消除、障碍物消除时才上洪泛法,普通消消乐用逐行扫描法改动面最小。
3.3 匹配后去重:避免一个格子被消除两次的致命 bug
// 去重防止一个格子被重复消除 public Set<String> getUniqueMatches(List<int[]> matches) { Set<String> set = new HashSet<>(); for (int[] cell : matches) { String key = cell[0] + "_" + cell[1]; // 用字符串拼行和列做唯一键 set.add(key); } return set; }逻辑说明:一个格子同时命中横向匹配和纵向匹配时,如果不做去重,eliminate阶段会对同一个坐标执行两次减分、两次特效播放、两次数组置空,轻则动画卡顿,重则数组越界。参数说明:用row_col拼字符串做 key 是最简单的做法,数据量小完全够用;也可以用一个List<int[]>自己实现 equals,但对 64 个格子来说没这个必要。
去重后返回的数据结构最好是List<int[]>,因为Set遍历顺序不稳定,而动画播放希望从左到右、从上到下有序进行。实际操作是:先把扫描结果全部塞进Set,再转成ArrayList并按行列排序。这段代码无需引入第三方库,纯 JDK 写法在任何 Android 版本上都稳定运行。
4. 下落与填补机制:决定手感的核心模块,参数全部可调
4.1 列式下落法:自底向上扫描,空位交给上方棋子落下
public void collapseColumn(int col) { // 从下往上,把所有非空棋子紧凑地移到最底部 int nextRow = rows - 1; for (int row = rows - 1; row >= 0; row--) { if (board[row][col] != EMPTY) { if (nextRow != row) { board[nextRow][col] = board[row][col]; board[row][col] = EMPTY; // 通知界面播放下落动画 notifyFallListeners(row, col, nextRow, col); } nextRow--; } } // 顶部的空位生成新棋子 for (int row = nextRow; row >= 0; row--) { int type = randomType(); board[row][col] = type; // 通知界面播放新棋子生成动画 notifyNewPieceListeners(row, col, type); } }逻辑说明:nextRow是指向下一个可填充位置的“指针”,自底向上扫描时,遇到非空棋子就把它搬运到nextRow所指的位置,遇到空位就跳过。扫描结束后,棋盘顶部nextRow之上全是空位,用随机类型棋子填满。参数说明:randomType()的随机范围决定关卡难度,简单关卡常见的是 4 种颜色,困难关卡可能加到 6 种。颜色种类越多,自然形成三连的概率越低,这是控制难度的核心参数。
这里要特别注意“搬运和下落”的区别:board[nextRow][col] = board[row][col]是直接改数据,动画是 View 层根据notifyFallListeners里的新旧坐标自己算位移。如果动画和逻辑不同步,你会看到棋子瞬间跳到新位置再或者先动画后跳回,两种都是体验灾难。常见处理是在逻辑层先把所有下落路线收集好,一次性发给 View 层,View 层统一播放动画而不是边循环边通知边播放。
4.2 下落与消除的死循环检测:防止棋盘永不安定
public boolean isBoardStable() { return findMatches().isEmpty(); } public void resolveBoard() { int maxIterations = 50; // 防止死循环的兜底 int iteration = 0; while (!isBoardStable() && iteration < maxIterations) { collapseAllColumns(); eliminate(findMatches()); iteration++; } }逻辑说明:每次消除后棋盘都会出现空位和下落,下落后又可能产生新匹配,所以“消除—下落—再检测—再消除”会循环多次。maxIterations是一个安全阀,防止极端情况下棋盘陷入无限循环。参数说明:50 次对 8×8 棋盘是极其充足的余量,实际运行中连击次数很难超过 10 次。但如果你觉得 50 太大,可以设成 20 并在循环里加Log.d打印当前迭代序号,观察实际连击分布来调整。
这个模块的另一个坑点是:下落动画的时长和循环节奏要配合。如果用Thread.sleep同步等待动画播完再进入下一次循环,会卡 UI;如果不等动画直接跑逻辑,画面又跟不上。常见做法是——逻辑层异步跑完所有判定,把“动画队列”一次性交给 UI 层顺序播放,逻辑不等待动画。这也是类似“快进”和“倍速播放”功能能够实现的基础,因为你只需要加速动画播放时间,不用动逻辑。
4.3 控制难度的参数组合:颜色数量、步数与目标分联动
| 参数 | 默认值 | 调大影响 | 调小影响 |
|---|---|---|---|
| 颜色种类 | 4 | 三消概率降低,关卡更难 | 三消概率升高,关卡偏简单 |
| 步数上限 | 25 | 玩家容错率提高 | 高压关卡,逼迫玩家规划 |
| 目标分数 | 每消一个 10 分 | 需高连击才能过 | 轻易达成,无挑战 |
| 棋盘尺寸 | 8×8 | 单局时长变长 | 空间拥挤,策略受限 |
这组参数在代码里通常集中在LevelManager或GameConfig文件中。我在做某跨平台系统的消消乐模块时,把这四个参数做成关卡表level_config的形式,每一关读取一行配置,这样新增关卡只需要改配置表,不需要动代码。新手容易犯的错是把颜色种类写死在randomType()里,后面调整难度时满项目找 magic number,极其痛苦。建议从写第一行代码起就养成抽取常量的习惯。
5. 动画与音效的接入方式:别让逻辑跑得好却看起来廉价
5.1 消除特效与下落动画是挂在哪个回调上的
public interface GameEventListener { void onEliminate(List<int[]> cells); // 消除动画 void onFall(int fromRow, int fromCol, int toRow, int toCol); // 下落动画 void onNewPiece(int row, int col, int type); // 新棋子生成 void onInvalidSwap(); // 无效交换回弹 }逻辑说明:这个接口是逻辑层暴露给 View 层的“事件出口”,View 层注册监听器后在回调里启动对应的动画。消消乐动画最耗性能的操作是取消动画或执行中的状态切换,所以接口设计要保证每个回调对应一个独立的动画方法,不要在回调里同时启动多个相冲突的动画。参数说明:onEliminate接收到的是去重后的坐标列表,View 层拿到后一次性创建多组动画播放,播放完成再通知逻辑层“消除动画已完成”,此时才把数组里的对应格子置空。
实际工程里用ValueAnimator做位移动画是最轻量的选择,它适合大量并行动画,性能优于ObjectAnimator逐属性创建的成本。我做过的项目里,8×8 棋盘同时消除 12 个格子会创建 12 个ValueAnimator,中低端手机上依然流畅,核心原因是每个动画时长控制在 200ms 到 300ms,且全部使用LinearInterpolator,避免贝塞尔曲线增加计算量。
5.2 音效播放别用线程睡等待,用回调链串起来
消消乐音效看起来简单——点击一声、消除一声、连击升高音调一声、过关一声——但处理不当会出现严重的音画不同步。我的习惯是把音效播放挂在动画回调链上而不是时间轴上:动画开始时播放“消除预备音”,动画结束回调里播放“消除完成音”,这样音效始终跟随事件发生而不是跟定时器。
// 在动画结束回调里播放音效,保证音画同步 animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { audioManager.playEliminateSound(); engine.handleAfterEliminateAnimation(); // 继续逻辑 } });逻辑说明:onAnimationEnd回调是音效播发和逻辑继续的“汇合点”。要注意AnimatorListenerAdapter的onAnimationEnd在某些快速连续点击场景下可能被系统跳过,因为多个动画互相 cancel。参数说明:预防方式是每次新动画启动前,把旧的监听器置空,保证一个动画只绑定一次回调。这里也是大量消消乐代码出现“无声但画面正常”或“逻辑卡住”的根源,排查时优先检查有没有动画被中途取代导致回调没执行。
6. 消消乐实例工程避坑指南:我亲手踩过的五个雷
6.1 界面点击时接收到多次触摸事件,导致连续交换
现象:玩家快速滑动两个格子,画面出现两个格子同时对调、后面一次把前面一次覆盖的情况。
原因:手指在屏幕上快速滑动时,会连续触发多个ACTION_DOWN和ACTION_MOVE事件;如果你在ACTION_DOWN里就开始执行交换逻辑,一个手势可能触发两次完整交换。解决:在onTouchEvent开头加防重入标志位,用一个boolean isSwapping控制,交换未完成时直接忽略新的触摸事件。这类 bug 在模拟器上几乎复现不了,必须在真机上调试。
6.2 交换检测时把对角线交换当成了合法操作
现象:玩家从左上角斜滑到右下角,两个格子发生了交换,但这在消消乐规则里是无效的。
原因:isAdjacent方法里同时判断了行差和列差绝对值都等于 1,这种情况是对角,按规则不能交换。解决:改成“行差等于 1 且列差等于 0” 或 “行差等于 0 且列差等于 1”,用异或判断更简洁。
6.3 下落循环里死循环导致 UI 无响应
现象:某次消除后,游戏界面完全卡死,Logcat 里刷出大量重复的消除日志。
原因:findMatches里的去重没做好,或某种棋盘状态让消除后生成的匹配永远满足不了“ 匹配消失 ”条件。解决:给resolveBoard加上maxIterations兜底,同时每次消除后打印棋盘数组快照,能更快看出问题状态。
6.4 新棋子生成时数组越界崩溃
现象:游戏运行到某一步后直接闪退,堆栈指向collapseColumn里的board[row][col]赋值。
原因:nextRow指针推算错误,或下落时列索引超出范围,通常是cols和rows在循环里用混了。解决:在循环里先打印 row 和 col 的取值,确认都是从 0 开始、小于 rows/cols;另外把全部下落逻辑统一到同一个方法里,避免不同列之间互相干扰。
6.5 动画和逻辑错位:棋子先消失又出现,或动画结束后位置不对
现象:消除动画播放完之后,屏幕上有些棋子闪了一下又回到原来的位置。
原因:动画播放期间,逻辑层其实已经跑完了下一轮消除,把数组里的数据改了;但 View 层还在播放旧动画,动画结束时用新数组状态刷新,导致闪烁或跳位。解决:整个交换、匹配、消除、下落流程在逻辑层以同步方式快速完成,再把最终棋盘状态一次性交给 View 层重绘;动画只做视觉过渡,不参与数据计算。
7. 进阶:把你手里的消消乐代码改成“可联机对战”的最小改动方案
消消乐往前走一步,最常见也最值钱的方向是联机对战或好友比拼。但绝大多数实例工程的结构里,GameEngine和GameView是写在同一线程里的,要改成联机,核心不是引入网络库,而是先引入“指令化”机制——把玩家的每一步操作压缩成一条指令字符串,传到对方设备,对方设备用自己的引擎重放这条指令。
// 联机指令的简化格式:操作类型_行1_列1_行2_列2 public String packMove(int fromRow, int fromCol, int toRow, int toCol) { return "SWIPE_" + fromRow + "_" + fromCol + "_" + toRow + "_" + toCol; } // 收到对方指令后,直接调用本地引擎执行,保证两幕一致性 public void onReceiveMove(String cmd) { // 解析指令各段 String[] parts = cmd.split("_"); int fromRow = Integer.parseInt(parts[1]); int fromCol = Integer.parseInt(parts[2]); int toRow = Integer.parseInt(parts[3]); int toCol = Integer.parseInt(parts[4]); engine.onPlayerSwipe(fromRow, fromCol, toRow, toCol); }逻辑说明:这条指令的粒度是“玩家每次滑动交换”,不含随机生成棋子的种子。为什么?因为两边的引擎各自维护一套随机数发生器,只要初始种子相同,后续新棋子的生成序列就完全一致。这样联机传输的数据量极小,每步操作最多十几个字节,实时性很好。参数说明:seed要在开局时同步给双方设备,这是保证公平性和一致性的关键。我踩过的坑是,只同步指令不同步随机种子,导致同样指令在两边生成的新棋子不同,比到一半棋盘就对不上了,联机当场变单机。
如果你只想做“好友分数比拼”而不做实时对战,那改动更轻:本地完成一局后,把最终分数、步骤数和棋盘快照压缩上传到排行榜服务,不需要动引擎代码。这个方案的落地成本低得多,适合先把消消乐核心玩通再考虑网络层。
说到最后,我给你一条真心建议:读这种“代码实例详解”类的工程,别沉迷于把每一行都看懂,先跑通,再改一个参数,再试着加一个功能。我从某图像处理Demo到完整消消乐的成长路径,就是从“把颜色种类从 4 改成 5”这一步开始的。你自己亲手改一遍 4→5,比看十遍源码注释都管用,因为你会立刻发现随机出子概率、消除判定阈值和难度曲线全都在跟着变——那些藏在源码深处的参数突然就活了。希望这篇能帮到你,少走一段我走过的弯路。
本文还有配套的精品资源,点击获取