ARTICLE DETAIL

资讯详情

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

Android Studio实现2048小游戏:源码运行、算法解析与工程实践

Android Studio实现2048小游戏:源码运行、算法解析与工程实践 简介这是一份基于Android Studio实现的2048小游戏完整工程源码主要面向安卓初学者以及需要完成期末大作业、课程设计的在校生可作为高分开题基础或二次开发模板。压缩包为zip格式内含141个文件核心游戏逻辑集中于21个Java源文件50个XML文件负责界面布局30个WebP文件用于图片资源另有Gradle构建配置、依赖JAR和属性文件等整包仅394KB结构清晰、易于部署。该资源已有269人学习浏览。代码由作者手写并附带详细注释是导师认可的高分项目下载后简单配置即可运行便于逐行理解滑动监听、方块合并、分数累加与界面刷新等关键点对冲击期末大作业高分很有帮助。1. 拿到“基于 Android Studio 实现的 2048 小游戏源代码”之后你真正要做的是这三件事基于 Android Studio 实现的 2048 小游戏源代码几乎每个安卓方向的学生都见过网盘、GitHub、课程群里同名压缩包一大把。但真正把它装进 Android Studio、Sync 通过、再放到真机上滑动一次很多人第一晚就交代给了 Gradle 和 Logcat。这份源码虽然小覆盖的东西其实不少Activity 生命周期、自定义 View、手势识别、二维数组运算、SharedPreferences 存档刚好对应安卓大作业爱考的几个能力点。这篇文章要做的就是把这套源码从“能看懂”推到“能运行、能改、能答辩”。新手按章节一步步走熟手直接跳到避坑那章看版本对应关系和触摸事件处理就够了。2. 跑通 2048 源码的三个前置SDK、Gradle 版本与工程结构顺序排查2.1 Android Studio 版本与 Gradle JDK先让项目能 Sync 再谈改代码网上能找到的基于 Android Studio 的 2048 源码绝大多数是 2021 到 2023 年间的老工程。如果你用的是 2023.1.1.16 或更新版本的 Android Studio 安装包打开老工程时最常翻车的地方就是 Gradle 版本和 JDK 版本对不上。千万不要一上来就点 Run先做两件事第一查看工程根目录下的 gradle-wrapper.properties第二打开 Android Studio 的 Settings - Build Tools - Gradle看 Gradle JDK 是否指向了正确的 JDK 版本。我一般会把 Module 级别的 build.gradle 里的几个 SDK 参数整理成固定模板老工程打开后逐项核对android { // applicationId 是应用唯一标识改包名时这里要跟着改 namespace com.example.game2048 compileSdk 34 defaultConfig { applicationId com.example.game2048 minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 }这段配置里最关键的是compileSdk和targetSdk。老源码很多停在 compileSdk 30 左右而你本机装的是新版本 Android Studio默认创建工程会用 34 到 35两者不一致时 Gradle Sync 会提示下载对应 SDK下载失败就是红字报错。minSdk 21值得刻意保留它决定 App 能跑在哪些老设备上大作业演示一般不需要抬高标准。dependencies 里的依赖版本也别急着升升了以后容易出现 AndroidX 冲突。提示Android Studio 想切中文界面的去 Settings - Plugins 里装中文语言包但报错信息仍然是英文别指望翻译插件能帮你定位堆栈。Gradle JDK 推荐直接指向 Android Studio 自带的 JDK 17不要用系统里另装的 JDK 8老版本 Gradle 会直接罢工。2.2 读懂 2048 源码的目录结构从 MainActivity 到 BoardView 的调用链结构清晰与否直接决定你改代码敢不敢下手。常见的 2048 安卓大作业源码Java 文件不会很多一般是四到五个类加上布局、菜单和资源文件。拿到源码后不要急着看 MainActivity先按下面这张表格把职责分清楚。文件/目录职责改动频率MainActivity.java生命周期入口初始化棋盘、绑定监听、加载存档高BoardView.java 或 GameView.java自定义 View负责绘制棋盘和数字中Game2048.java / GameLogic.java核心算法矩阵滑动、合并、判断结束高activity_main.xml外层布局放置分数、按钮和棋盘中AndroidManifest.xml声明 Activity、权限、应用图标、主题低我见过的源码还有一种组织方式算法逻辑直接写在 MainActivity 里BoardView 只做绘制。这种写法改起来很痛苦因为滑动事件、矩阵运算、UI 刷新全挤在同一个文件里。判断这份 2048 源代码质量好不好第一眼看的就是逻辑类是否独立。站在“安卓大作业”的评分角度逻辑独立是加分项因为老师会问“如果要把棋盘改成 5x5要动哪几个文件”你答“只改算法类里的 N 和初始化部分”就稳了。2.3 第一次构建与真机部署命令行构建和 Run 按钮哪个更省心老手拿到新工程通常先跑gradlew assembleDebug而不是直接点 Run。原因很简单命令行能把完整的编译错误按顺序列出来而 Android Studio 的 Run 按钮有时候把 Sync 错误和编译错误混在一次弹窗里看起来像黑匣子。在项目根目录执行一次构建你能更早发现签名、依赖、资源文件缺漏的问题。# 在工程根目录先做一次完整构建跳过测试看编译能否通过 .\gradlew.bat assembleDebug --stacktrace # 模拟器已经启动的情况下用 gradle 直接装到已连接设备 .\gradlew.bat installDebug第一条命令里的--stacktrace是我每次必加的参数报错时能输出完整的调用链否则 Gradle 只给你一行“FAILURE: Build failed with an exception”根本没有定位头绪。assembleDebug只构建不安装适合先验证纯编译问题installDebug则会安装到当前已连接的设备或模拟器。把老工程从别的机器上“移植”过来时常见做法是只拷贝 gradle 目录、gradlew 脚本和 app 目录不要连.gradle、build这些本地产物一起拷拷贝后第一次 Sync 会重新生成本地配置。3. 2048 核心算法4x4 矩阵的滑动、合并与终局判定怎么写稳3.1 棋盘数据模型一维数组、二维数组与 0 值空位约定2048 的棋盘本质是一个 4x4 矩阵空位用 0 表示。绝大多数源码用二维数组int[][] board new int[4][4]简单直观打印和调试都方便。用一维数组int[16]也能做旋转和合并时下标换算更灵活但可读性差大作业答辩时容易解释不清楚。我建议你按二维数组读源码如果手里的源码是一维的优先自己重构成二维后续加 AI 求解和动画都会省力。初始化和生成新数字是每局游戏的基础核心代码大概长这样// 棋盘类负责所有矩阵运算不关心 UI public class Game2048 { private int[][] board; private int score; private Random random new Random(); public Game2048() { board new int[4][4]; score 0; addRandomTile(); addRandomTile(); } // 随机生成一张新牌90% 概率为 210% 概率为 4 private void addRandomTile() { Listint[] empty new ArrayList(); // 收集所有值为 0 的格子 for (int r 0; r board.length; r) { for (int c 0; c board[r].length; c) { if (board[r][c] 0) { empty.add(new int[]{r, c}); } } } if (empty.isEmpty()) return; int[] pos empty.get(random.nextInt(empty.size())); board[pos[0]][pos[1]] (random.nextInt(10) 0) ? 4 : 2; } }这里random.nextInt(10) 0决定了生成 4 的概率约为 10%生成的 2 和 4 只落在empty列表随机挑中的一个空位。源码里若是固定先找第一个空位你会发现游戏很快就没法玩新数字永远出现在左上角滑动后棋盘越来越歪。score字段放在这个类里而不放在 Activity 里是为了让逻辑层可以独立做单元测试。3.2 左滑合并的完整实现压实、合并、再压实的三个子过程2048 的滑动合并是整个工程最容易写错的部分。正确做法不是“一边扫描一边合并”而是分成三个子过程先压实把一行里的非零数字全部挪到左边再合并相邻相同数字最后再压实一次。为什么要做两次压实因为合并后可能产生新的空位比如[2, 2, 2, 2]合并成[4, 4, 0, 0]中间的空位不压掉下一轮状态就不对。// 对一行执行左移合并返回是否发生了变化 private boolean mergeRow(int[] row) { boolean moved false; int[] compacted compactRow(row); // 第一步把非零数字压实到左侧 for (int i 0; i 3; i) { if (compacted[i] ! 0 compacted[i] compacted[i 1]) { compacted[i] * 2; score compacted[i]; // 分数在这里累加 compacted[i 1] 0; i; // 跳过下一格避免同一格被合并两次 moved true; } } int[] result compactRow(compacted); // 第二步合并后再压实 System.arraycopy(result, 0, row, 0, row.length); moved | !Arrays.equals(row, compacted) | !Arrays.equals(row, result); // 简化示意 return moved; } // 把一行中的非零元素顺序取出补零到原长度 private int[] compactRow(int[] row) { int[] out new int[row.length]; int idx 0; for (int value : row) { if (value ! 0) out[idx] value; } return out; }这段代码里最容易忽略的是i那一步它在合并后跳过被合并的数字保证[4, 4, 4, 4]合并成[8, 8, 0, 0]而不是一路合并成[16, 0, 0, 0]。曾经有份网上流传的 2048 源码就是在这里少了一个自增玩到后面分数虚高老师一看矩阵就穿帮。score compacted[i]放在合并瞬间累加而不是等一轮结束再统计这样每一步的变化都能和界面数字对应上。至于moved判断我这里写的是简化版实际工程里我会用一个临时数组before保存原始行最后Arrays.equals(before, row)判断是否发生了移动这样最严谨。3.3 方向复用的技巧旋转棋盘让上下右滑共用同一套逻辑只写一个mergeRow不够棋盘有四个方向。新手最容易踩的坑是复制四份 merge 方法分别处理左、右、上、下结果四个方法各自有各自的 bug。常见的可靠做法是“旋转后统一左滑”上滑 把矩阵顺时针旋转 90 度左滑合并再旋转回去右滑 把矩阵水平镜像一次左滑合并再镜像回去。// 将矩阵顺时针旋转 90 度返回新矩阵 private int[][] rotate(int[][] src) { int n src.length; int[][] dst new int[n][n]; for (int r 0; r n; r) { for (int c 0; c n; c) { dst[c][n - 1 - r] src[r][c]; } } return dst; } // 公共入口direction 为 0左 1上 2右 3下 public boolean move(int direction) { int[][] temp board; for (int i 0; i direction; i) { temp rotate(temp); } boolean changed false; for (int r 0; r temp.length; r) { changed | mergeRow(temp[r]); } for (int i 0; i 4 - direction; i) { temp rotate(temp); } board temp; return changed; }这个技巧的价值在于你只维护一份合并逻辑四个方向全部复用测试工作量少掉四分之三。direction用 0 到 3 表示通过三次循环控制旋转次数虽然性能上多做了几次数组拷贝但 4x4 棋盘完全感觉不到。源码里如果四个方向各写一套我建议动手重构到这个版本再继续因为接下来要加撤销、自动求解时一维的合并逻辑压根没法扩展。3.4 终局判定空位检查与相邻等值检查游戏是否结束很多源码是每次移动后遍历一遍矩阵看有没有 0再看有没有相邻相同数字。这个做法的判断逻辑没错但有一个细节容易写得过于复杂有人会从左上角开始对每个格子检查右、下两个方向发现相邻相等就返回 false最后还要单独处理最后一行和最后一列的边界。其实只要两轮循环就能写得清晰。public boolean isGameOver() { // 有空位就说明还能生成新方块游戏未结束 for (int[] row : board) { for (int value : row) { if (value 0) return false; } } // 检查横向和纵向相邻是否有相同值 for (int r 0; r 4; r) { for (int c 0; c 3; c) { if (board[r][c] board[r][c 1]) return false; if (board[c][r] board[c 1][r]) return false; } } return true; }横向检查用board[r][c] board[r][c 1]纵向检查用board[c][r] board[c 1][r]两个判断挤在一个循环体里顺便把没有 0 但还能通过滑动合并的情况也覆盖了。注意这里的board[c][r]和board[c 1][r]就是把 r 和 c 对调后的写法很多一眼看不出来的 bug 就是在这里把行列搞反。终局判定放在每次移动之后调用放在移动之前会误判棋盘满了但还能合并不应该立刻结束。4. 界面与交互自定义 View、手势识别与多分辨率适配怎么协同4.1 渲染方案选型自定义 View 的 onDraw 还是 GridView 套卡片2048 的棋盘渲染有两种主流写法。一种是用 GridView 套 16 个 TextView另一种是自绘 View 重写 onDraw。拿大作业来说GridView 方案写起来快但每次滑动要更新 16 个 TextView 的数据滑动瞬间还会闪一下自绘 View 的代码量稍大但动画、缩放、颜色渐变都可以后续往上加答辩时更有的聊。我更喜欢第二种因为 Android Studio 的自定义组件本来就是高频考点源码里如果已经是自定义 View保留它如果源码是 GridView 版可以考虑只改棋盘这一部分作为差异化亮点。// 自定义棋盘 View核心是 onDraw 里的排版 public class BoardView extends View { private int[][] board; private Paint backgroundPaint new Paint(); private Paint textPaint new Paint(); private float cellSize; private float margin 12f; public BoardView(Context context, AttributeSet attrs) { super(context, attrs); backgroundPaint.setColor(0xFFBBADA0); textPaint.setTextAlign(Paint.Align.CENTER); textPaint.setTextSize(48f); textPaint.setTextScaleX(1.0f); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (board null) return; float width getWidth() - margin * 5; cellSize width / 4f; // 第一层画卡片背景第二层写数字 for (int r 0; r 4; r) { for (int c 0; c 4; c) { float left margin c * (cellSize margin); float top margin r * (cellSize margin); canvas.drawRoundRect(left, top, left cellSize, top cellSize, 12f, 12f, backgroundPaint); if (board[r][c] ! 0) { drawTextCentered(canvas, String.valueOf(board[r][c]), left cellSize / 2f, top cellSize / 2f); } } } } }这段代码里margin是卡片间距cellSize在 onDraw 里按 View 实际宽度计算保证 4 列铺满屏幕这就是多分辨率适配的关键。很多源码把 cellSize 写死成 80dp到平板上棋盘只占一小块这就是典型的适配失败。从 Android Studio 的自定义组件角度看必须重写(Context, AttributeSet)这个构造方法否则在 XML 布局里引用该组件时直接崩。drawTextCentered是我封装的辅助方法内部用Paint.getTextBounds测量文字宽高再计算偏移避免数字不居中。4.2 手势识别onTouchEvent、GestureDetector 与误触阈值手势识别的实现简版代码是重写onTouchEvent记录按下和抬起的位置然后算 dx 和 dy 判断方向。增强版是用 GestureDetector 配合onFling。对我来说大作业用前者就够因为 2048 不需要区分快速滑动和慢速拖动只看位移方向即可反而更可控。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: downX event.getX(); downY event.getY(); return true; // 由本 View 处理整条事件流 case MotionEvent.ACTION_UP: float dx event.getX() - downX; float dy event.getY() - downY; int threshold dpToPx(50); // 误触阈值低于 50dp 的移动忽略 if (Math.abs(dx) threshold Math.abs(dy) threshold) { return true; } if (Math.abs(dx) Math.abs(dy)) { onSwipe(dx 0 ? 2 : 0); // 0左 2右 } else { onSwipe(dy 0 ? 3 : 1); // 1上 3下 } return true; } return super.onTouchEvent(event); }ACTION_DOWN里return true是一件容易被忽略的小事。如果你返回 false后续的 ACTION_UP 都不会传进来手势识别直接失效症状是“点击没反应、滑动完全无响应”。方向判断用Math.abs(dx) Math.abs(dy)决定水平还是垂直再用正负决定左右、上下。dpToPx(50)是我封装的密度换算千万不要把阈值写死成50在 2.x 密度的高清屏上 50px 连一个格子都划不满操作会显得特别生涩。4.3 多分辨率适配与大作业交付细节图标、包名和状态栏运行没问题之后大作业交付前还要过几个“非功能项”应用图标、包名、App 名称和状态栏沉浸。这些检查项不涉及算法但直接影响演示观感。AndroidManifest 里的默认 Activity 图标和 android:label 要换掉否则桌面显示的是机器人头和一个叫 “MainActivity” 的 App老师一点开印象分先扣一半。application android:iconmipmap/ic_launcher android:labelstring/app_name android:themestyle/Theme.Game2048 activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application这段配置里的android:exportedtrue是 Android 12 以后必须加的不加的话安装到新设备直接闪退这是老源码最常见的兼容问题之一。android:label建议从strings.xml里引用不要硬编码在中文字符串否则换语言环境容易变成乱码。图标替换路径是app/src/main/res/mipmap-*系列文件夹把老师要求的图片丢进去记得准备 48、72、96、144 四档只放一张大图在 mipmap-xxxhdpi 里别的档位会缩放糊掉。5. 照着源码改会踩的 5 个坑现象、原因与解决5.1 Gradle Sync 失败AGP 与 Gradle 版本对不上现象工程一打开右侧 Gradle Sync 红条构建窗口提示The Android Gradle plugin supports only Gradle 8.x。原因老源码用 AGP 7.x 配 Gradle 7.x你本机默认模板是 AGP 8.x 配 Gradle 8.x两者版本矩阵没对上。解决打开 gradle-wrapper.properties把distributionUrl改成和 AGP 匹配的 Gradle 版本如果不想记矩阵直接新建一个空工程把新工程的 gradle-wrapper.properties 和com.android.tools.build:gradle版本号抄过来这是最快止血方案。5.2 真机闪退自定义 View 缺构造方法、Style 属性不识别现象模拟器上正常一装真机就崩Logcat 里InflateException指向BoardView。原因布局 XML 里声明自定义 View 时系统调用的是(Context, AttributeSet)构造方法源码里只写了(Context)或无参版本。解决补上完整的两个构造方法并在其中一个里统一初始化资源。另一个常见变体是布局引用了自定义属性app:xxx但没加xmlns:app声明解决方法是把自定义属性删掉只保留系统支持的 layout 属性。5.3 手势方向错乱或触摸无效事件被消费、返回值和拦截现象向上滑数字却向右移或者第一次滑动没反应第二次才有。原因onTouchEvent 的 switch 写成了case MotionEvent.ACTION_DOWN后不返回值还在 switch 结束后调用了super.onTouchEvent导致事件被父容器或父布局拦截。解决按我前面那版代码DOWN 和 UP 都返回 true方向判断重新测一遍用 Log 输出 dx 和 dy别忘了屏幕上顺手画一个 “” 记下手指起点对照起点判断方向才准。5.4 合并翻车一次滑动同一格被合并两次分数乱跳现象[4, 4, 4, 4]左滑后变成[8, 8, 0, 0]是对的但某些源码变成[16, 0, 0, 0]或者单次滑动加了两次分。原因循环里没有在合并后跳过被合并的格子或者加分写在了循环体外。解决合并后立即执行i跳过一格加分放在compacted[i] * 2之后立刻算。验证办法很简单在 mergeRow 入口和出口各打一行日志把整行数组打出来一眼就能看出结果对没对。5.5 游戏结束误判明明还有空位就弹结束现象棋盘上还有四五个空格但系统已经弹了 “Game Over”。原因终局判定写在了移动之前或者移动失败时也进行了判定。有时是因为你滑动了不可移动的方向moved 返回 false源码不管三七二十一直接判定结束。解决只在 moved 为 true 时执行isGameOver检查检测顺序是“滑动是否改变状态 - 改变后再看是否终局”isGameOver 里保留两个遍历空位检查 相邻等值检查缺一个都会误报。6. 让这份 2048 源码真正变成你自己的项目存档恢复与自动求解到了这一步你已经离“读懂”很近了。但“安卓大作业”要拿高分需要一些别人没有的差异化功能。我建议按下面顺序加两个东西存档恢复和自动求解。这两个功能都不难但都能写进答辩讲稿里。6.1 存档恢复SharedPreferences 存棋盘与分数的序列化方案很多源码只管当前一局退出 App 就丢进度。存档的常见做法是 SharedPreferences但 4x4 矩阵不能直接塞进去要么用 json 序列化要么用简单分隔符拼字符串。// 保存把二维数组转成 2,4,0,0,4,2,... 字符串 public void saveState(Context context) { StringBuilder sb new StringBuilder(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (sb.length() 0) sb.append(,); sb.append(board[r][c]); } } SharedPreferences sp context.getSharedPreferences(game2048, Context.MODE_PRIVATE); sp.edit() .putString(board, sb.toString()) .putInt(score, score) .putBoolean(hasState, true) .apply(); } // 读取把字符串再变回矩阵 public void loadState(Context context) { SharedPreferences sp context.getSharedPreferences(game2048, Context.MODE_PRIVATE); if (!sp.getBoolean(hasState, false)) return; String data sp.getString(board, ); String[] parts data.split(,); if (parts.length ! 16) return; for (int i 0; i 16; i) { board[i / 4][i % 4] Integer.parseInt(parts[i]); } score sp.getInt(score, 0); }两个方法分别放在 MainActivity 的onPause和onCreate里调用App 被杀掉再打开进度就回来了。注意.apply()是异步写盘.commit()是同步写存档这种低频操作用 apply 完全够。有个坑是数组里的 0 也存进字符串了解析时split(,)会把连续逗号处理成空字符串Integer.parseInt对空字符串会抛异常所以我在读回之后加了parts.length ! 16的防御防止老版本存档格式不一致导致崩溃。6.2 一个 8 行的自动求解按“最大化角落权重”选方向2048 的 AI 是个大话题但大作业只需要一个“自动跑”按钮。最简单的方案是给每个格子加一个权重大数往角落堆每次移动前取四个方向的“加权和”选最大那个方向。private int[] weights { 16, 8, 4, 2, 8, 4, 2, 1, 4, 2, 1, 0, 2, 1, 0, -1 }; // 对四个方向分别计算得分选最高者 public int autoMove() { int bestDirection 0; int bestScore -1; for (int dir 0; dir 4; dir) { Game2048 copy clone(); // 在副本上模拟不破坏当前局面 if (copy.move(dir)) { int s evaluate(copy.board); if (s bestScore) { bestScore s; bestDirection dir; } } } return bestDirection; }clone()在 Game2048 里实现一个浅拷贝拷贝整个 board 二维数组。evaluate就是把每个格子的值乘上对应权重再求和。这样 AI 会倾向于把大数字堆在左上角玩个几千分不成问题。你可以在界面上加一个“自动”按钮点击后连发 move 事件间隔 200 毫秒。答辩时老师问“这个 AI 怎么实现的”你就说“贪心 静态权重”比背神经网络名词实在多了。6.3 验证方法把改动回归跑一遍老用例加完存档和自动求解之后一定要按这条用例顺序回归一遍第一安装后首次进入棋盘应有两个初始数字第二滑动一步数字往期望方向移动分数增加且带有 2 的幂值第三退出 App 再进入棋盘和分数和退出前完全一致不能丢格子第四连续滑动到死局看是否弹出结束提示第五点自动按钮观察 AI 能否连续完成几步而不是原地不动。这套验证覆盖了算法、生命周期、存档和交互四条链路跑完你才敢把它当自己的作品交上去。我第一次写 2048 大作业时就翻过车存档里忘了处理 0 值恢复后棋盘全变成 0分数还在。后来养成了习惯凡是棋盘相关的数据结构先序列化输出到 Logcat 看一眼再写进 SharedPreferences。这也是我想提醒你的源码能跑通只是第一步真正的收获是把每个改动边缘试一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表