ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android 2048小游戏源码拆解:自定义View、手势与合并算法

Android 2048小游戏源码拆解:自定义View、手势与合并算法

简介:这份资源是面向计算机相关专业本科生、尤其是准备撰写毕业设计的同学的一份完整学位论文文档,选题为基于 Android 平台的 2048 小游戏设计与实现,适合处于选题开题、结构搭建、内容填充与答辩准备阶段的读者参考,也可作为软件工程类课程设计的技术文档范本。压缩包内共 1 个 doc 文件,约 619KB,为排版完整的论文正文,含中英文摘要、关键词、目录、正文与参考文献,并附毕业设计任务书、要求及原始资料等前置材料。论文按摘要、背景意义、开发技术介绍、需求分析、可行性分析、功能分析、业务流程分析、数据库设计、ER 图、数据字典、数据流图、详细设计、系统截图、测试、总结、致谢、参考文献的顺序展开,完整呈现从选题论证到测试收尾的写作脉络。目前已有 223 人学习。读者可据此了解 Android 应用类毕业论文的章节组织与论述深度,借鉴界面布局、游戏算法、数据存储与功能测试等章节的写法,并参照其任务书与文献列表安排自己的进度与参考资料,减少结构搭建与内容组织上的弯路。

1. 从一份计算机毕业论文资源说起:这个 Android 2048 小游戏能解决什么问题

很多人拿到这份名为“毕业论文安卓530 2048小游戏app.doc”的资源,第一反应是翻论文格式,真正能拿去复现的其实是里面那套基于 Android 的 2048 小游戏源码。它适合两类人:一类是正在找计算机毕业论文项目、需要一份能跑起来也能讲清楚模块划分的 Android 小游戏;另一类是想练手 Java、Activity、自定义 View、手势检测和 4×4 棋盘合并算法的开发者。工程年代偏早,Eclipse、ADT、SDK 那一套环境跟现在的 Android Studio 不一样,但核心逻辑不过时:欢迎页、模式选择、卡片绘制、滑动判定、数字合并、得分和最高分保存,一条链路完整。下面按“先跑通、再拆算法、最后改功能”的顺序,把这套资源拆开讲。

2. 工程骨架与开发环境:Eclipse + ADT 时代的 Android 项目怎么落地

2.1 目录结构与模块划分

这套工程不是现在常见的 Gradle 工程,而是 Eclipse + ADT 插件时代的 Android 项目。典型目录里会看到src、gen、res、assets、AndroidManifest.xml、project.properties、proguard-project.txt这些文件,gen目录下放的是自动生成的R.java,老工程导入后报红叉,十有八九跟R.java没生成或资源文件有错有关。论文里把系统拆成欢迎界面、游戏主界面、卡片实现、卡片滑动、其他功能几块,对应到源码里,常见做法是一个MainActivity负责欢迎页和模式选择,一个GameActivity承载游戏主界面,再配一个自定义GameView或GameBoard负责棋盘绘制和触摸事件,合并算法单独抽成GameLogic或直接放在GameView里。

我一般会先按“界面层、交互层、算法层、存储层”四层去看这类工程。界面层是res/layout下的 XML 和 Activity 的setContentView;交互层是OnTouchListener、GestureDetector或自定义 View 的onTouchEvent;算法层是 4×4 二维数组的移动、合并、随机落子;存储层是SharedPreferences存最高分,或者用文件保存棋盘快照。论文里提到“数据库设计”“数据存储设计”,对 2048 这种体量来说,常见做法不是上 SQLite,而是用SharedPreferences存最高分和模式偏好,简单、够用、少一层依赖。看源码时别被论文里的数据库章节带偏,先找getSharedPreferences或SQLiteOpenHelper,以实际代码为准。

模块边界清楚之后,改功能就不容易翻车。比如换主题皮肤,只动颜色映射和 drawable 资源;改合并规则,只动moveLine和方向映射;加撤销功能,就在每次有效移动前把棋盘数组压进一个栈。最怕的是把绘制、手势、合并全塞在一个onTouchEvent里,改一个阈值就牵出三个 bug。论文目录里“详细设计”分开写界面设计、按钮设计、算法设计,落地时也尽量按这个边界拆类,后面二次开发会轻松很多。

2.2 环境搭建与 AVD 跑通

