从零构建2048安卓游戏:MVC架构、自定义View与核心算法详解
2026/9/4 4:23:43 网站建设 项目流程

简介:本资源是一份基于Android Studio开发的2048小游戏完整安卓项目源码,专为计算机专业本科生课程设计与期末大作业打造,面向Android开发初学者及移动应用实践教学场景,有效解决课程项目选题难、代码实现弱、注释缺失等常见痛点。压缩包共141个文件,含21个核心Java逻辑类(如GameActivity、Board、Tile等)、50个XML布局与资源文件(涵盖主界面、分数栏、动画配置等)、30个WebP图标素材,以及Gradle构建脚本、Git配置和Windows/Linux启动脚本等工程必需文件,整体仅394KB,轻量易部署。已有268人学习下载,项目获导师高度认可,获评98分高分作业。源码全程手写,关键逻辑均配有中文注释,包含完整的滑动合并算法、分数统计、游戏状态管理及响应式UI适配,目录结构规范,模块职责清晰,开箱即用,是理解Android事件处理、自定义View与MVC架构的优质实践范例。

1. 项目缘起与核心价值:从“大作业”到“完整项目”的跨越

又到了期末,或者说是课程设计的季节。如果你是计算机、软件工程或者相关专业的学生,大概率会收到一个“基于Android Studio开发一个安卓应用”的大作业任务。选题五花八门,但“2048”绝对是其中经久不衰的经典之一。它规则简单,逻辑清晰,界面也不复杂,看起来像是“几天就能搞定”的练手项目。但当你真正打开Android Studio,新建一个Empty Activity时,可能会瞬间陷入迷茫:游戏逻辑怎么写?界面怎么画?数据怎么存?高分榜怎么做?一个看似简单的“大作业”,背后其实是一个完整的、麻雀虽小五脏俱全的安卓应用开发流程。

我当年也做过类似的作业,后来在带实习生和辅导学弟学妹的过程中,更是见过无数个“半成品”的2048。常见的问题包括:滑动合并算法有bug导致数字错乱、界面在旋转屏幕后状态丢失、游戏结束后无法重新开始、代码结构一团糟毫无扩展性可言。所以,今天我想分享的,不仅仅是一个能运行的2048源代码,而是一套从零开始,构建一个结构清晰、功能完整、易于理解和扩展的2048安卓应用的完整思路与实现方案。这个方案,足以让你的大作业从“及格”跃升到“优秀”,甚至能成为你简历上一个不错的项目经历。

我们将使用Kotlin语言(这是目前安卓开发的官方首选和未来趋势)和Android Studio最新稳定版进行开发。整个项目会涵盖MVC架构设计、自定义View绘制、触摸事件处理、游戏核心算法、本地数据持久化等关键知识点。你会发现,完成一个2048,几乎把安卓应用开发的基础核心都过了一遍。下面,我们就从项目的顶层设计开始,一步步拆解。

2. 架构设计与项目结构:告别“面条式”代码

拿到一个项目,最忌讳的就是打开MainActivity.kt就开始埋头写所有代码。这种“面条式”代码在初期可能进展很快,但一旦需要修改功能(比如加个撤销步骤)或者调试bug,就会变成一场灾难。对于游戏这类带有明显状态和视图交互的应用,采用一个清晰的架构模式至关重要。这里我推荐使用经典的MVC(Model-View-Controller)模式,它概念清晰,非常适合中小型项目理解和解耦。

我们的项目结构会这样组织:

com.yourcompany.game2048 ├── model │ ├── GameBoard.kt // 数据模型:棋盘数据、数字方块 │ └── GameState.kt // 游戏状态:分数、是否结束等 ├── view │ └── GameBoardView.kt // 自定义视图:负责绘制棋盘和方块 ├── controller │ └── GameController.kt // 控制器:处理滑动逻辑、游戏规则 └── ui ├── MainActivity.kt // 主Activity,协调MVC └── ScoreFragment.kt // (可选)用于显示历史记录的Fragment

