ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android开心消消乐代码实例:从零实现自定义View与三消逻辑

Android开心消消乐代码实例:从零实现自定义View与三消逻辑 简介这份PDF文档面向具备一定Java与Android基础的开发者围绕开心消消乐这一经典三消玩法系统讲解从零实现一个可运行Demo的完整思路。内容涵盖基本概念、布局设计、按钮点击事件、UI设计与游戏逻辑等模块重点剖析8x8按钮数组的代码化布局、TableLayout与LinearLayout的配合使用、按钮三种状态的selector定义以及用两个滚动变量记录点击、判断相邻交换、通过mark二维数组标记可消去方块并处理下落更新的核心算法还涉及每轮消去后地图是否仍有解的判断思路。资源包内共1个PDF文件约378KB篇幅紧凑、示例代码详实适合作为Android入门练手项目或三消类游戏逻辑的参考范例。目前已有3220人学习对想理解消消乐消除判定与布局实现细节的读者具有较高参考价值。1. 从零拆解一个 Android 开心消消乐代码实例到底能跑出什么很多人第一次搜「Android 开心消消乐代码实例」心里想的其实不是要做一个商业级三消游戏而是想找一个能跑起来、能看懂、能改的完整 Demo把 Android 自定义 View、触摸事件、动画、状态管理这几块串起来。三消游戏是个特别合适的练手项目规则简单到一句话说清但实现起来会逼着你处理网格坐标、手势判定、消除连锁、下落补位、动画时序这些真实工程问题。它不像记事本那样只练 UI也不像视频播放器那样重度依赖第三方库核心逻辑全在你自己的代码里改一行就能看到效果这种反馈感对建立 Android 图形编程的直觉非常关键。这篇内容面向的是已经装好 Android Studio、能新建项目、但对「游戏循环怎么组织」「自定义 View 怎么画格子」「触摸坐标怎么映射到行列」还没底的人。我会按一个可复现的最小实现来讲先定数据模型再画棋盘再接手势再做消除与下落最后补动画和关卡。中间会给出关键代码块和参数说明也会把我在真机上踩过的坑摊开讲。你照着走一遍能拿到一个 8x8 棋盘、支持滑动交换、三连消除、连锁下落、分数统计的可运行实例再往上加特效和关卡就是体力活了。2. 数据模型与棋盘渲染先把 8x8 网格画对2.1 为什么用二维数组而不是 List三消的核心状态就是「每个格子当前是什么类型的方块」。最直接的表达是int[][] board行和列都是固定 8取值 0 到 N-1 表示方块种类-1 表示空格。用二维数组的好处是坐标访问是 O(1)行列互换、上下左右邻居判断写起来直观调试时打印出来就是一个矩阵一眼能看出哪里没消干净。用ListListInteger或者一维数组加索引换算也能做但前者有装箱开销、后者容易在row * COLS col上写错新手阶段没必要给自己加难度。方块种类我一般设 5 种颜色区分度高三消概率也合适。种类太少3 种会频繁自动三连棋盘一直在自己消太多7 种以上则玩家很难凑出三连体验发闷。5 种是多数三消的默认值这个参数在BlockType里定义后面做关卡难度时可以按关卡调整种类数。public class GameBoard { public static final int ROWS 8; public static final int COLS 8; public static final int TYPE_COUNT 5; // 方块种类数5 种手感最稳 public static final int EMPTY -1; private int[][] board new int[ROWS][COLS]; // 初始化随机填充但要保证开局没有现成的三连 public void init() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { int type; do { type (int) (Math.random() * TYPE_COUNT); } while (wouldMatchAt(r, c, type)); // 避免开局自带三连 board[r][c] type; } } } // 判断在 (r,c) 放 type 是否会立刻形成横向或纵向三连 private boolean wouldMatchAt(int r, int c, int type) { // 检查左边两个 if (c 2 board[r][c - 1] type board[r][c - 2] type) return true; // 检查上边两个 if (r 2 board[r - 1][c] type board[r - 2][c] type) return true; return false; } public int get(int r, int c) { return board[r][c]; } public void set(int r, int c, int type) { board[r][c] type; } public boolean inBounds(int r, int c) { return r 0 r ROWS c 0 c COLS; } }这段代码里init()的 do-while 是关键如果只是纯随机填充开局很可能直接出现三连玩家一进来就看到已经消好的局面逻辑上很别扭。wouldMatchAt只检查左边两个和上边两个因为填充是从左上往右下走的右边和下边还没填不需要检查。这个「只往回看」的技巧在生成类算法里很常见能省掉一半判断。2.2 自定义 View 的测量与绘制棋盘要用自定义 View 画因为每个方块的位置、大小、动画状态都要自己控制。继承View重写onMeasure和onDraw。onMeasure里把宽高取成正方形边长取min(宽, 高)这样棋盘不会因为屏幕比例被拉变形。onDraw里先算格子边长cellSize boardSize / COLS再双重循环画每个方块。public class BoardView extends View { private GameBoard game; private Paint paint new Paint(Paint.ANTI_ALIAS_FLAG); private float cellSize; private float originX, originY; public BoardView(Context context, GameBoard game) { super(context); this.game game; paint.setStyle(Paint.Style.FILL); } Override protected void onMeasure(int widthSpec, int heightSpec) { int w MeasureSpec.getSize(widthSpec); int h MeasureSpec.getSize(heightSpec); int size Math.min(w, h); setMeasuredDimension(size, size); // 强制正方形 } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int size getWidth(); cellSize size / (float) GameBoard.COLS; originX 0; originY 0; for (int r 0; r GameBoard.ROWS; r) { for (int c 0; c GameBoard.COLS; c) { int type game.get(r, c); if (type GameBoard.EMPTY) continue; // 空格不画 float left originX c * cellSize; float top originY r * cellSize; paint.setColor(COLORS[type]); // 留 2px 间隙视觉上格子分明 canvas.drawRoundRect(left 2, top 2, left cellSize - 2, top cellSize - 2, 8, 8, paint); } } } private static final int[] COLORS { 0xFFFF5252, 0xFF448AFF, 0xFFFFEB3B, 0xFF69F0AE, 0xFFE040FB }; }COLORS数组和TYPE_COUNT必须对齐5 种类型对应 5 个颜色改类型数时两个地方都要改这是最容易漏的。drawRoundRect的圆角半径 8 和间隙 2 是视觉参数真机上如果觉得格子太挤可以调大间隙但别超过cellSize的十分之一否则方块看起来会散。onDraw里不要做任何对象分配Paint和颜色数组都提到成员变量或静态常量否则每帧 GC 会让滑动明显掉帧这是自定义 View 的血泪经验。3. 触摸交换与三消判定手势怎么映射到行列3.1 从 ACTION_DOWN 到行列坐标触摸事件的核心是把屏幕坐标(x, y)换算成棋盘行列。公式是col (int)((x - originX) / cellSize)row (int)((y - originY) / cellSize)换算完必须做边界检查因为手指可能点在棋盘外面。三消的交互我一般用「按下选中、抬起时判断滑动方向」的方式ACTION_DOWN记录起始行列ACTION_UP时如果和起始格相邻上下左右差 1就交换这两个格子然后跑一次消除判定如果没滑动或者滑到不相邻的格子就当作取消选中。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: startRow toRow(event.getY()); startCol toCol(event.getX()); return inBoard(startRow, startCol); case MotionEvent.ACTION_UP: int endRow toRow(event.getY()); int endCol toCol(event.getX()); if (!inBoard(endRow, endCol)) return true; // 只允许相邻交换 if (Math.abs(endRow - startRow) Math.abs(endCol - startCol) 1) { trySwap(startRow, startCol, endRow, endCol); } return true; } return super.onTouchEvent(event); } private int toRow(float y) { return (int) ((y - originY) / cellSize); } private int toCol(float x) { return (int) ((x - originX) / cellSize); } private boolean inBoard(int r, int c) { return r 0 r GameBoard.ROWS c 0 c GameBoard.COLS; }Math.abs(endRow - startRow) Math.abs(endCol - startCol) 1这个判断是曼哈顿距离等于 1正好覆盖上下左右四个方向斜着滑不算。这里有个常见翻车点cellSize和originX/originY是在onDraw里算的如果触摸发生在第一次绘制之前这些值还是 0换算出来的行列全是 0。稳妥做法是在onSizeChanged里也算一遍或者触摸时判断cellSize 0直接返回。3.2 交换合法性先试后撤三消的规则是「交换后必须能形成至少一个三连否则交换无效并撤回」。实现上就是先交换跑一次findMatches()如果结果为空就再换回来。这个「先试后撤」的模式比先模拟再决定要简单得多代码也好读。private void trySwap(int r1, int c1, int r2, int c2) { swap(r1, c1, r2, c2); Listint[] matches findMatches(); if (matches.isEmpty()) { swap(r1, c1, r2, c2); // 撤回 // 可选播放一个抖动动画提示无效 } else { // 有效交换进入消除流程 resolveMatches(matches); } invalidate(); } private void swap(int r1, int c1, int r2, int c2) { int tmp game.get(r1, c1); game.set(r1, c1, game.get(r2, c2)); game.set(r2, c2, tmp); }findMatches()返回所有需要消除的格子坐标列表。扫描方式是横向扫一遍、纵向扫一遍每行/每列找连续相同且长度大于等于 3 的段。注意要处理「L 形」和「T 形」交叉横扫和纵扫的结果可能有重叠格子用Set去重否则同一个格子会被消两次分数算重、下落也会错乱。private Listint[] findMatches() { SetString seen new HashSet(); Listint[] result new ArrayList(); // 横向扫描 for (int r 0; r GameBoard.ROWS; r) { int runStart 0; for (int c 1; c GameBoard.COLS; c) { if (c GameBoard.COLS game.get(r, c) ! GameBoard.EMPTY game.get(r, c) game.get(r, runStart)) { continue; } if (c - runStart 3 game.get(r, runStart) ! GameBoard.EMPTY) { for (int k runStart; k c; k) { String key r , k; if (seen.add(key)) result.add(new int[]{r, k}); } } runStart c; } } // 纵向扫描逻辑相同把 r 和 c 对调即可 // ... 省略实际代码里补全 return result; }横向扫描用runStart标记当前连续段的起点当遇到不同种类或到达行尾时结算这一段。c GameBoard.COLS这个上界多取 1是为了让最后一段也能在循环内结算不用在循环外再写一遍。seen.add(key)返回 true 才加入结果天然去重。纵向扫描把行列对调逻辑完全一样建议抽成一个scanLine方法传方向参数避免复制粘贴时改错下标。4. 消除、下落与连锁把一次点击跑成完整回合4.1 消除后下落补位消除只是把匹配格子的值设成EMPTY真正让棋盘「活」起来的是下落。下落逻辑是逐列处理从最底行往上扫维护一个「写指针」writeRow遇到非空格就写到writeRow位置然后writeRow上移。扫完后writeRow以上的格子全部填新随机方块。这个算法是 O(ROWS*COLS)比冒泡式交换高效得多也不会漏格。private void collapse() { for (int c 0; c GameBoard.COLS; c) { int writeRow GameBoard.ROWS - 1; for (int r GameBoard.ROWS - 1; r 0; r--) { if (game.get(r, c) ! GameBoard.EMPTY) { if (r ! writeRow) { game.set(writeRow, c, game.get(r, c)); game.set(r, c, GameBoard.EMPTY); } writeRow--; } } // writeRow 及以上填新方块 for (int r writeRow; r 0; r--) { game.set(r, c, (int) (Math.random() * GameBoard.TYPE_COUNT)); } } }writeRow从底行开始每写一个非空格就减一循环结束后writeRow指向「最后一个被填充的位置上方」所以0到writeRow都是空的需要补新方块。这里有个细节补新方块时也要避免立刻形成三连吗严格说不需要因为下落本身就可能产生新的三连这正是连锁得分的来源。如果补位时强行避免三连连锁就永远不会发生游戏会变得很死板。4.2 连锁循环与分数结算一次有效交换后流程是「消除 → 下落 → 再判定 → 再消除」直到findMatches()返回空为止。这个循环就是连锁连锁次数越多分数倍率越高。我用一个combo计数器每次消除时分数加上matches.size() * 10 * combocombo 从 1 开始每轮加 1。private void resolveMatches(Listint[] matches) { int combo 1; while (!matches.isEmpty()) { // 消除 for (int[] pos : matches) { game.set(pos[0], pos[1], GameBoard.EMPTY); } score matches.size() * 10 * combo; combo; // 下落补位 collapse(); // 再判定 matches findMatches(); } // 循环结束后检查是否还有可行步没有则洗牌 if (!hasPossibleMove()) { shuffleBoard(); } }combo的倍率设计直接影响手感倍率太低连锁没成就感太高又会让分数膨胀失控。10 分基础分加每轮递增的 combo 倍率实测在 8x8 棋盘上单次操作得分在几十到几百之间节奏比较舒服。hasPossibleMove()是遍历所有相邻交换、模拟后看能否产生三连虽然复杂度是 O(ROWSCOLS4)但只在回合结束时跑一次性能完全够。如果无可行步就洗牌洗牌后同样要保证没有现成三连否则会立刻触发消除玩家会一脸懵。注意resolveMatches里的 while 循环必须有终止条件findMatches在棋盘全空或没有三连时返回空列表循环自然退出。如果补位逻辑写错导致某列一直有空格findMatches可能反复返回同一批格子循环就停不下来真机上表现为卡死。调试时在循环里打日志看matches.size()是否在收敛。5. 避坑与排查真机上最容易翻车的 5 个点5.1 触摸坐标偏移点左边消右边现象是手指点某个方块实际选中的是旁边一格越靠棋盘边缘偏移越明显。原因通常是originX/originY没算对或者cellSize用了getWidth()但绘制时用了getMeasuredWidth()两者在onMeasure强制正方形后可能不一致。解决是统一在onSizeChanged里算cellSize w / (float) COLSoriginX (w - cellSize * COLS) / 2f绘制和触摸都用这一份值别在两处各算一遍。5.2 消除后棋盘出现「悬空」方块现象是某列中间有空格但上面的方块没落下来。原因是collapse里writeRow的初始值或递减时机写错比如从ROWS开始而不是ROWS-1导致越界或漏掉底行。排查方法是在collapse前后打印每一列看非空方块是否都沉到底部。另一个可能是findMatches返回了重复坐标同一个格子被设了两次EMPTY第二次把已经落下来的方块又清掉了。5.3 连锁停不下来分数暴涨现象是一次交换后消除一直循环分数几秒内涨到几万。原因是补位时新方块生成逻辑有 bug比如每次都生成同一种类型导致新棋盘立刻又是满屏三连。检查Math.random() * TYPE_COUNT有没有被强转成固定值或者TYPE_COUNT被误设成 1。另外findMatches如果没排除EMPTY空格会被当成一种「类型」参与匹配连续空格也会被判定成三连这个坑很隐蔽。5.4 滑动卡顿帧率掉到 30 以下现象是棋盘大了或者方块多了之后滑动明显不跟手。原因基本是onDraw里分配对象比如每次new Paint()、new RectF()或者用了String拼接做 key。解决是把Paint、RectF提成成员变量复用findMatches里的SetString换成SetIntegerkey 用r * COLS c计算避免字符串分配。真机上用 Android Studio 的 Profiler 看内存分配如果onDraw每帧都有 allocation一定会卡。5.5 旋转屏幕后棋盘重置现象是手机横竖屏切换游戏进度全没了。原因是 Activity 重建GameBoard和BoardView都重新创建。解决有两个方向一是AndroidManifest里给 Activity 加android:configChangesorientation|screenSize让系统不重建简单但治标二是用onSaveInstanceState把board二维数组和score存下来重建时恢复这是正规做法。注意二维数组要展平成一维int[]再存Bundle 不支持直接放二维数组。6. 进阶技巧用状态机把动画和逻辑解耦做到上面那一步游戏逻辑已经完整但体验上还是「点一下方块瞬间消失又瞬间出现」缺少三消该有的爽感。真正让手感上一个台阶的是动画而动画最容易把逻辑搅乱——如果你在resolveMatches里直接Thread.sleep或者用一堆postDelayed嵌套代码很快就会变成没法维护的意大利面。我的习惯是引入一个简单的状态机把「等待输入」「交换动画中」「消除动画中」「下落动画中」几个状态分开逻辑层只负责改数据动画层只负责根据数据变化插值渲染。状态机用枚举加一个currentState字段就够不需要引第三方库。每次状态切换时记录动画起始时间和时长onDraw里根据(now - startTime) / duration算进度对位置或透明度做插值。比如消除动画让方块在 200ms 内缩放到 0下落动画让方块从旧位置在 150ms 内平移到新位置。动画期间屏蔽触摸动画结束后再切回「等待输入」并检查连锁。private enum State { IDLE, SWAPPING, CLEARING, FALLING } private State state State.IDLE; private long animStart; private static final long CLEAR_DURATION 200; private static final long FALL_DURATION 150; // 在 onDraw 里根据 state 决定怎么画 float progress Math.min(1f, (System.currentTimeMillis() - animStart) / (float) CLEAR_DURATION); if (state State.CLEARING) { // 消除中的方块按 progress 缩小 float scale 1f - progress; // 用 scale 调整绘制尺寸 }这里的关键是动画只影响绘制不改board数据。数据在动画开始前就已经更新好了比如消除时已经把格子设成EMPTY动画只是让视觉上有个过渡。这样即使动画被打断或者掉帧游戏逻辑也不会错。CLEAR_DURATION和FALL_DURATION这两个参数直接决定手感消除太快低于 150ms玩家看不清消了哪些太慢超过 300ms连锁时会等得着急。150 到 200ms 是我试下来最舒服的区间下落比消除略快一点整体节奏更紧凑。验证动画是否解耦干净有个简单办法把CLEAR_DURATION临时改成 2000ms如果游戏逻辑还能正常跑完连锁、只是慢说明解耦到位如果卡住或者数据错乱说明动画和逻辑还有耦合。这个「拉长时间」的调试技巧我经常用能把时序问题放大到肉眼可见。另外记得在onDetachedFromWindow里停掉所有动画相关的定时器否则 View 被销毁后回调还在跑轻则内存泄漏重则崩溃。我自己做这类小游戏时最大的教训是总想一步到位把特效做满结果逻辑还没稳就堆动画最后排查问题时根本分不清是数据错了还是渲染错了。后来改成先把纯逻辑跑通、用日志验证每一步的棋盘状态确认无误后再一层层加动画返工次数少了很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表