老工程的跑通路径跟新工程不一样。先确认本机有 JDK,java -version能输出版本信息;再确认 Android SDK 的platform-tools和tools在环境变量里,adb devices能看到设备或模拟器;Eclipse 里装好 ADT 插件,配好 SDK 路径,才能新建 AVD 或直接跑工程。如果本机已经转向 Android Studio,也不必硬装回 Eclipse,可以先在 Android Studio 里新建一个空工程,把src、res、AndroidManifest.xml搬过去,再补 Gradle 配置。搬的时候注意包名、minSdkVersion、targetSdkVersion和资源命名,老工程里大写的资源文件名在新构建工具下可能直接报错。

# 确认 adb 能看到设备或模拟器 adb devices # 老式 ADT 工程没有 Gradle,直接安装已导出的 apk 更省事 adb install -r 2048-debug.apk # 如果已经迁到 Gradle 工程,先跑一次 debug 构建 ./gradlew assembleDebug

adb devices用来确认连接状态,设备列表为空时先查驱动、USB 调试或模拟器有没有启动。adb install -r里的-r表示覆盖安装,适合反复改、反复装;如果签名冲突,先卸载旧包再装。assembleDebug生成的是调试包,路径通常在app/build/outputs/apk/debug/下。老工程没有gradlew时不要硬跑这条命令,先把工程结构迁到 Gradle 再试。模拟器方面,AVD 的 API 级别不要选太新,老工程在过高版本上可能遇到权限、exported声明、存储路径变化等问题;也不要选太低,太低缺少现代输入法支持,调试体验差。我一般选一个中间版本,先把欢迎页跑出来,再逐步往上加。

2.3 清单文件与资源组织

AndroidManifest.xml是这类老工程最容易被忽略的地方。论文里通常只写“全局配置文件”,实际代码里它决定应用名、图标、启动 Activity、屏幕方向和主题。2048 一般固定竖屏,避免旋转后棋盘状态重建;欢迎页作为入口,游戏页作为第二个 Activity。现代 Android 对android:exported有强制要求,带intent-filter的 Activity 如果不写,安装后点图标可能直接闪退,Logcat 里会提示缺少exported声明。这个问题在旧设备上不一定复现,在新设备上很容易翻车。

<application android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme"> <!-- 欢迎页作为入口,必须显式声明 exported --> <activity android:name=".MainActivity" android:exported="true" android:screenOrientation="portrait"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <!-- 游戏主界面,不需要对外暴露 --> <activity android:name=".GameActivity" android:exported="false" android:screenOrientation="portrait" /> </application>

android:name写相对包名的类名,.MainActivity会拼上package属性里的包名;android:exported="true"只给带启动入口的 Activity,游戏页保持false更安全;screenOrientation="portrait"固定竖屏,避免旋转导致onCreate重走、棋盘状态丢失。资源组织上,布局放res/layout,颜色和尺寸放res/values/colors.xml、dimens.xml,字符串放strings.xml,卡片背景和图标放res/drawable。论文里如果写了“按钮设计”,落地时通常就是Button或ImageButton加onClick,别把按钮逻辑写进布局里,方便后面换皮肤。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 棋盘由自定义 View 自绘,比分用 TextView 叠在上层 --> <com.example.game2048.GameView android:id="@+id/game_view" android:layout_width="match_parent" android:layout_height="match_parent" /> <TextView android:id="@+id/tv_score" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="top|end" android:layout_margin="16dp" android:text="0" android:textSize="24sp" /> </FrameLayout>

FrameLayout适合叠层,GameView占满屏幕画棋盘,TextView浮在右上角显示分数。match_parent让棋盘跟随屏幕大小,wrap_content让分数文本按内容撑开,layout_gravity控制位置,textSize用sp跟随系统字体缩放。自定义 View 的包名要跟AndroidManifest.xml里的package对上,否则 XML 里找不到类,运行时直接InflateException。这类错误在 Eclipse 里不一定编译期报,跑起来才崩,所以布局改完先看 Logcat 第一行异常。

提示:老工程迁移时先把AndroidManifest.xml和res目录处理干净,再搬 Java 代码,顺序反了容易在一堆红叉里找不到真正原因。

3. 界面与交互实现:欢迎页、游戏主界面和手势滑动链路

3.1 欢迎页与模式选择