为什么选择MVC而不是MVVM?对于这个体量的游戏,MVC完全够用且更直观。Model负责管理游戏数据(棋盘上的数字矩阵),View负责将数据画出来(屏幕上的彩色格子),Controller负责处理用户的输入(手指滑动)并更新Model。三者职责分离,MainActivity主要扮演初始化者和协调者的角色。这样的结构,让单元测试成为可能(比如单独测试GameController的合并算法),也让代码阅读和维护变得轻松。

GameBoard.kt中,我们用一个4x4的二维数组Array<Array<Int>>来存储棋盘数据,0代表空位。同时,可以定义一个Cell数据类,除了数值value,还可以包含位置row, col以及未来可能需要的动画状态等属性。GameState.kt则是一个简单的数据类,记录当前分数score、历史最高分bestScore以及游戏是否结束isGameOver

注意:很多初学者喜欢把棋盘数据直接放在ActivityView里,这会导致数据与视图强耦合。一旦你想把游戏改成6x6的棋盘,或者需要将游戏状态保存到本地,就会非常麻烦。从一开始就建立清晰的Model,是专业性的体现。

3. 核心战场:自定义GameBoardView的实现细节

安卓系统提供的标准控件(Button、TextView)无法满足我们绘制彩色动态方块的需求,因此必须自定义一个GameBoardView。这是本项目在UI层面的核心,也是理解安卓绘图系统CanvasPaint的好机会。

3.1 测量与布局:让View自适应

首先,我们的棋盘应该是正方形的,并且能够适应不同尺寸的手机屏幕。这需要在onMeasure方法中进行处理。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec) val height = MeasureSpec.getSize(heightMeasureSpec) val size = width.coerceAtMost(height) // 取宽高中的较小值,保证正方形 setMeasuredDimension(size, size) }

这样,无论布局文件里给GameBoardView设定的是match_parent还是固定值,它最终都会呈现为一个完美的正方形,避免了拉伸变形。

3.2 绘制棋盘与方块:Canvas与Paint的运用

绘制工作在onDraw(canvas: Canvas)方法中完成。我们需要绘制两部分:背景网格和数字方块。

绘制背景网格:这相对简单。先计算每个格子的边长cellSize = width / 4。然后使用Paint对象设置一个浅灰色的背景色和线条宽度,用canvas.drawLine()画出横竖各5条线(4个格子需要5条线来分隔)。

