资讯动态

Android开心消消乐代码实例:三消棋盘算法与动画实现

发布时间:2026/10/14 4:14:53 来源:尧图企业网站定制
简介这是一份面向Android初学者的消消乐游戏开发实例讲解PDF适合想从零实现小游戏、理解Android布局与事件机制的开发者。文档围绕开心消消乐代码展开从基本概念、XML布局设计、按钮点击事件、UI设计到游戏逻辑均有拆解重点说明8×8按钮数组的代码创建方式、TableLayout与LinearLayout混合布局、getStyle()随机样式、点击交换与消去判断算法并提到mark二维数组的更新策略。资源为单个PDF文件压缩包约378KB内容简洁、可快速通读。该资源已有3221人学习适合作为课堂项目、课程设计或业余练手的入门参考能帮助读者避开环境搭建与界面设计中的常见坑。1. 这个消消乐代码实例到底要解决什么问题从棋盘模型到动画的一整条链路接到过某工作室一个三消Demo需求要在 Android 上做一款能玩的开心消消乐一周交原型。我当时以为难点在消除算法真正写起来才发现最花时间的反而是棋盘数据模型和动画时序。这也是这篇 Android 开心消消乐代码实例详解想讲清楚的事——从二维棋盘数组到匹配检测、下落补位、手势交换完整链路怎么落地。标题里的“代码实例”不是一个玩具工程而是三消游戏最核心的可运行循环玩家交换两个相邻方块系统判断能否消除消除后上方方块下落补位补位再触发连锁。它解决的问题很具体——棋盘怎么存、三连怎么判、非法交换怎么回退、动画期间怎么锁住输入。适合正在做休闲游戏方向、或者想在 Android 上把三消机制彻底吃透的开发者。我按自己做过的最小可玩版本来讲每段代码都是能直接抄进工程再改参数的。先从棋盘数据模型开始因为这一层一旦选错后面所有算法都会跟着别扭。2. 棋盘数据模型与初始化用二维数组承载 8×8 彩色方块三消游戏的视觉再花哨底层也只是一张二维表。这张表就是整个游戏逻辑的“唯一事实来源”匹配检测读它下落补位改它渲染层每一帧也从它取数。先把这张表定义清楚后面所有功能都只是围绕这张表的操作。2.1 数据模型用类型ID而不是颜色值来存棋盘我见过有人直接用颜色 Int 存进二维数组看着省事但一旦要做图标、特效、特殊方块颜色值根本不够用。常见做法是定义方块类型枚举棋盘数组里只存类型 ID渲染层再去映射颜色和图标。enum class BlockType(val colorRes: Int) { RED(R.color.block_red), GREEN(R.color.block_green), BLUE(R.color.block_blue), YELLOW(R.color.block_yellow), PURPLE(R.color.block_purple) } class GameBoard(private val rows: Int, private val cols: Int) { val grid Array(rows) { IntArray(cols) } val rowCount: Int get() rows val colCount: Int get() cols companion object { const val EMPTY 0 } }grid 里存的是类型 ID 而不是 BlockType 枚举本身也不是颜色值。原因有三层枚举数组在序列化和调试打印上不如 IntArray 直观类型 ID 到颜色的映射放在渲染层逻辑层完全不关心颜色EMPTY 0 作为空格标记在下落补位前的那一帧渲染层看到 0 就直接跳过绘制不会把空格当成某种颜色画出来。数组下标我统一用 row 在前、col 在后对应绘制时的行优先遍历。后面所有检测、下落代码都遵守这个约定避免写一半出现 (x, y) 和 (row, col) 混用导致的行列颠倒。这个约定看起来小实际是三消代码最隐蔽的翻车源头之一。2.2 初始棋盘生成随机填充时的三连预判棋盘生成最忌讳的事就是 App 一启动棋面上自带三连玩家还没操作就自动触发消除。所以生成每一格时都要检查如果这个位置放当前类型会不会和已经生成的格子构成连续三个。fun generateInitialBoard(random: Random Random()) { val typeCount BlockType.values().size for (row in 0 until rows) { for (col in 0 until cols) { var type random.nextInt(typeCount) 1 while (wouldFormMatch(row, col, type)) { type random.nextInt(typeCount) 1 } grid[row][col] type } } } private fun wouldFormMatch(row: Int, col: Int, type: Int): Boolean { // 检查左边连续两个是否同类型 if (col 2 grid[row][col - 1] type grid[row][col - 2] type ) { return true } // 检查上方连续两个是否同类型 if (row 2 grid[row - 1][col] type grid[row - 2][col] type ) { return true } return false }生成顺序是从上到下、从左到右。处理当前格子时只有它左侧两格和上方两格是已经确定的右边和下边还没生成不需要检查。所以 wouldFormMatch 只看两个方向左方连续两个、上方连续两个。只要这两个方向不构成三连整张棋盘就不会自带匹配。这个方案对 8×8、6 色棋盘来说重随次数非常有限几乎不会出现死循环。如果棋盘缩小到 5×5 且颜色只有 4 种重随概率会上升那种场景我建议改成“限制每个类型最大数量”的洗牌生成但普通配置下没必要。另有一类做法是先生成完全随机棋盘再扫描并修正三连逻辑上等价只是多一次全盘遍历代码反而更绕。注意下落后顶部补位产生的新三连不是 Bug那是连锁机制的正常触发。需要避开的是“开局即三连”两者的差别在于触发时机一个在玩家操作前一个在玩家操作后。3. 匹配检测与消除算法横纵扫描、交换回退与连锁下落棋盘模型建好之后接下来就是三消的核心判断逻辑怎么找出所有可以消除的格子。这一步写对了交换、回退、下落、连锁都只是围绕它的外围操作。3.1 横纵扫描检测连续同色段与十字交叉去重匹配检测最常见的写法是对每个格子检查“右侧两格”或“下方两格”。这种写法在四连、五连时容易漏而且重叠区域很难去重。我更推荐用连续段扫描按行找连续同色段段长达到 3 就整段标记再按列做同样一遍。fun detectMatches(): MapInt, ListPairInt, Int { val matches mutableMapOfInt, MutableListPairInt, Int() fun mark(row: Int, col: Int) { val key row * colCount col matches.getOrPut(key) { mutableListOf() }.add(row to col) } // 横向扫描 for (row in 0 until rowCount) { var col 0 while (col colCount) { val type grid[row][col] if (type EMPTY) { col; continue } var end col while (end 1 colCount grid[row][end 1] type) end if (end - col 1 3) { for (c in col..end) mark(row, c) } col end 1 } } // 纵向扫描 for (col in 0 until colCount) { var row 0 while (row rowCount) { val type grid[row][col] if (type EMPTY) { row; continue } var end row while (end 1 rowCount grid[end 1][col] type) end if (end - row 1 3) { for (r in row..end) mark(r, col) } row end 1 } } return matches }这里有个新手很难一次写对的细节横向扫描和纵向扫描的结果用同一个 Map 按“行 * colCount 列”做 key 合并。因为十字交叉的格子会同时被横、竖两段包含如果用 List 直接 add同一个格子会出现两次后面消除时动画播两遍、计分翻倍视觉和数值一起崩。返回的 Map 以位置为 keyvalue 是坐标列表。实际使用场景通常只需要 key 集合value 里保留坐标是为了排查问题方便真机调试时把 Map 打印出来能看到每个待消除格子是哪些坐标非常直观。检测阈值 3 可以提成常量 MIN_MATCH 3后面做四连特型方块时判断段长是否大于等于 4 就靠这个段扫描的自然输出。3.2 交换与非法回退给玩家的误触一颗后悔药三消里玩家可以随意拖动相邻方块但不是每次交换都能形成匹配。游戏必须保证非法交换自动回退原样。常见做法是先交换再检测检测为空就换回来这也是我推荐的写法逻辑最短不容易留状态残留。fun trySwap( firstRow: Int, firstCol: Int, secondRow: Int, secondCol: Int ): Boolean { // 只允许上下左右相邻斜对角不算 if (abs(firstRow - secondRow) abs(firstCol - secondCol) ! 1) { return false } swapCells(firstRow to firstCol, secondRow to secondCol) if (detectMatches().isEmpty()) { swapCells(firstRow to firstCol, secondRow to secondCol) return false } return true } private fun swapCells(a: PairInt, Int, b: PairInt, Int) { val tmp grid[a.first][a.second] grid[a.first][a.second] grid[b.first][b.second] grid[b.first][b.second] tmp }相邻判定用曼哈顿距离实现横向或纵向距离之和为 1 才算相邻斜对角距离之和是 2直接排除。先交换再检测的好处是检测函数只处理“当前棋盘状态”不需要为“假设交换后”单独写一套预判逻辑。8×8 的棋盘做一次全盘检测不到 1 毫秒先换再查的性能代价可以忽略。非法回退在逻辑层就是把两个格子再换一次。但要注意UI 层不能只做逻辑回退动画层也要把交换动画反向播放否则会出现“格子已经回来但玩家眼睛看到的还是错位”的割裂感。我一般把逻辑回退和动画回退绑在同一个入口动画结束再恢复输入锁。3.3 下落补位与连锁消除while 循环把棋盘清到稳定消除之后棋盘会留下空格。下落的经典实现是逐列从下往上压缩非空格子依次往下落空格浮到顶部再由新随机块填充。这里有个小技巧压缩过程是原地完成的不需要额外开队列。fun applyGravity(random: Random Random()) { for (col in 0 until colCount) { var writeRow rowCount - 1 for (row in rowCount - 1 downTo 0) { if (grid[row][col] ! EMPTY) { if (writeRow ! row) { grid[writeRow][col] grid[row][col] grid[row][col] EMPTY } writeRow-- } } // 顶部空位补新随机块 for (row in writeRow downTo 0) { grid[row][col] random.nextInt(BlockType.values().size) 1 } } }writeRow 指针从列底开始遇到非空格子就搬到 writeRow 位置并上移指针。扫完一整列后writeRow 以上的格子全是空位从 writeRow 到 0 填充新随机块。这个写法的好处是一次遍历完成移动和补位时间复杂度 O(rows)而且不会出现格子移动顺序错乱的问题。下落之后新块可能又形成三连这就是连锁。连锁不能靠递归递归深度会随着连续消除变得不可控。正确做法是 while 循环检测、消除、下落、再检测直到棋盘稳定。fun resolveAllMatches(): Int { var totalScore 0 var combo 1 while (true) { val matches detectMatches() if (matches.isEmpty()) break // 每格基础分 10连锁倍率逐轮递增 totalScore matches.size * 10 * combo for (key in matches.keys) { val row key / colCount val col key % colCount grid[row][col] EMPTY } applyGravity() combo } return totalScore }matches.size 统计的是格子数而不是组数因为检测阶段已经用 Map 去重。计分公式“格子数 × 10 × 连锁倍率”是常见做法第一轮消除是 1 倍第二轮连锁变成 2 倍。这个公式可以直接换成分数配置写在关卡配置文件里。注意resolveAllMatches 只能在逻辑层调用一次不要在动画回调里连续调用多次。逻辑层负责把棋盘算到最终稳定态UI 层只负责播放下落动画两边各干各的才不会有“动画播一半棋盘已经变了”的错位。4. 渲染、动画与手势自定义 View 把逻辑变成能玩的样子逻辑层跑通之后三消还是一堆看不见的数字。要把这些数字变成屏幕上能点的方块需要自定义 View 绘制棋盘再用属性动画表现交换和下落。这一章是整个实例里最容易“看起来玄学”的部分其实拆开之后都是很机械的代码。4.1 自定义 View绘制棋盘的最小骨架棋盘是 8×8 的规则网格每帧最多画 64 个圆角矩形用单个自定义 View 承担绘制是最直接的方式。RecyclerView 或 ConstraintLayout 在这个场景里既重又难做动画非要引入反而把简单问题复杂化。class GameView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val board GameBoard(8, 8) private val paint Paint(Paint.ANTI_ALIAS_FLAG) private val blockColors mapOf( 1 to Color.parseColor(#F44336), 2 to Color.parseColor(#4CAF50), 3 to Color.parseColor(#2196F3), 4 to Color.parseColor(#FFEB3B), 5 to Color.parseColor(#9C27B0), 6 to Color.parseColor(#FF9800) ) override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val cellSize calculateCellSize() for (row in 0 until board.rowCount) { for (col in 0 until board.colCount) { val type board.grid[row][col] if (type EMPTY) continue val left paddingStart col * cellSize val top paddingTop row * cellSize paint.color blockColors[type] ?: Color.GRAY canvas.drawRoundRect( left 4f, top 4f, left cellSize - 4f, top cellSize - 4f, 12f, 12f, paint ) } } } private fun calculateCellSize(): Float { val usableWidth width - paddingStart - paddingEnd val usableHeight height - paddingTop - paddingBottom return min(usableWidth, usableHeight) / board.colCount.toFloat() } }绘制循环是经典的双层遍历row 和 col 分别对应网格的行列。drawRoundRect 里的 4f 缩进让方块之间留出间隙视觉上更像消消乐而不是马赛克12f 是圆角半径数值可以根据图片素材调整。calculateCellSize 取宽高较小值保证棋盘不变形剩余空间留给 padding。这里有个细节用 paddingStart 而不是 paddingLeft是为了在布局方向为 RTL 时依然正确。如果你的 Demo 不考虑 RTL两者效果一样但统一用 paddingStart 是更稳的习惯。4.2 交换与下落动画逻辑位置和渲染位置必须分开三消动画翻车率最高的原因只有一个在动画播放过程中修改了 grid。动画没播完逻辑数据已经变了下一帧绘制读到的和动画想表现的根本不是同一个状态。解决办法是把“逻辑位置”和“渲染位置”分开动画期间只改渲染偏移。private val offsetMap HashMapInt, FloatArray() private var isAnimating false fun animateSwap( r1: Int, c1: Int, r2: Int, c2: Int, onFinished: () - Unit ) { if (isAnimating) return isAnimating true val animator ValueAnimator.ofFloat(0f, 1f).apply { duration 160 interpolator DecelerateInterpolator() addUpdateListener { valueAnimator - val t valueAnimator.animatedValue as Float offsetMap[key(r1, c1)] floatArrayOf( c1 (c2 - c1) * t, r1 (r2 - r1) * t ) offsetMap[key(r2, c2)] floatArrayOf( c2 (c1 - c2) * t, r2 (r1 - r2) * t ) invalidate() } addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { offsetMap.clear() isAnimating false onFinished() } }) } animator.start() }offsetMap 以格子位置为 key存的是该格子当前的渲染坐标。动画从 0 到 1 插值两个格子互相交换坐标每帧 invalidate 触发重绘。注意 grid 在整个动画期间保持原样真正的数据交换放在 onFinished 回调里执行。这样动画无论播到哪一帧onDraw 都只画一张自洽的画面不会出现半路状态。onDraw 里取绘制位置时优先看 offsetMap 有没有偏移有就用偏移坐标没有就回落到 grid 坐标。这个读取逻辑很简短但它是“逻辑与渲染分离”的核心val offset offsetMap[key(row, col)] val drawRow offset?.get(1) ?: row.toFloat() val drawCol offset?.get(0) ?: col.toFloat()下落动画和交换动画同理只是 offsetMap 的 key 用下落后的目标位置value 记录从原行到目标行的插值。动画结束后同样清空偏移并回调。动画时长我默认交换 160ms、下落 220ms这个数值区间是多年手感沉淀下来的低于 120ms 会像瞬移玩家看不清高于 250ms 会拖慢节奏连击快感丢失。4.3 手势判定滑动方向、最小距离与格子定位触摸逻辑要解决两件事玩家手指按在哪个格子上、滑动的方向是什么。前者需要把像素坐标换算成格子索引后者需要处理斜滑和误触。常见做法是 ACTION_DOWN 记录起点格子ACTION_UP 时统一判定方向并触发交换。private var downRow -1 private var downCol -1 private var downX 0f private var downY 0f override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN - { val cell locateCell(event.x, event.y) if (cell ! null) { downRow cell.first downCol cell.second downX event.x downY event.y } return true } MotionEvent.ACTION_UP - { val upCell locateCell(event.x, event.y) ?: return true if (downRow -1) return true val direction resolveDirection(event.x - downX, event.y - downY) val targetRow downRow direction.dy val targetCol downCol direction.dx if (targetRow in 0 until board.rowCount targetCol in 0 until board.colCount ) { onUserSwap(downRow, downCol, targetRow, targetCol) } downRow -1 downCol -1 return true } } return super.onTouchEvent(event) } private fun locateCell(x: Float, y: Float): PairInt, Int? { val cellSize calculateCellSize() val col ((x - paddingStart) / cellSize).toInt() val row ((y - paddingTop) / cellSize).toInt() if (row !in 0 until board.rowCount) return null if (col !in 0 until board.colCount) return null return row to col } private fun resolveDirection(dx: Float, dy: Float): Direction { val minDistance resources.displayMetrics.density * 24f val threshold resources.displayMetrics.density * 16f if (abs(dx) minDistance abs(dy) minDistance) { return Direction.NONE } return when { abs(dx) - abs(dy) threshold - if (dx 0) Direction.RIGHT else Direction.LEFT abs(dy) - abs(dx) threshold - if (dy 0) Direction.DOWN else Direction.UP else - Direction.NONE } } enum class Direction(val dx: Int, val dy: Int) { LEFT(-1, 0), RIGHT(1, 0), UP(0, -1), DOWN(0, 1), NONE(0, 0) }resolveDirection 的判定逻辑是重点。只用“|dx| |dy|”判方向在斜向滑动时非常不稳定45 度附近抖动一下就会误判。我加的阈值是 16dp 的差量横向位移必须比纵向位移大出 16dp 才算横移反之亦然斜向滑动直接返回 NONE 忽略。最短距离 24dp 则是为了过滤“点了一下”的误触小于这个距离不触发交换。locateCell 里把像素坐标换算成格子索引时必须先减 padding 再除 cellSize并且做边界判断。漏掉 padding 会导致贴边触摸时索引值偏大漏掉边界判断则可能算出负数或超过 8 的下标直接触发数组越界崩溃。5. 消消乐避坑实录5 次常见的翻车与修复三消项目里真正让人熬夜的从来不是算法本身而是那些逻辑边界和时序问题。我把自己做这个实例踩过的坑整理成 5 条按“现象 → 原因 → 解决”写清楚每一行都是血泪经验。5.1 数据与算法层的坑初始三连、重复计分与断掉的连锁坑一App 启动后棋盘自带三连玩家没操作就自动消除了。现象是加载完棋局后分数自己跳了一下有时甚至触发多次连锁掉分。原因就是初始填充时只做了纯随机没有预判“当前格子是否会和已生成格子构成三连”。解决回到 2.2 的 wouldFormMatch生成时每格重随直到不构成三连。注意这里只需要检查“左”和“上”两个方向因为生成顺序决定了另外两个方向的格子还不存在。如果你用的是完全随机后全盘修正的方案效果一样但代码路径更长我推荐边生成边预判。坑二十字交叉的格子被重复消除和计分。现象是某个方块同时处于横竖两个三连的交点消除后这个格子弹了两遍动画分数也比预期高。原因检测结果如果存 List 而不是按位置去重横向扫描加一次、纵向扫描又加一次同一个格子在 matches 里出现两遍。解决用“行 * colCount 列”作为 key 存 Map横竖扫描共享同一个 key自动合并重复项。这个坑在只看单行或单列检测时不会暴露一写完整棋盘必现。坑三棋盘补位后明明又有三连但游戏停住不消。现象是第一次消除后下落结束界面上出现了新的三连但再也没有后续动作。原因只调用了一次 detectMatches没有在下落后重新检测。逻辑上随机补位的新块完全可能形成新匹配这是连锁机制的一部分不是设计缺陷。解决用 3.3 的 resolveAllMatcheswhile 循环一直执行到棋盘稳定。顺带一提连锁不要用递归实现连续消除十几轮时递归栈会压出隐患。5.2 动画与手势层的坑动画竞态、斜滑误判与定位偏差坑四动画播放期间连点棋盘错位甚至出现重叠。现象是玩家快速连续滑动方块还没落稳又开始下一次交换画面里两个格子叠在一起之后棋盘的逻辑状态和视觉对不上。原因动画回调里直接修改 grid或者根本没加输入锁导致动画期间又发起了一次新的交换判定。解决加 isAnimating 标志位动画进行时忽略所有新的交换请求。如果想让操作更跟手可以把请求存进队列动画结束后自动取队首继续执行而不是直接丢弃。我实际项目里一般用队列方案手感明显比直接丢弃更流畅。坑五斜向滑动被误判成横向或纵向交换。现象是玩家快速斜上滑想换右上角的格子结果系统换的是正上方或正右方的邻居。原因只用 |dx| |dy| 判断方向斜 45 度时两个值接近判定结果随手指抖动剧烈变化。解决用 4.3 的差量阈值横向位移必须比纵向位移大出 16dp 才算横移斜向直接忽略。另有一个相关小坑格子定位忘了减 padding触摸点靠边时换错行这是纯数学问题把 locateCell 里的 padding 减掉就不会出现。6. 进阶验证与扩展无头测试、特型方块与参数建议逻辑层和渲染层分离之后一个直接的收益就是可以脱离 UI 验证算法正确性。三消的大部分 bug 都在检测和下落逻辑里这些逻辑不依赖 Android 组件纯 JVM 就能跑。我后来养成的习惯是先写逻辑层测试再上真机调动画。class GameBoardTest { Test fun gravity fills empty cells from top() { val board GameBoard(3, 3) board.grid[2][0] 1 board.applyGravity(Random(0)) assertEquals(1, board.grid[2][0]) assertNotEquals(0, board.grid[0][0]) assertNotEquals(0, board.grid[1][0]) } Test fun detect matches removes duplicate cross cell() { val board GameBoard(3, 3) board.grid[0][0] 1 board.grid[0][1] 1 board.grid[0][2] 1 board.grid[1][0] 1 board.grid[2][0] 1 val matches board.detectMatches() assertEquals(5, matches.size) } }这类无头测试的价值在于每次改检测算法或者重力逻辑跑一遍测试就能立刻知道有没有破坏已有行为不用手动开模拟器摆棋盘。第二个测试验证的就是 5.1 提到的十字交叉去重横三连加竖三连一共 5 个不同格子如果去重失败matches.size 会是 6。扩展方向按性价比排序第一优先级是特型方块四连生成条纹块、五连生成彩球这能让游戏从“数学演示”变成“有爽点”。做法是在 detectMatches 里额外记录连续段长度长度等于 4 或 5 时在中间格生成特殊类型 ID再在消除剧本里加入特型方块的特效逻辑。第二优先级是关卡配置数据化目标分数、步数限制、棋盘尺寸全部从配置读取方便策划调参数。参数参考表按上面代码里的默认值整理参数建议值说明棋盘尺寸8×8手机竖屏最稳横屏可改 9×7方块类型数6少于 5 容易频繁连锁多于 7 匹配变难交换动画时长160ms低于 120ms 会像瞬移下落动画时长220ms配合 DecelerateInterpolator 手感更好最小滑动距离24dp低于此值视为点击方向判定差量16dp横竖判定的迟滞区间做完这些再回头看标题里说的“代码实例详解”其实就是一条线数据模型 → 匹配算法 → 交换与回退 → 重力与连锁 → 渲染与手势。我第一次做这类项目时顺序写反了先画 UI 再补逻辑结果动画和算法互相打架翻车翻到怀疑人生。后来老老实实把 GameBoard 写成不依赖 Android 的纯逻辑类跑通所有边界测试再做动画整条链路突然就清楚了。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