欢迎页一般承担两个职责:展示应用名和图标,给用户一个进入游戏的入口;如果游戏有多个模式,就在这里做选择。论文里提到两种模式,一种是传统数字,另一种是“酷车一族”汽车主题,这个设计放到代码里通常是用一个int或枚举通过Intent传给游戏页,游戏页根据模式切换颜色映射或图片资源。欢迎页不要做太重,常见做法是Handler.postDelayed延时跳转,或者放一个“开始游戏”按钮,让用户自己点。延时跳转要注意页面销毁时移除回调,否则退出后还可能触发startActivity,Logcat 里会看到Activity has leaked之类的警告。

// MainActivity.java public void startGame(int mode) { Intent intent = new Intent(this, GameActivity.class); // 0 表示数字模式,1 表示汽车主题模式 intent.putExtra("mode", mode); startActivity(intent); finish(); // 欢迎页不需要留在返回栈 } @Override protected void onDestroy() { super.onDestroy(); // 如果用了 Handler 延时跳转,这里要移除未执行的回调 handler.removeCallbacksAndMessages(null); }

mode是模式参数,游戏页用getIntent().getIntExtra("mode", 0)取,默认值给 0,避免旧入口没传参时崩溃。finish()让欢迎页退出返回栈,用户从游戏页按返回键不会又回到欢迎页。removeCallbacksAndMessages(null)清掉所有延时消息,防止页面销毁后跳转。模式选择不要用静态变量传,进程被回收后静态值会丢,Intent更稳。汽车主题如果只是换图片,建议把数字和图片的映射单独放一个ThemeResolver,别在onDraw里写一堆if (mode == 1)。

3.2 主界面布局与卡片绘制

2048 的棋盘最适合用自定义 View 画,4×4 格子、圆角矩形、数字居中,用Canvas控制起来比GridView更直接。论文里“卡片的实现”通常就是这一层。核心是onDraw里遍历二维数组,空格画浅色背景,有数字的格子画深色背景和文字。颜色映射要按数值分档,2、4、8、16 一直到 2048,越大越深,文字颜色在浅色格子上用深色、深色格子上用白色,保证对比度。Paint不要在onDraw里反复new,构造器里初始化好,onDraw只改颜色和文字。

@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cell = getWidth() / 4f; // 每个格子的边长 float gap = cell * 0.06f; // 格子间距 float radius = cell * 0.12f; // 圆角半径 for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { float left = c * cell + gap; float top = r * cell + gap; float right = left + cell - gap * 2; float bottom = top + cell - gap * 2; paint.setColor(colorFor(board[r][c])); canvas.drawRoundRect(left, top, right, bottom, radius, radius, paint); if (board[r][c] != 0) { paint.setColor(textColorFor(board[r][c])); paint.setTextSize(cell * 0.34f); paint.setTextAlign(Paint.Align.CENTER); // 用 FontMetrics 让文字垂直居中,不要直接减一个固定值 Paint.FontMetrics fm = paint.getFontMetrics(); float centerY = (top + bottom) / 2 - (fm.ascent + fm.descent) / 2; canvas.drawText(String.valueOf(board[r][c]), (left + right) / 2, centerY, paint); } } } }

cell是格子边长,gap是格子与格子之间的留白,radius控制圆角。colorFor根据数值返回背景色,textColorFor返回文字色,两个方法用switch或数组映射都行。文字垂直居中用FontMetrics的ascent和descent算基线,直接写centerY = (top + bottom) / 2会偏上。Paint.Align.CENTER让drawText的 x 坐标变成文字中心,不用再手动减文字宽度。onDraw里不要做分配对象、读文件、查数据库这类操作,否则滑动时掉帧很明显。

3.3 手势检测与滑动方向判定

2048 的交互核心是四个方向滑动。常见做法是给GameView设OnTouchListener,或者重写onTouchEvent,把事件交给GestureDetector。onFling只会在一次快速滑动结束时触发一次,适合做“一次滑动一次移动”;onScroll会连续回调,如果在那里直接调用移动,手指还没抬起棋盘就可能动了好几步。方向判定先比较横向位移dx和纵向位移dy的绝对值,横向大就看左右,纵向大就看上下,再配一个最小阈值,防止手指轻微抖动被识别成滑动。