绘制数字方块:这是重点和难点。我们需要遍历Model中的棋盘数据矩阵,对于每个非零的值,绘制一个圆角矩形,并在中间绘制对应的数字。

  1. 方块背景色:2048游戏的一个视觉特色是不同数字对应不同的颜色。我们可以预先定义一个颜色映射表。

    private val cellColorMap = mapOf( 2 to Color.parseColor("#EEE4DA"), 4 to Color.parseColor("#EDE0C8"), 8 to Color.parseColor("#F2B179"), // 橙色 16 to Color.parseColor("#F59563"), 32 to Color.parseColor("#F67C5F"), 64 to Color.parseColor("#F65E3B"), 128 to Color.parseColor("#EDCF72"), 256 to Color.parseColor("#EDCC61"), 512 to Color.parseColor("#EDC850"), 1024 to Color.parseColor("#EDC53F"), 2048 to Color.parseColor("#EDC22E") ) // 获取颜色:cellColorMap[value] ?: Color.GRAY

    使用圆角矩形canvas.drawRoundRect(rectF, cornerRadius, cornerRadius, paint)来绘制,看起来比直角更现代。

  2. 数字绘制:数字的颜色(深色)需要与背景色有足够对比度。通常,数值小于8时用深灰色,大于等于8时用白色。文字的大小需要根据格子大小动态计算,并且要使用Paint.setTextAlign(Paint.Align.CENTER)Paint.getTextBounds()来让文字完美居中。

    fun drawTextCentered(canvas: Canvas, text: String, cx: Float, cy: Float, paint: Paint) { val textBounds = Rect() paint.getTextBounds(text, 0, text.length, textBounds) val textHeight = textBounds.height() canvas.drawText(text, cx, cy + textHeight / 2, paint) }

    这里的cx, cy是格子中心的坐标。这个居中的细节处理不好,文字就会偏上或偏下,非常影响美观。

实操心得:onDraw中要尽量避免创建新对象(如new RectF(),new Paint()),因为onDraw会被频繁调用。正确的做法是在View初始化时(如init块中)创建好Paint等对象并复用。对于每个方块的RectF,可以在每次绘制前根据其行列号重新计算,这是一个轻量级操作。

4. 游戏逻辑的灵魂:滑动合并算法详解

这是2048游戏最核心的部分,也是算法面试的常客。逻辑必须严谨,否则会出现各种诡异的bug。算法的输入是当前棋盘状态和滑动方向(上、下、左、右),输出是新的棋盘状态、本次滑动新增的分数以及棋盘是否发生了变化(用于判断是否需要生成新数字)。

我们以“向左滑动”为例,分解算法步骤。其他三个方向原理相同,只是遍历的顺序和方向不同。

核心思想:对每一行单独处理。对于左滑,我们处理每一行(row)。

步骤1:移除空格(压缩)遍历该行的4个格子,将所有非零的数字依次移动到行的左侧,右侧留空。 例如:[2, 0, 4, 2]->[2, 4, 2, 0]

步骤2:合并相邻相同数字从左到右遍历压缩后的行,如果当前格子[i]的值与下一个格子[i+1]的值相同且均不为零,则将[i]的值翻倍,[i+1]置零,并将翻倍后的值累加到本次滑动的得分中,然后跳过[i+1](因为已经合并了)。 接上例:[2, 4, 2, 0],首先看2和4不同,跳过;然后看4和2不同,跳过;最后看2和0,不合并。所以这行没有合并发生。 再看一个例子:[2, 2, 4, 4]-> 压缩后为[2, 2, 4, 4]-> 合并:第一个2和第二个2合并为4,第三个4和第四个4合并为8。注意,合并一次后,产生的4不应再与后面的4合并(即“一次滑动中,每个格子只能参与一次合并”)。所以中间过程是:[4, 0, 8, 0]

步骤3:再次压缩合并后,行中间可能又出现了空格(如上例中的0),需要再次执行步骤1的压缩操作,让所有数字靠左。[4, 0, 8, 0]->[4, 8, 0, 0]

将以上三步封装成一个函数processRow(row: Array<Int>): Pair<Array<Int>, Int>,它接收一行原始数据,返回处理后的新行数据和本次行合并获得的分数。

步骤4:应用到整个棋盘对于“向左滑动”,我们对棋盘的每一行调用processRow函数。 对于“向右滑动”,只需先将每一行反转,调用processRow处理,然后再将结果反转回来。 对于“向上滑动”,需要将棋盘矩阵“转置”,然后对每一行(即原矩阵的每一列)进行左滑处理,最后再转置回去。 对于“向下滑动”,转置后对每一行进行右滑处理,再转置回去。

步骤5:判断棋盘是否变化比较滑动前后的棋盘矩阵。如果发生了变化,则在随机的一个空位(值为0的位置)生成一个新的数字(90%概率为2,10%概率为4)。如果没变化,则本次滑动无效,不生成新数字。

步骤6:检查游戏状态生成新数字后,必须立即检查游戏是否结束。游戏结束有两个条件:1. 棋盘已满(没有0);2. 棋盘已满且任意相邻(上下左右)的格子数字都不相同。需要编写一个checkGameOver()函数来遍历判断。

class GameController(private var board: Array<Array<Int>>) { fun move(direction: Direction): MoveResult { val oldBoard = deepCopy(board) // 深拷贝旧棋盘用于比较 var totalScore = 0 // ... 根据方向应用上述算法,更新board和totalScore ... val changed = !oldBoard.contentDeepEquals(board) val generatedCell: Cell? = if (changed) generateRandomCell() else null val isOver = if (changed) checkGameOver() else false return MoveResult(changed, totalScore, generatedCell, isOver) } }

避坑指南:这里最大的坑在于“深拷贝”。board是一个二维数组,直接使用board.copyOf()=赋值只是浅拷贝,内外层数组的引用是一样的,导致比较changed时永远为false。必须手动或使用序列化进行深拷贝。另一个常见bug是在合并算法中,没有正确处理“一次滑动只合并一次”的规则,导致出现[4,4,8]滑成[16]而不是[8,8]的情况。务必用多组测试用例验证你的算法。

5. 交互与动效:让游戏“活”起来

一个只有静态画面的游戏是枯燥的。我们需要处理用户的触摸滑动,并添加一些简单的动画来提升体验。

5.1 触摸事件处理

GameBoardView中重写onTouchEvent(event: MotionEvent): Boolean方法。我们不需要处理复杂的多指手势,只需识别基本的滑动方向。一个经典的实现方式是:

  1. ACTION_DOWN:记录手指按下的初始坐标startX, startY
  2. ACTION_UP:记录手指抬起的坐标endX, endY。计算差值dx = endX - startX,dy = endY - startY
  3. 判断方向:取dxdy的绝对值,如果两者都小于一个最小滑动阈值(如10像素),则视为点击,忽略。如果Math.abs(dx) > Math.abs(dy),则为水平滑动(左或右),否则为垂直滑动(上或下)。再根据差值的正负决定具体方向。

识别出方向后,调用GameControllermove方法,并将结果传递给Activity来更新Model和View。

override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { startX = event.x startY = event.y return true } MotionEvent.ACTION_UP -> { val endX = event.x val endY = event.y val dx = endX - startX val dy = endY - startY if (Math.abs(dx) < MIN_SWIPE_DIST && Math.abs(dy) < MIN_SWIPE_DIST) { return false // 点击,不处理 } val direction = if (Math.abs(dx) > Math.abs(dy)) { if (dx > 0) Direction.RIGHT else Direction.LEFT } else { if (dy > 0) Direction.DOWN else Direction.UP } onSwipeListener?.onSwipe(direction) // 回调给Activity return true } } return super.onTouchEvent(event) }

5.2 方块生成与合并动画

虽然实现完整的平滑移动动画涉及属性动画和更复杂的状态管理,但对于大作业,我们可以先实现两种简单的动画来增强反馈感:

  1. 新方块生成动画(缩放):当新数字生成时,对应的格子可以从0.5倍大小缩放到1倍。这可以通过ValueAnimator修改一个缩放比例属性,并在onDraw中根据这个比例绘制方块来实现。
  2. 合并动画(脉冲):当两个方块合并时,合并后的新方块可以有一个简单的“脉冲”效果,比如颜色短暂变亮再恢复。这可以通过ValueAnimator修改颜色值或透明度来实现。

经验之谈:动画不是必须的,但加上后体验提升巨大。如果时间有限,优先实现新方块生成的缩放动画,因为它最简单且效果明显。实现动画时,一定要在View中维护好动画状态,并在onDraw中根据这些状态进行绘制,最后记得在onDetachedFromWindow中取消所有动画,防止内存泄漏。

6. 数据持久化与游戏状态管理

游戏不能每次打开都从零开始。我们需要保存两项核心数据:当前游戏棋盘状态历史最高分

6.1 使用SharedPreferences存储最高分

最高分是一个简单的整数,非常适合用SharedPreferences存储。

object ScoreManager { private const val PREFS_NAME = "game_2048_prefs" private const val KEY_BEST_SCORE = "best_score" fun getBestScore(context: Context): Int { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return prefs.getInt(KEY_BEST_SCORE, 0) } fun saveBestScore(context: Context, score: Int) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putInt(KEY_BEST_SCORE, score).apply() } }

在游戏结束时,比较当前分数和最高分,如果更高则更新并保存。

6.2 保存与恢复游戏状态

保存完整的棋盘状态(4x4矩阵)和当前分数,以便下次进入游戏时能继续。这同样可以用SharedPreferences,但需要将二维数组序列化为字符串(如JSON格式)。更规范的做法是使用ViewModel配合SavedStateHandle,或者直接使用Room数据库。对于2048,我们折中一下,用JSON序列化到SharedPreferences

  1. 序列化与反序列化:使用Gson或Kotlin自带的kotlinx.serialization库,将棋盘数据List<List<Int>>和分数等转换成一个JSON字符串进行存储和读取。
  2. 保存时机:在游戏棋盘发生变化时(每次有效移动后),以及应用退到后台时(在ActivityonPause()中),都应该自动保存当前状态。
  3. 恢复时机:在ActivityonCreate()中,尝试读取保存的状态。如果存在,则恢复棋盘和分数;如果不存在,则初始化一个新游戏。
// 简化示例,使用Gson val gson = Gson() val boardJson = gson.toJson(gameBoard.cells) // 假设cells是List<List<Int>> prefs.edit().putString(KEY_GAME_STATE, boardJson).apply()

注意事项:不要保存isGameOver状态。否则,用户上次玩到游戏结束并退出,下次打开直接就是一个结束界面,体验很差。正确的逻辑是:恢复棋盘和分数后,立即检查恢复后的棋盘是否已经结束,如果结束了,就显示结束对话框,但棋盘状态依然保留供用户查看。

7. 功能扩展与界面美化:让项目脱颖而出

完成核心功能后,你的大作业已经可以拿到一个不错的分数了。但如果想冲击高分或让项目更出彩,可以考虑以下扩展点:

7.1 添加游戏控制按钮与菜单

在界面底部或侧边栏添加按钮:“新游戏”、“撤销一步”、“音效开关”。实现“撤销”功能需要用一个栈(Stack)来保存每一步移动前的棋盘状态和分数,点击撤销时弹出状态并恢复。这能很好地体现你对数据结构的应用。

7.2 实现数字方块的颜色动态渐变

之前的颜色映射是固定的。可以尝试实现动态渐变,比如数值为8的方块,其颜色可以根据一个主题色动态计算出来,让用户能切换不同的主题皮肤。这涉及到颜色的HSV/HSL空间计算,是一个不错的加分点。

7.3 集成简单的音效与震动

在方块移动、合并、游戏胜利或失败时,添加简短的音效和手机震动反馈。可以使用SoundPool播放短促音效,使用Vibrator实现震动(注意权限声明)。这些细节能极大提升游戏的沉浸感和完成度。

7.4 响应式布局与主题适配

确保你的游戏界面在横屏模式下也能正常显示(可以考虑横屏时调整布局)。同时,为GameBoardView实现深色模式适配,根据系统的主题设置自动切换棋盘和数字的颜色。

我在实际完成这个项目时,最大的体会是:架构清晰远比赛场效应重要。最初我为了快速实现,把所有代码都写在Activity里,结果在添加“撤销”功能时几乎重写了所有逻辑。后来用MVC重构后,添加新功能就像搭积木一样简单。另一个深刻的教训是关于状态保存,最初我忘了处理屏幕旋转,一横屏游戏状态就全丢了,后来通过ViewModelonSaveInstanceState才彻底解决。最后,测试一定要充分,尤其是滑动合并算法,要用各种边界情况(如满盘、连续合并等)去验证,否则在演示时出现bug会非常尴尬。把这个项目做扎实,你收获的不仅仅是一个大作业的分数,更是一套完整的、可复用的安卓开发实战经验。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询