简介这份PDF文档面向具备一定Java与Android基础的开发者围绕开心消消乐这一经典三消游戏的完整实现展开讲解帮助读者理解从布局搭建到消除判定的核心开发思路。内容涵盖基本概念、XML与代码混合布局、按钮点击事件处理、UI与动画设计以及游戏逻辑等模块其中8×8按钮数组的代码化布局、按钮三种状态的selector定义、用两个滚动变量记录先后点击位置、借助二维mark数组标记可消去方块并处理横纵交叉覆盖顺序等细节均有具体示例代码支撑对理解三消类游戏的算法设计颇具参考价值。资源包为1个PDF文件大小约378KB轻量便于随时查阅。目前已有3220人学习适合想通过小demo练手Android界面与逻辑开发的读者参考借鉴。1. 从零拆一个开心消消乐Android 三消游戏到底难在哪很多人第一次在 Android Studio 里做开心消消乐卡住的地方根本不是「不会写 Java」而是不知道一个三消游戏该拆成几块。棋盘怎么存、消除怎么判、连锁怎么算、动画怎么接这四件事只要有一件没想清楚写到一半就会推倒重来。我见过太多人上来就画九宫格、贴图标结果连「交换两个相邻格子后是否合法」都判断不出来最后整个项目烂尾。这篇内容面向的是有 Android 基础、想完整跑通一个三消 Demo 的开发者。我会按「棋盘数据结构 → 匹配判定 → 消除与下落 → 连锁与计分 → 动画与性能」这条线把开心消消乐最核心的代码实例拆开讲。你跟着走完能拿到一个可运行、可扩展的骨架而不是一堆散落的代码片段。Android 开发里做游戏逻辑最忌讳的就是把界面和数据揉在一起这一点在三消里尤其致命。2. 棋盘数据结构与初始化为什么不能用二维数组硬扛2.1 用一维数组还是二维数组存棋盘先说结论棋盘用一维数组存逻辑上用二维坐标访问。这是我在实际项目里踩过坑之后固定下来的做法。二维数组int[][] board看起来直观但做下落、交换、序列化的时候到处是嵌套循环代码又臭又长。一维数组配合index row * COLS col的换算遍历和批量操作都干净得多。public class GameBoard { public static final int ROWS 8; public static final int COLS 8; // 0-5 表示六种糖果类型-1 表示空格 private final int[] cells new int[ROWS * COLS]; public int get(int row, int col) { return cells[row * COLS col]; } public void set(int row, int col, int value) { cells[row * COLS col] value; } // 判断坐标是否在棋盘内交换和匹配前必须调用 public boolean inBounds(int row, int col) { return row 0 row ROWS col 0 col COLS; } }这段代码的关键在于inBounds。三消里大量操作是「取当前格子上下左右四个邻居」如果不做边界判断row - 1在row 0时会变成-1一维数组直接抛ArrayIndexOutOfBoundsException。参数上ROWS和COLS我一般设成 8这是开心消消乐经典关卡的尺寸再大屏幕适配会麻烦再小连锁空间不够。2.2 初始化时如何避免开局就有三连初始化不能纯随机。纯随机会导致开局棋盘上直接存在可消除的三连玩家一进来就白送分体验很怪。常见做法是逐格生成生成时检查左边两个和上边两个是否同色同色就重摇。private void initBoard() { Random random new Random(); for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { int value; do { value random.nextInt(6); } while (createsImmediateMatch(row, col, value)); set(row, col, value); } } } private boolean createsImmediateMatch(int row, int col, int value) { // 检查左边两个是否同色 if (col 2 get(row, col - 1) value get(row, col - 2) value) { return true; } // 检查上边两个是否同色 if (row 2 get(row - 1, col) value get(row - 2, col) value) { return true; } return false; }createsImmediateMatch只检查左和上是因为棋盘是从左上往右下生成的右边和下面的格子还没填检查了也没意义。这个细节很多人写反把四个方向都查一遍结果逻辑上永远返回 false开局照样有三连。参数random.nextInt(6)里的 6 是糖果种类数种类越多越难凑三连但太少又容易连锁爆炸6 到 7 是比较舒服的区间。3. 匹配判定与交换合法性三消逻辑的心脏3.1 横向和纵向扫描的匹配算法匹配判定是三消最核心的算法。思路很直接分别按行和按列扫描找出连续三个及以上同色的区间。我一般用一个boolean[] matched数组标记哪些格子被消除避免行和列重复标记同一个格子。public boolean[] findMatches() { boolean[] matched new boolean[ROWS * COLS]; // 横向扫描 for (int row 0; row ROWS; row) { int runStart 0; for (int col 1; col COLS; col) { boolean same col COLS get(row, col) ! -1 get(row, col) get(row, runStart); if (!same) { if (col - runStart 3) { for (int k runStart; k col; k) { matched[row * COLS k] true; } } runStart col; } } } // 纵向扫描逻辑与横向一致只是行列互换 for (int col 0; col COLS; col) { int runStart 0; for (int row 1; row ROWS; row) { boolean same row ROWS get(row, col) ! -1 get(row, col) get(runStart, col); if (!same) { if (row - runStart 3) { for (int k runStart; k row; k) { matched[k * COLS col] true; } } runStart row; } } } return matched; }这里有个容易翻车的点runStart的更新时机。当same为 false 时说明当前连续段结束先判断长度是否够三再把runStart挪到当前位置。如果先挪再判断长度永远差一。另外get(row, col) ! -1这个条件不能省空格不能参与匹配否则消除后下落过程中会误判。3.2 交换合法性判断与回退玩家交换两个相邻格子后必须判断这次交换是否产生了匹配。如果没有匹配就要把两个格子换回去。这个「换过去再换回来」的过程是三消里最容易被忽略的交互细节。public boolean trySwap(int r1, int c1, int r2, int c2) { // 只允许相邻交换 if (Math.abs(r1 - r2) Math.abs(c1 - c2) ! 1) { return false; } swap(r1, c1, r2, c2); boolean[] matched findMatches(); boolean hasMatch false; for (boolean b : matched) { if (b) { hasMatch true; break; } } if (!hasMatch) { swap(r1, c1, r2, c2); // 没有匹配换回去 } return hasMatch; }Math.abs(r1 - r2) Math.abs(c1 - c2) ! 1这个判断用的是曼哈顿距离只有上下左右相邻才等于 1斜对角等于 2 会被拒绝。这个写法比分别判断四个方向简洁也不容易漏。交换后立刻调用findMatches有匹配就保留交换结果没有就回退。注意回退一定要在动画播放之前完成否则玩家会看到格子换过去又弹回来视觉上很别扭。4. 消除、下落与连锁让棋盘动起来4.1 消除后如何做重力下落消除只是把匹配的格子标记为 -1真正让棋盘「活」起来的是下落。下落逻辑是从下往上逐列处理遇到空格就把上面的格子往下挪。public void applyGravity() { for (int col 0; col COLS; col) { int writeRow ROWS - 1; // 从最底部开始写 for (int row ROWS - 1; row 0; row--) { if (get(row, col) ! -1) { set(writeRow, col, get(row, col)); if (writeRow ! row) { set(row, col, -1); } writeRow--; } } // 剩余顶部空格补新糖果 for (int row writeRow; row 0; row--) { set(row, col, randomCandy()); } } }writeRow是写入指针从底部开始。遍历时遇到非空格就写到writeRow位置然后writeRow上移。遍历结束后writeRow以上的位置全是需要补新糖果的空格。这个算法是原地操作不需要额外数组时间复杂度 O(ROWS × COLS)。参数上要注意writeRow ! row这个判断如果相等说明格子本来就在正确位置不需要清空省一次赋值。4.2 连锁消除的循环控制与终止条件一次消除后下落下落完可能又形成新的三连这就是连锁。连锁用while循环处理直到findMatches找不到任何匹配为止。public int resolveBoard() { int totalScore 0; int chain 0; while (true) { boolean[] matched findMatches(); int count 0; for (boolean b : matched) { if (b) count; } if (count 0) break; // 没有匹配连锁结束 chain; // 连锁加成第几连就乘几倍 totalScore count * 10 * chain; for (int i 0; i matched.length; i) { if (matched[i]) { cells[i] -1; } } applyGravity(); } return totalScore; }chain变量记录当前是第几连用来做分数加成。第一连 1 倍第二连 2 倍以此类推。这个设计能让玩家有「连击」的爽感也是开心消消乐这类游戏的核心反馈。终止条件是count 0也就是棋盘上再也找不到三连。这里要特别注意applyGravity补新糖果后必须重新调用findMatches否则连锁会漏掉。我见过有人把补糖果放在循环外面结果连锁永远只有一次。5. 避坑与排查三消开发中最容易翻车的五个地方5.1 消除后棋盘出现「幽灵格子」现象消除并下落之后某些位置显示的还是旧糖果但逻辑上已经是空格点击没反应。原因Adapter 或自定义 View 没有调用notifyDataSetChanged或者刷新时只更新了部分格子。解决每次resolveBoard结束后对整个棋盘做一次全量刷新。不要试图只刷新变化的格子三消的连锁会让变化范围不可预测全量刷新最稳。5.2 交换动画和逻辑不同步现象玩家快速连续交换格子位置和逻辑数据对不上出现「两个格子重叠」。原因动画是异步的逻辑是同步的动画还没播完逻辑已经改了数据。解决加一个isAnimating标志位动画期间禁止新的交换操作。等动画回调结束后再置回 false。这个锁看起来简单但不加的话线上必出问题。5.3 连锁次数过多导致 ANR现象偶尔一次消除触发十几连界面卡死几秒系统弹出无响应。原因resolveBoard在 UI 线程同步执行连锁次数多时计算量大。解决把匹配和下落计算放到子线程算完再回 UI 线程刷新。或者给连锁加一个上限比如最多 10 连超过就强制结束。我一般用前者体验更完整。5.4 新糖果补充后立刻形成三连现象下落补新糖果后新糖果直接和旁边的凑成三连玩家没操作就自动消除。原因randomCandy()纯随机没有做「补糖果时避免立即匹配」的检查。解决补糖果时复用初始化阶段的createsImmediateMatch逻辑生成时检查左右和上下避免刚补上就消。这个检查会增加一点计算量但能避免玩家困惑。5.5 边界坐标计算错误导致数组越界现象在棋盘边缘交换或匹配时崩溃日志显示ArrayIndexOutOfBoundsException。原因取邻居时没有做inBounds判断或者row * COLS col的换算写反。解决所有取邻居的操作统一封装成getNeighbor(row, col, direction)内部先判断边界再取值。换算公式固定为row * COLS col不要写成col * ROWS row这两个在方阵下结果一样但一旦行列不等就全错。6. 从能跑到好用三消游戏的性能优化与手感调校把逻辑跑通只是第一步真正决定玩家留不留下来的是手感和性能。我在实际调优里总结了几条经验都是血泪换来的。先说渲染。三消棋盘如果用GridView加BaseAdapter格子一多就会掉帧。常见做法是换成RecyclerView配合DiffUtil做局部刷新。但更彻底的做法是用自定义View一次性绘制整个棋盘把每个糖果当成一个绘制单元用Canvas批量画。这样滚动和动画都更顺代价是点击命中判定要自己算。// 自定义 View 里根据触摸坐标反推格子行列 Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() MotionEvent.ACTION_DOWN) { int col (int) (event.getX() / cellWidth); int row (int) (event.getY() / cellHeight); if (board.inBounds(row, col)) { selectedRow row; selectedCol col; invalidate(); } } return true; }cellWidth和cellHeight是每个格子的像素尺寸用getWidth() / COLS和getHeight() / ROWS算出来。这个反推逻辑必须和绘制逻辑用同一套尺寸参数否则点击位置会偏移。我一般把尺寸计算放在onSizeChanged里只算一次避免每帧重复计算。再说手感。交换动画的时长很关键太短玩家看不清太长显得拖沓。我试下来150 到 200 毫秒是最舒服的区间。消除动画可以稍长一点250 毫秒左右配合一个缩放加淡出的效果。下落动画用加速曲线让糖果有「掉下来」的物理感而不是匀速平移。动画类型建议时长插值器说明交换150-200msAccelerateDecelerate来回对称手感自然消除200-250msDecelerate缩放淡出强调反馈下落200msAccelerate加速下落有重力感连锁提示300msOvershoot分数弹出带一点回弹最后说一个验证方法把棋盘尺寸临时改成 4×4糖果种类改成 3 种。这样连锁会非常频繁能在几分钟内把各种边界情况跑出来。等 4×4 稳定了再改回 8×8基本不会再有逻辑崩溃。这个技巧帮我省了无数调试时间比盯着 8×8 棋盘等偶然出现的 bug 高效得多。我自己做三消最大的教训是别急着写界面先把GameBoard这个纯逻辑类用单元测试跑通。匹配、交换、下落、连锁这四个方法每个都写几个测试用例确认无误再接 UI。我早期图快逻辑和界面一起写结果一个边界 bug 查了一整天最后发现是runStart更新顺序写反了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?