private static final int SWIPE_THRESHOLD = 80; // 像素阈值,按屏幕密度换算更稳 private final GestureDetector detector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() { @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float vx, float vy) { if (e1 == null || e2 == null) return false; float dx = e2.getX() - e1.getX(); float dy = e2.getY() - e1.getY(); // 先判断主轴,再判断正负方向 if (Math.abs(dx) > Math.abs(dy)) { if (dx > SWIPE_THRESHOLD) move(Direction.RIGHT); else if (-dx > SWIPE_THRESHOLD) move(Direction.LEFT); } else { if (dy > SWIPE_THRESHOLD) move(Direction.DOWN); else if (-dy > SWIPE_THRESHOLD) move(Direction.UP); } return true; } }); @Override public boolean onTouchEvent(MotionEvent event) { // 交给 GestureDetector 处理,自己不要重复判断方向 return detector.onTouchEvent(event) || super.onTouchEvent(event); }

SWIPE_THRESHOLD是滑动阈值,80 像素在低密度屏上偏大,在高密度屏上偏小,实际项目里用TypedValue.applyDimension把 dp 转成 px 更稳。dx、dy是手指起点到终点的位移,正数分别代表右和下。vx、vy是速度,可以用来过滤慢速拖拽,但核心方向判断看位移就够。onTouchEvent里直接返回detector.onTouchEvent(event)会让 View 消费事件,如果外层还有滚动容器,要处理事件冲突;2048 全屏自绘一般没有嵌套滚动,问题不大。滑动后如果棋盘没变化,不要生成新数字,也不要加分,这个判断放在move里做。

4. 核心算法拆解:4×4 棋盘的移动、合并与随机生成

4.1 棋盘数据结构与状态表示

棋盘用int[4][4]最直观,0 表示空格,2、4、8 这些表示数字。也有工程用int[16]一维数组,移动时通过索引换算,性能差别可以忽略,二维数组可读性更好。论文里“算法设计”一般会写“初始化、移动、合并、判负”,落地时对应initBoard、move、mergeLine、isGameOver。初始化时清空数组,随机放两个数字,常见概率是 90% 出 2、10% 出 4,也可以 80/20,看手感。随机数用Random,不要每次new,放在成员变量里复用。

private final int[][] board = new int[4][4]; private final Random random = new Random(); private int score = 0; private void initBoard() { for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { board[r][c] = 0; } } score = 0; addRandomTile(); addRandomTile(); }

board是棋盘状态,score是当前得分,random复用避免频繁创建。initBoard先把所有格子清零,再放两个随机数字,这是 2048 的标准开局。棋盘状态最好只在一个地方改,绘制、存档、判负都读同一份数据,别在onDraw里临时算一份。如果要做撤销,就在每次有效移动前把board深拷贝一份压栈,栈深度别太大,10 步左右够用。

4.2 单行合并算法

合并算法是 2048 的核心,最容易翻车的地方是“一次滑动里同一个方块被连续合并”。正确顺序是:先把非零数字向滑动方向压缩,再从左到右扫描相邻相同项合并,合并后跳过下一个位置,最后再压缩一次去掉中间产生的 0。比如一行[2, 2, 2, 2]向左滑,结果是[4, 4, 0, 0],不是[8, 0, 0, 0];[4, 2, 2, 4]向左滑,结果是[4, 4, 4, 0],新生成的 4 不能和右边的 4 再合并。这个规则写错,测试时很容易出现“数字穿墙”或者分数异常。

