简介:面向安卓课程设计与期末大作业场景,这是一份基于Android Studio的2048小游戏完整源代码。项目由个人手打并获导师认可,代码含详细注释,逻辑清晰,下载解压后简单配置即可运行,可作为高分项目范例参考。资源包共141个文件,压缩包仅394KB,内容以Java、XML为主,Java文件承担游戏核心逻辑,XML定义界面布局与资源,配合Gradle构建脚本、webp图片素材及bat运行脚本,结构紧凑,便于学习。目前已有269人学习下载,适合需要快速上手Android项目或提交大作业的读者。通过详细注释可掌握游戏状态管理、滑动事件监听、数字合成与分数计算等关键实现,同时完整构建配置与资源文件能减少环境搭建成本,方便对照自查或二次扩展,既适合入门模仿,也适合作为课程答辩展示素材。
1. 从安卓大作业到能跑的 2048:这份源代码包解决的是“能讲清”的问题
很多人的安卓大作业清单里,“基于 Android Studio 实现 2048 小游戏源代码”几乎是绕不过去的一项。它热门不是因为名字响亮,而是难度刚好卡在一个能独立完成的位置:数据逻辑是一套纯算法,界面层只需要一块自绘棋盘,一个人一周就能从骨架写到能答辩。这份资源就是一个能直接在 Android Studio 里打开运行的完整工程,拆开看是由二维数组、手势识别、自绘 View 组成的三个清晰模块,这也是我把它看成“能讲清楚的源码包”,而不是“复制粘贴交差包”的原因。
如果你只是想找一段代码改个颜色就交作业,这份资源反而有点重;但如果你想让老师问到底层逻辑时自己不虚,它能帮你把 2048 讲成“一个 4×4 数组在四个方向上的状态迁移”,而不是“我抄了一个网上很火的游戏”。下载解压后第一步不要急着往自己项目里拷文件,用 Android Studio 的 Open 打开根目录,先等 Gradle 同步完成、app 能跑起来,再开始动手改。跳过这一步的人,大概率会在依赖冲突和自己的语法错误之间来回翻车。
2. 2048 核心算法先立住:一行合并、方向归约与终局判定
2.1 棋盘怎么存:0、二维数组与随机开局
2048 的棋盘就是一块 4×4 的方格,最适合的数据结构是int[4][4],其中 0 代表空格,2、4、8 这类数字直接存格子当前显示的值。这样做的直接好处是:绘制时“数字是什么就画什么”,不需要在像素和指数之间来回换算,合并时也只需要做数值相等判断和乘法。
开局的常见做法是在空棋盘里随机放两个数字,大部分源码的实现逻辑都类似:
private int[][] mBoard = new int[4][4]; private Random mRandom = new Random(); private void initBoard() { for (int r = 0; r < 4; r++) { Arrays.fill(mBoard[r], 0); // 清零 } addRandomTile(); // 开局放两个数字 addRandomTile(); mScore = 0; } private void addRandomTile() { List<int[]> empty = new ArrayList<>(); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (mBoard[r][c] == 0) empty.add(new int[]{r, c}); } } if (empty.isEmpty()) return; // 没有空位就不再生成 int[] pos = empty.get(mRandom.nextInt(empty.size())); mBoard[pos[0]][pos[1]] = mRandom.nextInt(10) == 0 ? 4 : 2; }这里有两个值得注意的参数:新块 90% 概率生成 2、10% 概率生成 4。这是 2048 系游戏最流行的一组手感参数,改大 4 的概率会让游戏节奏变快、难度上升。addRandomTile()在每次有效移动之后都会被调用,但要在移动确实改变了棋盘时才调用,这一点放到 2.3 小节详细说,因为它是很多半成品源码翻车的高发地。
2.2 一行合并的三步:压缩、合并且只合并一次、再压缩
整个 2048 的算法核心其实只有一行:把 4 个数字按照规则合并。最清晰的做法不是对 4×4 棋盘写四份不同方向的循环,而是先写出“合并一行”的函数,再把四个方向都归约成逐行调它。
public static int[] mergeLine(int[] line) { int[] tmp = new int[line.length]; // 存放第一次压缩的结果 int idx = 0; for (int v : line) { if (v != 0) tmp[idx++] = v; // 去掉0,把所有数字压到左侧 } for (int i = 0; i < idx - 1; i++) { if (tmp[i] == tmp[i + 1]) { tmp[i] *= 2; // 相邻相等就合并 tmp[i + 1] = 0; i++; // 跳过被合并的格子,防止二次合并 } } int[] result = new int[line.length]; idx = 0; for (int v : tmp) { if (v != 0) result[idx++] = v; // 把合并留下的0再压掉 } return result; }以[0,2,0,2]为例:第一步压缩得到[2,2,0,0];合并循环里 2 和 2 相等,得到[4,0,0,0];第二次压缩结果不变。这就是玩家看到的“两个 2 合成一个 4”。注意合并循环里的i++,如果漏掉它,[2,2,2,2]会变成[8,0,0,0],而不是正确的[4,4,0,0]。这个细节是 2048 合并规则的灵魂:一排数字里,每个数字每轮只能参与一次合并。
四个方向的归约做法是:左滑就直接对每行调用mergeLine;右滑先反转每行,合并完再反转回来;上滑先转置矩阵,做左滑操作,再转置回来;下滑先转置、再对每行反转,做左滑、再反转、再转置。这套“转置 + 反转 + 复用同一行函数”的写法比复制粘贴四份循环清晰得多,以后想改成 5×5 棋盘,也只需要把数组长度从 4 改掉,方向逻辑一行都不用动。
2.3 新块生成时机与终局判定:两个最容易翻车的边界
新块生成有个前提:必须在“有效移动”之后。有效移动的定义是滑动前后棋盘数组发生了变化。比如[2,0,0,0]左滑还是[2,0,0,0],这就是无效滑动,不应该生成新块。有些源码在每次 onTouchEvent 后都无条件调用addRandomTile(),结果玩家原地划几下,棋盘上突然多出好几个数字。
判断有效移动的常见做法是先深拷贝棋盘,移动后比较:
private boolean move(int direction) { int[][] before = copyBoard(mBoard); // 深拷贝当前棋盘 // 按 direction 变换、调用 mergeLine、把结果回写 mBoard if (boardEquals(before, mBoard)) { return false; // 棋盘没变化,属于无效滑动 } addRandomTile(); // 只有变化了才补一个新块 return true; }深拷贝必须逐格复制成一个新数组而不能直接赋值引用,否则 before 和 mBoard 指向同一块内存,比较永远相等。游戏结束判定也有一个常见误解:以为需要检查上下左右四个方向,其实只需要检查“还有没有空格”和“是否存在相邻相等的数字”:
public boolean canMove() { for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (mBoard[r][c] == 0) return true; // 还有空位 if (r + 1 < 4 && mBoard[r][c] == mBoard[r + 1][c]) return true; if (c + 1 < 4 && mBoard[r][c] == mBoard[r][c + 1]) return true; } } return false; }这里只检查下方和右方就已经覆盖了全部相邻关系:任意两个相等格子,必然一个是另一个的下方或右方。每次移动后调用canMove(),返回 false 就进入游戏结束流程。
2.4 把算法从 View 里剥出来:GameLogic 独立类的作用
源码里最值得保留的结构,是单独开一个类只放算法函数,不碰 Context、不碰 View。我会在类似项目里新建GameLogic.java,把mergeLine、move、canMove、spawnRandomTile全放进去,里面不 import 任何 android 包。这个独立类带来三个能力:JVM 单元测试验证算法、换界面实现不需要重写逻辑、排查问题时空指针概率大幅下降。
这套接口不要求固定名字,但任何一个结构清晰的 2048 源码里基本都能看到对应的四个方法:
| 方法 | 入参 | 返回值 | 作用 |
|---|---|---|---|
| mergeLine | int[] 一行 4 个元素 | int[] 合并后的一行 | 处理单行合并 |
| move | 方向常量 + 当前棋盘 | boolean 是否发生有效移动 | 驱动整盘状态迁移 |
| canMove | 当前棋盘 | boolean 是否还能继续 | 终局判定 |
| spawnRandomTile | 当前棋盘 | boolean 是否成功生成 | 随机补充新块 |
如果拿到的源码把这段逻辑和 onDraw 画在同一个类里,建议你动手拆出来。拆的时候保持方法签名不变,View 的调用处几乎不用修改,但后续所有调试都会轻松很多。
3. Android 层手势与绘制:自定义 View 把数组变成一块能玩的棋盘
3.1 手势方向判定:DOWN/UP 两段式与 dp 阈值消抖
2048 不需要跟手滑动,常见方案是 ACTION_DOWN 记下起点,ACTION_UP 算位移方向。这里最大的坑是“快速滑动时的坐标抖动”:手指按下时可能在几毫秒内移动十几像素,如果不设阈值,点一下都会变成一个方向。阈值用 dp 换算而不是写死像素值,才能在高低密度屏上表现一致。
float downX = 0, downY = 0; @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: downX = event.getX(); downY = event.getY(); return true; case MotionEvent.ACTION_UP: float dx = event.getX() - downX; float dy = event.getY() - downY; int threshold = (int) (16 * getResources().getDisplayMetrics().density); if (Math.abs(dx) < threshold && Math.abs(dy) < threshold) return true; if (Math.abs(dx) > Math.abs(dy)) { move(dx > 0 ? DIRECTION_RIGHT : DIRECTION_LEFT); } else { move(dy > 0 ? DIRECTION_DOWN : DIRECTION_UP); } return true; } return super.onTouchEvent(event); }16dp 在主流屏幕上约等于 48 到 64 像素,能过滤掉手指轻微移动和点击。觉得游戏“很难滑出方向”,把阈值降到 8dp;觉得“老是误触”,升到 24dp。这两个值就是这份源码里最值得调的第一个参数。斜滑的判断用 dx/dy 绝对值比较,而不是先判断横竖,这样在接近 45° 的滑动时也能有比较稳定的表现。
3.2 自绘棋盘:onDraw 里的格子、颜色与字号
2048 用自绘 View 而不是 GridView + Adapter,理由很实际:GridView 刷新时要重建整个 Item 列表,成本高,动画难控制;自绘只需要在 onDraw 里画 16 个格子,每次滑动后局部重绘即可。对一个画面简洁、状态简单的游戏,自绘是最低成本的实现方式,这也是“自定义 View”知识点的一个最小实践。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int size = Math.min(getWidth(), getHeight()); // 取短边做画布 float density = getResources().getDisplayMetrics().density; float padding = 10 * density; // 外框留白 float gap = 4 * density; // 格子间距 float cell = (size - padding * 2 - gap * 3) / 4f; // 每格边长 Paint bg = new Paint(); bg.setColor(0xFFBBADA0); // 经典棋盘底色 canvas.drawRoundRect(0, 0, size, size, 12 * density, 12 * density, bg); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { float left = padding + c * (cell + gap); float top = padding + r * (cell + gap); int value = mBoard[r][c]; Paint cellPaint = new Paint(); cellPaint.setColor(tileColor(value)); canvas.drawRoundRect(left, top, left + cell, top + cell, 8 * density, 8 * density, cellPaint); if (value > 0) { drawNumber(canvas, String.valueOf(value), left, top, cell); } } } }格子边长的计算必须统一用 px:dp 先乘 density 再参与除法和布局,直接拿 dp 和 px 混算,在部分机型上会出现格子溢出画布,这就是很多人“模拟器正常、真机串位”的血泪经验。数字颜色按格子值映射,最流行的一套配色如下:
| 格子值 | 背景色 ARGB |
|---|---|
| 0 | 0xFFCDC1B4 |
| 2 | 0xFFEEE4DA |
| 4 | 0xFFEDE0C8 |
| 8 | 0xFFF2B179 |
| 16 | 0xFFF59563 |
| 32 | 0xFFF67C5F |
| 64 | 0xFFF65E3B |
| 128 | 0xFFEDCF72 |
| 256 | 0xFFEDCC61 |
| 512 | 0xFFEDC850 |
| 1024 | 0xFFEDC53F |
| 2048 | 0xFFEDC22E |
| >2048 | 0xFF3C3A32 |
字号不要固定,按数字位数收缩:一位数用格子边长的 0.45,两位数用 0.36,三位以上再继续缩小。drawNumber里把 Paint 的 TextAlign 设成 CENTER,文字垂直居中用baseline = top + cell / 2 - (fontMetrics.ascent + fontMetrics.descent) / 2,直接拿getHeight() / 2套会偏上。
3.3 MainActivity 接线:从 View 挂载到分数刷新回调
自绘 View 写好后,Activity 的职责就很简单:找 View、找分数 TextView、设置重新开始按钮。常见的正确接线方式是这个样子:
public class MainActivity extends AppCompatActivity { private Game2048View mGameView; private TextView mScoreView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mGameView = findViewById(R.id.game_view); mScoreView = findViewById(R.id.score_view); mGameView.setOnScoreChangedListener(score -> mScoreView.setText(String.valueOf(score))); mGameView.setBestScore(loadBestScore()); } }分数刷新的正确时机是在合并完成之后、invalidate 之前。如果先重绘再通知分数,界面会先画旧数字,玩家看到“分数慢半拍”的视觉误差。重新开始按钮同样要走完整流程:调用 reset() 清空棋盘和分数,再 addRandomTile 两次。很多半成品源码只清了棋盘不放回数字,点“重新开始”后开局是空棋盘,要滑一下才出现数字,体验上就像游戏还没就绪。
3.4 最高分持久化:SharedPreferences 的读写时机
游戏状态里最简单的持久化是当前分数和最高分。我的习惯是:最高分在每次分数变化时判断并写入,而不是统一放在 onPause 里写。这样即使进程被系统杀掉,最高分也不会丢。
private void updateBestScore(int currentScore) { SharedPreferences sp = getContext() .getSharedPreferences("2048_pref", Context.MODE_PRIVATE); int best = sp.getInt("best_score", 0); if (currentScore > best) { sp.edit().putInt("best_score", currentScore).apply(); } }读取时机放在 onCreate 里,显示到最高分 TextView 后再开始游戏。如果只存最高分而不存棋盘,旋转屏幕后分数还在但棋盘会丢,这个问题放到下一章,它是 2048 项目里最容易被问到也最容易答不上来的坑。
4. 源码包实战中的常见问题与避坑:五条我替你踩过的坑
4.1 旋转屏幕后棋盘清零:onSaveInstanceState 只救了一半
现象:横竖屏切换后,最高分还在,棋盘上的数字却全没了。很多人的第一反应是“游戏重开了”,其实是 Activity 重建后,自定义 View 里的 mBoard 字段没有跟着恢复。
原因:自定义 View 的 mBoard 是普通字段,系统默认不会保存。给 View 设置了 android:id 后,系统确实会调用 View.onSaveInstanceState,但它保存的是滚动位置、文本状态这类基础信息,不会知道你的 int[][] 棋盘里装着什么。
解决:在 Activity 里把棋盘扁平化成 int[16] 存进 Bundle,重建时还原:
@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); int[] flat = new int[16]; int[][] board = mGameView.getBoard(); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { flat[r * 4 + c] = board[r][c]; } } outState.putIntArray("board", flat); outState.putInt("score", mGameView.getScore()); }onCreate 里拿到savedInstanceState后把 flat 数组传回 View,再做一次二维还原。另一个更省心的方案是把棋盘也写进 SharedPreferences,每次有效移动后同步存档,坏处是滑动一次写一次盘,性能略差,作业项目里用 Bundle 方案就够了。
4.2 [2,2,2,2] 左滑变成 8:连续合并的经典错误
现象:屏幕上四个 2 排成一排,左滑后变成一个 8,而不是两个 4。这个错误特别隐蔽,因为单看结果好像也没问题,但 4×4 棋盘里一旦出现这类连续合并,游戏进程会明显变快,整局难度失衡。
原因:合并循环里没有“跳过被合并过的格子”。第一次合并把前两个 2 变成 4 后,循环指针继续右移,又拿这个 4 去和后面的 2 比较,一路吃下去就变成了 8。错误写法长这样:
for (int i = 0; i < idx - 1; i++) { if (tmp[i] == tmp[i + 1]) { tmp[i] *= 2; tmp[i + 1] = 0; // 少了 i++,下一步会把刚合并出来的 4 再参与比较 } }解决:合并完成后手动 i++ 跳过被清空的格子。对照 2.2 节的正确写法,[2,2,2,2]应该得到[4,4,0,0],[4,4,4,0]应该得到[8,4,0,0]。这两组输入输出值得记下来,后面写单元测试直接用。
4.3 快速滑动误判方向:阈值、斜滑与多指干扰
现象:明明向左滑,游戏判定成了向上;或者连续快速滑动时偶发方向错乱。这种问题在模拟器上很难复现,在真机上特别明显,属于输入识别层面的经典翻车。
原因:阈值设太小,比如写死 10px,手指按下时轻微抖动就会被算成一次长距离;另一个是多指操作——第二根手指落下的瞬间又触发一次 ACTION_DOWN,把起始坐标重置了,最终位移方向自然不对。
解决:阈值用 dp 换算并按 3.1 节的代码实现;方向判定用绝对值比较;多指干扰用getActionMasked()配合ACTION_POINTER_DOWN过滤,只以第一根手指的坐标为准。如果发现真机上滑动偏“肉”,可以先检查 View 是否被父容器拦截了事件,2048 这种全屏自绘 View 通常不会遇到,但嵌在 ScrollView 里就会。
4.4 分数在涨棋盘不动:invalidate 与数据回写
现象:每次滑动分数都在涨,棋盘的格子却几乎没变化,或者操作几次后突然数组越界。这类问题的现场最有迷惑性,因为分数确实是合并逻辑算出来的,看起来像“界面 bug”,其实是状态同步断了。
原因:合并逻辑作用在局部数组上,move 结束后没有把结果回写到 mBoard;或者数据改了但忘了调用 invalidate(),界面永远画的是旧数组。越界则是回写时用了错误的下标。
解决:把移动流程固定成五步,顺序不能乱:
- 深拷贝当前棋盘作为 before;
- 按方向变换、调 mergeLine、再变换回原坐标;
- 把合并结果逐格回写 mBoard;
- 比较 before 和 mBoard,变化了就 addRandomTile + invalidate + 刷新分数;
- 检查 canMove,false 就进入游戏结束。
回写这一步最容易省掉,代码就一行:
int[] merged = GameLogic.mergeLine(mBoard[r]); System.arraycopy(merged, 0, mBoard[r], 0, 4);如果拿到的源码里 move 方法同时“返回分数”和“修改棋盘”,要先确认它改完棋盘再返回分数。顺序反了,分数会提前累加,界面显示同步错乱。
4.5 导入源码报资源重复或 SDK 版本不匹配:先清 build 目录再看版本
现象:用 Android Studio 打开别人打包的工程,一同步就报resource linking failed或者duplicate resources,看起来像是源码本身有问题。
原因:压缩包里残留了旧电脑的 build 目录、.gradle 缓存,或者工程用的 compileSdk 版本和你本机安装的 SDK 不一致。资源重复则通常来自 drawable 里同名不同后缀的文件,以及不同 values 目录下的重复定义。
解决:先关掉项目,把工程根目录下的 build/ 和 .gradle/ 整个删掉再重新打开,让 IDE 重新构建;这一步能解决大半同步报错。剩下编译不过的,打开 app/build.gradle,把 compileSdk、targetSdk 改成你自己环境里已安装的版本,再 Sync 一次。这个操作对任何“从别的电脑移植 Android Studio 项目”都适用。
5. 进阶验证与自定义:用单测和 adb 滑动把这份源码变成你的作品
5.1 用 JUnit 给合并逻辑上保险,不靠手感
因为第 2 章的 GameLogic 不依赖任何 Android 类,它可以直接在 JVM 单元测试里跑。在 app/src/test 目录下新建 GameLogicTest,把 2.2 节那两个关键用例固化下来:
public class GameLogicTest { @Test public void mergeLine_twoAndTwo_makesFour() { int[] input = {2, 2, 0, 0}; assertArrayEquals(new int[]{4, 0, 0, 0}, GameLogic.mergeLine(input)); } @Test public void mergeLine_fourTwos_noDoubleMerge() { int[] input = {2, 2, 2, 2}; assertArrayEquals(new int[]{4, 4, 0, 0}, GameLogic.mergeLine(input)); } }运行方式很简单:在左侧 Project 窗口里找到 GameLogicTest,右键选择 Run,不需要模拟器也不需要真机。手动滑动时你很难判断 [2,2,2,2] 到底应该变成什么,单测一旦跑通,以后改成 5×5 棋盘、改成动画,合并逻辑都不会出现“改一个功能坏一个功能”的回归。验证完逻辑,再在模拟器里执行adb shell input swipe 300 300 100 300,从 (300,300) 滑到 (100,300) 就是一次标准左滑,坐标要落在游戏区域里。这一条命令能立刻确认手势方向映射有没有写反,比反复用手指滑快得多。
5.2 三个低成本加分项:动画时长、回退栈与颜色表
想给答辩加分的,不建议去改玩法,而是做三个低成本小点。合并动效用 ValueAnimator 记录每个格子的起始坐标和结束坐标,时长 120ms 到 150ms 是主流手感,动画结束那一帧提交最终棋盘数组,不需要引入第三方库。回退一步用一个Deque<int[]>,每次有效移动前把当前 16 个格子压栈,回退时弹栈并 restore,栈深限制在 20 步防止内存增长。主题变化则最简单,把 3.2 节的颜色表整体换掉,或者把棋盘背景改成圆角渐变,逻辑一行不动。这三个点分别对应“动画实现”“数据结构应用”“绘制封装”,都能在问答环节讲出实际细节。
我的习惯是,拿到任何一份 2048 源码后,先把 mergeLine 的单测跑一遍,再在模拟器里adb shell input swipe验证一次方向映射,确认无误才开始改代码。从那以后我每次调 2048 相关项目,都强制走这一遍流程再谈改功能,基本没在演示中途翻过车。希望帮到你。
本文还有配套的精品资源,点击获取