private boolean moveLine(int[] line) { int[] compact = new int[line.length]; int idx = 0; // 第一步:去掉 0,把非零数字往左压 for (int v : line) { if (v != 0) compact[idx++] = v; } boolean moved = false; int[] merged = new int[line.length]; idx = 0; // 第二步:相邻相同合并,合并后跳过下一个位置 for (int i = 0; i < compact.length; i++) { if (i + 1 < compact.length && compact[i] != 0 && compact[i] == compact[i + 1]) { int value = compact[i] * 2; merged[idx++] = value; score += value; moved = true; i++; // 关键:跳过被合并的下一项,避免连续合并 } else if (compact[i] != 0) { merged[idx++] = compact[i]; } } // 第三步:把合并结果写回原行,判断是否有变化 for (int i = 0; i < line.length; i++) { if (line[i] != merged[i]) moved = true; line[i] = merged[i]; } return moved; }

line是某一行或某一列按滑动方向抽出来的数组,长度固定 4。compact存压缩后的非零数字,merged存合并后的结果。score += value在合并发生时加分,加的是新生成数字的值。i++是防止连续合并的关键,少了这一行,[2, 2, 2, 2]可能变成[8, 0, 0, 0]。最后的写回循环同时判断moved,只有真正发生变化才生成新数字,否则用户滑一下没反应却多出一个 2,体验很怪。

四个方向不用写四套算法,常见做法是把棋盘按方向抽成“从左到右”的一行,统一调用moveLine,再写回棋盘。向左抽正常行,向右抽反转行,向上抽正常列,向下抽反转列。这样合并逻辑只有一份,改规则时不会漏方向。

public boolean move(Direction dir) { int[][] old = copy(board); for (int i = 0; i < 4; i++) { int[] line = extractLine(i, dir); // 按方向抽取一行或一列 moveLine(line); // 统一按“向左”处理 writeLine(i, dir, line); // 按方向写回 } return !Arrays.deepEquals(old, board); }

extractLine和writeLine要成对处理方向,Direction.LEFT正序取行,RIGHT逆序取行再反转写回,UP正序取列,DOWN逆序取列。Arrays.deepEquals用来判断整个棋盘有没有变化,比逐格比较省事。这个结构改起来很舒服,比如加一个“向上合并优先”的变体,只动extractLine的顺序就行。

4.3 随机落子、得分与游戏结束判定

每次有效移动后随机在空格里放一个数字,先收集所有空格坐标,再随机选一个,不要用“随机行列直到碰到空格”的写法,棋盘快满时可能循环很多次。得分在合并时累加,最高分用SharedPreferences存,退出再进还能看到。游戏结束判定不是“没有空格就结束”,还要检查相邻格子有没有相同数字,有相同数字就还能合并,没空格但能合并时不能弹结束框。

private void addRandomTile() { List<Point> empty = new ArrayList<>(); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (board[r][c] == 0) empty.add(new Point(r, c)); } } if (empty.isEmpty()) return; Point p = empty.get(random.nextInt(empty.size())); // 90% 出 2,10% 出 4 board[p.x][p.y] = random.nextFloat() < 0.9f ? 2 : 4; } private boolean canMove() { for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (board[r][c] == 0) return true; if (r < 3 && board[r][c] == board[r + 1][c]) return true; if (c < 3 && board[r][c] == board[r][c + 1]) return true; } } return false; }

empty存所有空格坐标,random.nextInt(empty.size())均匀选一个,nextFloat() < 0.9f控制出 2 的概率。canMove先看有没有空格,再看右边和下边有没有相同数字,只需要检查两个方向,因为左边和上边会被前一行前一列检查到。游戏结束时弹一个AlertDialog或Toast,把当前得分和最高分一起显示,重新开始就再调一次initBoard。

最高分保存用SharedPreferences就够,键名固定,读的时候给默认值 0,写的时候用apply()异步提交。别在主线程里commit()等磁盘,虽然数据量小,但习惯不好。

private static final String SP_NAME = "game2048"; private static final String KEY_BEST = "best_score"; private void saveBestScore() { SharedPreferences sp = getSharedPreferences(SP_NAME, MODE_PRIVATE); int best = sp.getInt(KEY_BEST, 0); if (score > best) { sp.edit().putInt(KEY_BEST, score).apply(); } }

SP_NAME是文件名,KEY_BEST是键,MODE_PRIVATE表示只有当前应用能读。getInt的第二个参数是默认值,第一次安装没有存过就返回 0。apply()异步写,适合这种小数据;如果要做严格同步,再用commit(),但不要在主线程频繁调用。

5. 避坑与排查:ADT 老工程、触摸冲突和合并顺序的常见问题

5.1 环境与构建类问题

现象:Eclipse 导入工程后满屏红叉,R.java不生成,setContentView里的R.layout.xxx找不到。原因通常是project.properties里的target跟本机 SDK 对不上,或者res目录里有文件名大写、图片损坏、XML 标签不合法,导致资源编译失败,R.java自然不生成。解决顺序是先看 Eclipse 的Problems和Console,把资源错误修掉;再右键工程Clean;然后确认project.properties的target=android-xx是本机已安装的 API;最后检查AndroidManifest.xml的package和src下包名是否一致。别一上来就重装 ADT,大多数时候是资源文件的问题。

现象:应用在旧模拟器上能跑,装到新手机上点图标闪退,Logcat 报android:exported相关异常。原因是新版本 Android 要求带intent-filter的 Activity 显式声明android:exported,老工程没写。解决是给启动 Activity 加android:exported="true",其他内部 Activity 加android:exported="false"。如果还闪退,继续看 Logcat 第一行,可能是targetSdkVersion太高触发权限或存储路径变化,先把targetSdkVersion降到工程能跑的级别,再逐项适配。这个坑在迁移老项目时很常见,提前改清单文件能省很多时间。

5.2 交互与算法类问题

现象:手指滑一下,棋盘连续移动好几次,或者斜着滑方向乱跳。原因通常是把移动逻辑写在onScroll里,onScroll会随着手指移动连续回调;另一个原因是方向判断只看了dx或dy的绝对值,没有比较主轴,斜滑时先触发横向又触发纵向。解决是用onFling做一次性判定,设置合理的滑动阈值,先比较Math.abs(dx)和Math.abs(dy)决定主轴,再判断正负。阈值建议用 dp 转 px,不要写死像素。如果外层有ScrollView,还要处理事件拦截,2048 全屏自绘一般不用。

现象:[2, 2, 2, 2]向左滑变成[8, 0, 0, 0],或者合并后的数字继续和旁边的数字合并,分数一次加很多。原因是合并循环里没有跳过被合并的下一项,新生成的数字又被当成普通数字参与下一次比较。解决是合并后i++,并且严格按照“先压缩、再合并、再压缩”的顺序写。测试时拿几个固定用例回归:[2,2,2,2]、[4,2,2,4]、[2,0,2,4]、[4,4,8,8],四个方向各走一遍。这个 bug 很玄学,肉眼看不出来,必须用固定棋盘测。

5.3 数据与测试类问题

现象:退出游戏再进来,最高分没了;或者游戏结束弹窗显示的最高分还是上一局的。原因是分数只存在内存变量里,没有在onPause或合并时写入SharedPreferences;另一种情况是读取最高分的时机太早,界面还没初始化完就去更新TextView。解决是在onPause里统一保存,在onCreate里先读最高分再设置文本;合并加分后只改内存,退出时落盘。测试时用adb shell清一次应用数据,再跑一局,确认首次安装默认值是 0,然后杀进程重进,看最高分是否还在。别用commit()在主线程频繁写,小数据用apply()就够。

测试老工程还有一个实用手段是看 Logcat。Eclipse 的 Logcat 面板可以按tag过滤,自定义 View 里打上Log.d("GameView", "move=" + dir),滑动时看有没有重复触发;合并算法里打一行Log.d("GameLogic", Arrays.toString(line)),对比输入输出。日志别留在正式包里,调完删掉或者用if (BuildConfig.DEBUG)包起来。血泪经验是:先保证欢迎页能进游戏页,再保证滑动有反应,最后才调合并和分数,顺序乱了会在环境问题上耗掉大半天。

6. 二次开发与改造:从论文 Demo 到可维护的小游戏

这套工程的改造空间主要在主题、存档和算法回归三块。主题皮肤不要直接改onDraw里的颜色常量,抽一个ThemeResolver,按模式返回背景色、文字色和卡片图片资源,数字模式和汽车主题各一套映射,换皮肤只动这个类。模式扩展也一样,Intent里多传一个mode,资源按前缀区分,比如car_2.png、car_4.png,找不到图就回退到颜色块,别让游戏因为缺资源崩掉。存档方面,最高分用SharedPreferences,棋盘快照可以序列化成字符串,键名带上版本号,后面改数据结构时能兼容旧数据。

改造点改哪里注意
换主题皮肤colorFor、textColorFor、drawable 映射数字和背景对比度要够
加撤销每次有效移动前深拷贝board栈深度别太大,10 步左右
存档棋盘SharedPreferences存序列化字符串加版本号,旧数据能回退
改合并规则moveLine和方向抽取四个方向都要回归

我一般先改颜色映射,再动合并算法,因为颜色改错一眼能看出来,算法改错要跑好几局才发现。每次动moveLine,先把四个方向的固定用例跑一遍,再拿一局能到 512 分的棋盘做回归,确认分数和空格生成都正常。从那以后我每次改 2048 的合并逻辑,都会先把[2,2,2,2]和[4,2,2,4]这两个用例走一遍,再上真机滑几次;这个习惯比事后看 Logcat 找后悔药管用。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进