尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Android Studio实战:从零构建2048游戏,掌握MVC架构与自定义View

Android Studio实战:从零构建2048游戏,掌握MVC架构与自定义View 简介本资源是一份基于Android Studio开发的2048小游戏完整源码项目专为安卓开发初学者及课程设计、期末大作业需求者打造有效解决从零实现经典逻辑游戏的学习与实践难题。压缩包共141个文件含21个Java核心业务类如GameActivity、BoardManager、50个XML布局与资源定义文件、30个WebP格式图标素材以及Gradle构建脚本、Git配置和Windows启动脚本等结构规范、模块清晰总大小仅394KB轻量易部署。已有268人学习下载项目获导师高度认可获评98分高分作业代码全程中文注释覆盖游戏主逻辑、滑动合并、分数计算、胜负判定及UI响应等关键环节开箱即用无需额外配置即可在模拟器或真机运行。1. 项目概述从零到一一个安卓开发者的2048实战如果你是一名计算机或软件工程专业的学生或者是一个刚刚踏入安卓开发领域的初学者那么“基于Android Studio实现的2048小游戏”这个题目对你来说绝对不陌生。它几乎是国内高校安卓开发课程大作业的“常青树”也是无数开发者入门时第一个想动手实现的、兼具趣味性和挑战性的小项目。我当年也是从这个项目开始一步步摸清了Android Studio的脾气理解了MVC或MVVM架构的皮毛体会了从代码编译成功到游戏流畅运行的喜悦与踩坑。今天我就以一个过来人的身份和你从头到尾拆解一遍这个项目的实现不止是给你一份能运行的源代码更重要的是分享那些教科书和官方文档里不会写的“实战心得”和“避坑指南”。这个项目的核心价值在哪里首先它麻雀虽小五脏俱全。一个完整的2048游戏几乎涵盖了安卓基础开发的多个核心模块Activity/Fragment的生命周期管理、自定义View的绘制与触摸事件处理、数据模型Model与界面View的交互逻辑、SharedPreferences或数据库用于本地数据持久化保存最高分、游戏状态以及动画效果的实现。其次它的业务逻辑清晰但富有挑战性。滑动合并数字的算法是考察你逻辑思维和代码组织能力的绝佳试金石。最后它成果可视趣味性强能给你带来即时的正向反馈这是坚持学习下去的巨大动力。所以无论你是为了完成课程作业还是想通过一个完整项目练手入门这篇内容都将为你提供一条清晰的路径。我会假设你已经有最基础的Java/Kotlin语法知识和Android Studio的安装环境我们将聚焦于项目设计、关键代码实现和那些决定成败的细节。2. 项目整体设计与架构思路拆解在打开Android Studio新建项目之前花点时间思考整体架构是事半功倍的关键。直接埋头写代码很容易导致结构混乱后期添加功能比如撤销、动画、主题切换时寸步难行。2.1 为何选择MVC/MVP模式对于2048这类逻辑相对独立的小游戏我强烈推荐采用MVCModel-View-Controller或其变种MVPModel-View-Presenter模式。这能让你清晰地分离关注点。Model模型这是游戏的核心大脑。它不关心界面长什么样只负责维护游戏状态。具体包括一个4x4的二维数组int[][]或Card[][]来表示棋盘。当前分数score和历史最高分bestScore。游戏状态进行中、胜利、失败。核心算法处理上下左右滑动后棋盘数字的移动、合并与新增逻辑。提供接口供Controller/Presenter调用来改变状态并通知View更新。View视图负责一切用户能看到的东西。在安卓里这通常是一个自定义View比如GameView。根据Model中的数据绘制出4x4的棋盘、每个格子里的数字不同数字配不同颜色和字体大小。接收用户的触摸手势OnTouchListener将滑动的方向上、下、左、右传递给Controller。实现数字移动、合并、新数字出现时的简单动画如缩放、位移。Controller/Presenter控制器/表示器作为Model和View之间的桥梁。在我们的设计中通常由Activity或Fragment来扮演这个角色。初始化Model和View。监听View传来的用户输入滑动事件调用Model对应的算法方法如moveLeft()。接收Model状态改变的通知然后命令View重绘界面。处理游戏逻辑例如判断游戏是否结束并在结束时弹出对话框。实操心得很多新手会把所有逻辑都塞在Activity或自定义View里导致一个类上千行代码难以维护。坚持MVC分离即使初期觉得繁琐但当你想加一个“撤销”功能时你会感谢自己——只需要在Model里加一个栈来保存历史棋盘状态在Controller里加一个按钮监听View几乎不用动。2.2 核心算法设计滑动与合并这是整个项目的技术心脏。算法效率不是关键4x4的棋盘怎么算都快但逻辑的清晰和正确性至关重要。算法的核心思想是针对单一行或列进行操作然后推广到整个棋盘。以向左滑动为例对每一行单独处理去零将当前行中所有的非零数字按顺序紧密地移动到左边。例如[2, 0, 2, 4]去零后变为[2, 2, 4, 0]。合并相邻相同数字从左到右遍历去零后的行如果当前数字和下一个数字相同则将它们合并值相加下一个数字置零分数增加。例如[2, 2, 4, 0]合并后变为[4, 0, 4, 0]分数加4。再次去零合并操作可能产生新的零需要再次紧凑排列。[4, 0, 4, 0]再次去零后变为[4, 4, 0, 0]。生成新数字如果本次滑动确实导致了棋盘变化即滑动前和滑动后的棋盘状态不同则在一个随机的、值为零的位置上生成一个新的数字90%概率为210%概率为4。向上、向下、向右滑动原理完全相同只是操作的方向和对应的行/列不同。向下滑动可以看作将每一列“向下”看作“向左”然后对倒置后的列进行向左操作最后再倒置回来。注意事项合并操作的顺序很重要必须确保一次滑动中一个格子只能被合并一次。例如行[2, 2, 2, 2]向左滑动正确结果应该是[4, 4, 0, 0]而不是[8, 0, 0, 0]。这要求在合并时当两个格子合并后需要跳过后一个已合并的格子继续检查后续的格子。2.3 工具与依赖选型这是一个纯净的安卓原生项目原则上不需要引入任何第三方库用Android SDK自带的能力就足够了。这有助于你夯实基础。语言Java或Kotlin均可。Kotlin语法更简洁空安全特性好是现在的官方首选。但考虑到很多学校课程仍以Java为主本文示例代码将用Java但会指出Kotlin的优化点。UI绘制自定义ViewView或SurfaceView。对于2048这种刷新不极端频繁的游戏继承View并重写onDraw()方法在UI线程中绘制完全够用且更简单。数据持久化使用SharedPreferences来存储最高分和游戏状态棋盘、分数就足够了。它适合存储简单的键值对。动画使用ValueAnimator或ObjectAnimator来实现数字格子的移动、缩放和渐变动画能让游戏体验提升一个档次。3. 核心模块实现与代码详解接下来我们进入实战环节一步步构建核心模块。我会贴出关键代码并解释其意图。3.1 数据模型Model的实现我们创建一个GameModel类它是游戏的状态中心。import java.util.Random; public class GameModel { public static final int SIZE 4; private int[][] board; private int score; private int bestScore; private boolean isGameOver; private boolean isWin; private Random random; public GameModel() { board new int[SIZE][SIZE]; score 0; random new Random(); initGame(); } // 初始化游戏清空棋盘随机生成两个2 public void initGame() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { board[i][j] 0; } } score 0; isGameOver false; isWin false; addRandomTile(); addRandomTile(); } // 核心向左移动的算法其他方向类似 public boolean moveLeft() { int[][] oldBoard copyBoard(board); boolean moved false; for (int i 0; i SIZE; i) { // 1. 去零 int[] newRow removeZeros(board[i]); // 2. 合并 for (int j 0; j newRow.length - 1; j) { if (newRow[j] ! 0 newRow[j] newRow[j 1]) { newRow[j] * 2; score newRow[j]; // 加分 newRow[j 1] 0; if (newRow[j] 2048) { isWin true; // 达成2048游戏胜利可继续玩 } } } // 3. 再次去零 newRow removeZeros(newRow); // 4. 补零到长度4 board[i] padZeros(newRow, SIZE); } // 判断棋盘是否发生变化 moved !isBoardsEqual(oldBoard, board); if (moved) { addRandomTile(); checkGameOver(); } return moved; } // 辅助方法移除数组中的0 private int[] removeZeros(int[] row) { // ... 实现逻辑遍历row将非零元素放入新数组 } // 辅助方法用0填充数组到指定长度 private int[] padZeros(int[] row, int length) { // ... 实现逻辑 } // 辅助方法复制棋盘 private int[][] copyBoard(int[][] source) { // ... 实现逻辑 } // 辅助方法比较两个棋盘是否相同 private boolean isBoardsEqual(int[][] a, int[][] b) { // ... 实现逻辑 } // 在随机空位添加一个数字2或4 private void addRandomTile() { Listint[] emptyCells new ArrayList(); for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) { emptyCells.add(new int[]{i, j}); } } } if (!emptyCells.isEmpty()) { int[] pos emptyCells.get(random.nextInt(emptyCells.size())); board[pos[0]][pos[1]] (random.nextFloat() 0.9f) ? 2 : 4; } } // 检查游戏是否结束无空位且无法合并 private void checkGameOver() { // 1. 检查是否有空位 for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) { isGameOver false; return; } } } // 2. 检查是否还能合并检查每个格子的右侧和下方邻居 for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { int current board[i][j]; // 检查右侧 if (j SIZE - 1 current board[i][j 1]) { isGameOver false; return; } // 检查下方 if (i SIZE - 1 current board[i 1][j]) { isGameOver false; return; } } } // 既无空位也无法合并游戏结束 isGameOver true; } // Getter 方法 public int[][] getBoard() { return copyBoard(board); } public int getScore() { return score; } public int getBestScore() { return bestScore; } public void setBestScore(int best) { this.bestScore best; } public boolean isGameOver() { return isGameOver; } public boolean isWin() { return isWin; } }代码解读与避坑moveLeft方法返回一个boolean表示本次移动是否有效即棋盘是否发生了变化。这很重要只有有效移动才需要添加新方块和重绘界面。checkGameOver()的逻辑是判断游戏结束的关键必须同时满足“无空位”和“相邻无相同数字”两个条件。很多初学者会漏掉第二个条件。在合并时加分并判断是否达到2048。注意达到2048不一定结束游戏玩家可以选择继续挑战更高分所以isWin只是一个状态标志。重要getBoard()方法返回的是棋盘的深拷贝copyBoard而不是直接返回board引用。这是因为View或其他组件不应该直接修改Model的内部数据这违反了MVC原则也容易导致状态不一致的bug。3.2 游戏主视图GameView的实现GameView负责将GameModel中的数据绘制到屏幕上。我们需要计算每个格子的位置、大小、颜色和数字。import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.RectF; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; public class GameView extends View { private GameModel gameModel; private Paint gridPaint, cellPaint, textPaint; private float cellSize, gridPadding; private OnSwipeListener swipeListener; public interface OnSwipeListener { void onSwipeLeft(); void onSwipeRight(); void onSwipeUp(); void onSwipeDown(); } public GameView(Context context, AttributeSet attrs) { super(context, attrs); init(); } private void init() { gridPaint new Paint(); gridPaint.setColor(Color.GRAY); gridPaint.setStyle(Paint.Style.STROKE); gridPaint.setStrokeWidth(8); cellPaint new Paint(); cellPaint.setStyle(Paint.Style.FILL); textPaint new Paint(); textPaint.setColor(Color.BLACK); textPaint.setTextAlign(Paint.Align.CENTER); textPaint.setAntiAlias(true); } public void setGameModel(GameModel model) { this.gameModel model; invalidate(); // 请求重绘 } public void setOnSwipeListener(OnSwipeListener listener) { this.swipeListener listener; } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 计算每个格子的大小留出一些边距 gridPadding 20; float availableWidth w - 2 * gridPadding; float availableHeight h - 2 * gridPadding; cellSize Math.min(availableWidth, availableHeight) / GameModel.SIZE; // 根据格子大小调整文字大小 textPaint.setTextSize(cellSize * 0.5f); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (gameModel null) return; drawGrid(canvas); drawCells(canvas); } private void drawGrid(Canvas canvas) { float startX gridPadding; float startY gridPadding; // 画竖线 for (int i 0; i GameModel.SIZE; i) { float x startX i * cellSize; canvas.drawLine(x, startY, x, startY GameModel.SIZE * cellSize, gridPaint); } // 画横线 for (int i 0; i GameModel.SIZE; i) { float y startY i * cellSize; canvas.drawLine(startX, y, startX GameModel.SIZE * cellSize, y, gridPaint); } } private void drawCells(Canvas canvas) { int[][] board gameModel.getBoard(); // 获取当前棋盘状态 for (int i 0; i GameModel.SIZE; i) { for (int j 0; j GameModel.SIZE; j) { int value board[i][j]; if (value ! 0) { // 1. 根据数字值决定格子颜色 cellPaint.setColor(getCellColor(value)); // 2. 计算格子绘制区域 float left gridPadding j * cellSize 4; // 加4是为了让格子比网格线稍小有边框感 float top gridPadding i * cellSize 4; float right left cellSize - 8; float bottom top cellSize - 8; RectF rect new RectF(left, top, right, bottom); // 3. 绘制圆角矩形格子 canvas.drawRoundRect(rect, 8, 8, cellPaint); // 4. 绘制数字 String text String.valueOf(value); float centerX left cellSize / 2 - 4; float centerY top cellSize / 2 - (textPaint.ascent() textPaint.descent()) / 2; // 数字很大时调整文字大小 if (text.length() 3) { textPaint.setTextSize(cellSize * 0.3f); } canvas.drawText(text, centerX, centerY, textPaint); // 恢复默认文字大小 textPaint.setTextSize(cellSize * 0.5f); } } } } // 根据数字返回对应的颜色可以自定义一套漂亮的配色 private int getCellColor(int value) { switch (value) { case 2: return Color.parseColor(#EEE4DA); case 4: return Color.parseColor(#EDE0C8); case 8: return Color.parseColor(#F2B179); case 16: return Color.parseColor(#F59563); case 32: return Color.parseColor(#F67C5F); case 64: return Color.parseColor(#F65E3B); case 128: return Color.parseColor(#EDCF72); case 256: return Color.parseColor(#EDCC61); case 512: return Color.parseColor(#EDC850); case 1024: return Color.parseColor(#EDC53F); case 2048: return Color.parseColor(#EDC22E); default: return Color.parseColor(#3C3A32); // 大于2048的数字 } } // --- 触摸事件处理用于识别滑动方向 --- private float startX, startY; private static final float SWIPE_THRESHOLD 100; // 滑动判定阈值 Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX event.getX(); startY event.getY(); return true; case MotionEvent.ACTION_UP: float endX event.getX(); float endY event.getY(); handleSwipe(startX, startY, endX, endY); return true; } return super.onTouchEvent(event); } private void handleSwipe(float startX, float startY, float endX, float endY) { float dx endX - startX; float dy endY - startY; boolean isHorizontal Math.abs(dx) Math.abs(dy); if (isHorizontal) { if (Math.abs(dx) SWIPE_THRESHOLD) { if (dx 0 swipeListener ! null) { swipeListener.onSwipeRight(); } else if (dx 0 swipeListener ! null) { swipeListener.onSwipeLeft(); } } } else { if (Math.abs(dy) SWIPE_THRESHOLD) { if (dy 0 swipeListener ! null) { swipeListener.onSwipeDown(); } else if (dy 0 swipeListener ! null) { swipeListener.onSwipeUp(); } } } } }绘制与交互细节onSizeChanged是计算绘制参数的关键位置。根据View的实际宽高动态计算cellSize能保证在不同尺寸屏幕上的自适应。getCellColor方法为不同数字匹配了不同的背景色这是提升游戏视觉效果最直接的方式。颜色值可以参考原版2048或自行设计。绘制文字时centerY的计算使用了(textPaint.ascent() textPaint.descent()) / 2来让文字在垂直方向真正居中这是很多新手容易忽略的细节。触摸事件处理采用了简单的“按下点”和“抬起点”计算差值来判断方向。SWIPE_THRESHOLD阈值用于避免误触只有滑动距离超过这个值才被认定为有效滑动。更复杂的可以用GestureDetector但对于这个游戏简单方法足够稳定。3.3 控制器Activity的串联与业务逻辑MainActivity扮演Controller角色它将Model和View粘合起来并处理游戏流程。import android.app.AlertDialog; import android.content.DialogInterface; import android.content.SharedPreferences; import android.os.Bundle; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; public class MainActivity extends AppCompatActivity implements GameView.OnSwipeListener { private GameModel gameModel; private GameView gameView; private TextView tvScore, tvBestScore; private SharedPreferences prefs; private static final String PREFS_NAME GamePrefs; private static final String KEY_BEST_SCORE best_score; private static final String KEY_BOARD saved_board; private static final String KEY_SCORE saved_score; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); tvScore findViewById(R.id.tv_score); tvBestScore findViewById(R.id.tv_best_score); gameView findViewById(R.id.game_view); prefs getSharedPreferences(PREFS_NAME, MODE_PRIVATE); int bestScore prefs.getInt(KEY_BEST_SCORE, 0); gameModel new GameModel(); gameModel.setBestScore(bestScore); gameView.setGameModel(gameModel); gameView.setOnSwipeListener(this); findViewById(R.id.btn_new_game).setOnClickListener(v - newGame()); findViewById(R.id.btn_undo).setOnClickListener(v - undo()); // 撤销功能需要Model扩展 updateUI(); checkSavedState(); } private void checkSavedState() { // 可选从SharedPreferences读取上次保存的游戏状态实现“继续游戏”功能 String savedBoard prefs.getString(KEY_BOARD, null); int savedScore prefs.getInt(KEY_SCORE, -1); if (savedBoard ! null savedScore ! -1) { // 解析保存的棋盘字符串恢复游戏状态 // 这里省略具体解析代码提示用户是否继续 } } Override public void onSwipeLeft() { handleMove(Direction.LEFT); } Override public void onSwipeRight() { handleMove(Direction.RIGHT); } Override public void onSwipeUp() { handleMove(Direction.UP); } Override public void onSwipeDown() { handleMove(Direction.DOWN); } private enum Direction { LEFT, RIGHT, UP, DOWN } private void handleMove(Direction dir) { boolean moved false; switch (dir) { case LEFT: moved gameModel.moveLeft(); break; case RIGHT: moved gameModel.moveRight(); break; case UP: moved gameModel.moveUp(); break; case DOWN: moved gameModel.moveDown(); break; } if (moved) { updateUI(); checkGameState(); saveCurrentState(); // 每次移动后保存状态 } } private void updateUI() { tvScore.setText(分数: gameModel.getScore()); int best Math.max(gameModel.getScore(), gameModel.getBestScore()); tvBestScore.setText(最高分: best); gameView.invalidate(); // 通知GameView重绘 } private void checkGameState() { if (gameModel.isWin()) { new AlertDialog.Builder(this) .setTitle(恭喜) .setMessage(你成功合出了2048要继续挑战更高分吗) .setPositiveButton(继续, null) .setNegativeButton(新游戏, (dialog, which) - newGame()) .show(); } else if (gameModel.isGameOver()) { // 更新最高分 int currentBest gameModel.getBestScore(); if (gameModel.getScore() currentBest) { currentBest gameModel.getScore(); gameModel.setBestScore(currentBest); prefs.edit().putInt(KEY_BEST_SCORE, currentBest).apply(); } new AlertDialog.Builder(this) .setTitle(游戏结束) .setMessage(游戏结束最终分数: gameModel.getScore()) .setPositiveButton(再来一局, (dialog, which) - newGame()) .setCancelable(false) .show(); } } private void newGame() { gameModel.initGame(); updateUI(); // 清除保存的状态 prefs.edit().remove(KEY_BOARD).remove(KEY_SCORE).apply(); } private void saveCurrentState() { // 将当前棋盘和分数保存到SharedPreferences实现“断点续玩” // 可以将棋盘二维数组转换成字符串如0,2,0,4,2,0,...存储 // 这里省略具体转换和存储代码 SharedPreferences.Editor editor prefs.edit(); // editor.putString(KEY_BOARD, boardToString(gameModel.getBoard())); editor.putInt(KEY_SCORE, gameModel.getScore()); editor.apply(); } private void undo() { // 撤销功能需要在GameModel中维护一个历史状态栈Stack // 每次移动前将当前棋盘和分数压栈 // 撤销时从栈顶弹出状态并恢复 // 这是扩展功能此处不展开但强烈建议作为练习实现 } Override protected void onPause() { super.onPause(); saveCurrentState(); // 应用退到后台时自动保存 } }Activity的核心职责与技巧状态保存与恢复这是提升用户体验的关键。onPause()中调用saveCurrentState()可以在游戏中途接电话或切换应用后回来还能继续。恢复逻辑在onCreate()或onResume()中处理。最高分持久化使用SharedPreferences存储bestScore非常简单有效。UI更新updateUI()方法集中更新分数文本和请求View重绘保持逻辑清晰。游戏状态检查checkGameState()在每次有效移动后被调用用于弹出胜利或失败对话框。注意胜利后可以选择继续游戏。扩展性预留了undo()方法和保存棋盘状态的注释这些都是可以让你的大作业脱颖而出的加分项。3.4 布局文件与资源一个简单的activity_main.xml布局文件。?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:padding16dp android:background#FAF8EF LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:gravitycenter TextView android:idid/tv_score android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text分数: 0 android:textSize24sp android:gravitycenter android:background#BBADA0 android:textColor#F9F6F2 android:padding12dp/ TextView android:idid/tv_best_score android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text最高分: 0 android:textSize24sp android:gravitycenter android:background#BBADA0 android:textColor#F9F6F2 android:padding12dp android:layout_marginStart16dp/ /LinearLayout Button android:idid/btn_new_game android:layout_widthmatch_parent android:layout_heightwrap_content android:text新游戏 android:layout_marginTop16dp android:textSize18sp/ !-- 撤销按钮可选 -- Button android:idid/btn_undo android:layout_widthmatch_parent android:layout_heightwrap_content android:text撤销 android:layout_marginTop8dp android:textSize18sp/ !-- 我们的自定义游戏视图 -- com.yourpackage.GameView android:idid/game_view android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:layout_marginTop16dp/ /LinearLayout布局要点给GameView设置layout_weight1可以让它在分数栏和按钮下方占据剩余的所有空间自适应不同屏幕高度。背景色和数字颜色都采用了接近原版2048的色调保持美观。4. 进阶优化与功能扩展一个基础版本完成后你可以从以下几个方面进行优化和扩展这能让你的项目从“及格”走向“优秀”。4.1 添加平滑动画目前的版本数字是“瞬间”出现在新位置的。添加移动和合并动画能极大提升体验。思路在GameModel的移动方法中不再只返回是否移动而是返回一个MoveInfo列表记录每个数字从哪个位置(from)移动或合并到哪个位置(to)以及是否是合并(merged)。GameView在onDraw时根据这些信息为每个正在移动的格子计算当前帧的偏移量进行绘制。简化实现一个更取巧的方法是在GameView中为每个格子位置维护一个“目标值”。当Model数据更新后View对比新旧棋盘计算出哪些格子是新增的、哪些是移动的。然后使用ValueAnimator在几百毫秒的时间内将格子的“绘制位置”从旧坐标渐变到新坐标同时将新增格子的透明度从0渐变到1或者伴随一个缩放动画。4.2 实现撤销功能这是一个非常实用的功能也是考察你对数据状态管理的理解。实现方案在GameModel中增加一个StackGameState或List作为历史记录栈。GameState是一个保存了某时刻棋盘、分数、是否可撤销等信息的类。在每次执行moveLeft()等操作之前将当前的棋盘、分数深拷贝一份压入栈中。注意初始化游戏和开始新游戏时要清空栈。在MainActivity中点击撤销按钮时调用GameModel的undo()方法。该方法从栈中弹出上一个状态如果栈不为空并用其恢复当前的棋盘和分数。在GameView中可以通过一个标志位在绘制时区分正常格子和刚刚通过撤销恢复的格子例如加一个淡入效果。4.3 本地数据持久化进阶除了最高分我们还可以保存完整的游戏状态saveCurrentState方法中注释的部分实现“继续游戏”。实现方案将4x4的棋盘二维数组转换成一个长度为16的字符串用逗号分隔。例如棋盘[[2,0,4,0], [0,2,0,8], ...]转换成2,0,4,0,0,2,0,8,...。保存时将这个字符串和当前分数一起存入SharedPreferences。游戏启动时读取如果存在就解析字符串还原棋盘并提示用户是否继续。4.4 音效与震动反馈虽然是小功能但能显著增加游戏的沉浸感。音效准备几个简短的Ogg或Mp3文件滑动声、合并声、游戏结束声。使用SoundPool或MediaPlayer在相应动作发生时播放。震动在合并出大数字如512、1024或游戏结束时调用Vibrator服务提供短暂的震动反馈。注意要在AndroidManifest.xml中声明权限uses-permission android:nameandroid.permission.VIBRATE /。5. 常见问题排查与调试技巧在开发过程中你肯定会遇到各种问题。这里记录一些典型问题和解决方法。5.1 游戏逻辑问题问题现象可能原因排查与解决滑动后数字不合并或合并错误合并算法逻辑有误特别是合并后未跳过已合并的格子。在moveLeft等方法中打印每一步操作后的棋盘状态使用Android Studio的Debug功能单步跟踪算法执行过程。重点关注removeZeros和合并循环的逻辑。游戏无法正确结束checkGameOver()函数逻辑不完整只检查了空位没检查相邻可合并。仔细检查checkGameOver方法确保它遍历了每个格子并检查其右侧和下方的格子是否相等因为遍历是从上到下、从左到右检查这两个方向即可覆盖所有相邻情况。新数字总是出现在固定位置addRandomTile()中随机数生成或空位列表获取有误。检查emptyCells列表是否正确收集了所有值为0的格子坐标。确保random.nextInt(emptyCells.size())的随机范围正确。可以在添加后打印新数字的位置来验证。5.2 界面显示问题问题现象可能原因排查与解决游戏棋盘显示不全或太大GameView的onSizeChanged或onDraw中计算cellSize的公式有误。在onSizeChanged中打印计算出的cellSize和View的宽高w,h。确保计算时考虑了gridPadding。使用布局检查器查看GameView的实际尺寸。数字不居中或显示被裁剪绘制文字时centerX和centerY计算错误或textPaint的TextSize相对于cellSize过大。调整textPaint.setTextSize(cellSize * 0.5f)中的系数。使用Paint.getTextBounds()获取文字的实际宽高进行更精确的居中计算。触摸滑动不灵敏或误触发SWIPE_THRESHOLD阈值设置不合理或触摸事件处理逻辑有误。适当调整SWIPE_THRESHOLD的值如从100改为50或150。在handleSwipe方法中打印dx, dy的值观察滑动距离是否正常。5.3 性能与内存问题问题快速滑动时感觉卡顿。排查这通常不是算法问题因为4x4棋盘计算量极小。卡顿很可能来自GameView的onDraw方法。解决避免在onDraw中创建对象像Paint、RectF、Path等对象应该在View的构造函数或init方法中初始化并复用而不是在每次onDraw时new出来。减少无效重绘确保只在棋盘状态确实改变时调用gameView.invalidate()而不是在MainActivity的updateUI中无脑调用虽然我们目前是这样做的但影响不大。更精细的控制可以结合动画状态。简化绘制getCellColor中的switch语句很快不用担心。但如果为每个格子绘制复杂的渐变或阴影可能会成为瓶颈。5.4 Android Studio调试技巧Logcat是好朋友在关键位置使用Log.d(“TAG”, “message”)打印日志是追踪程序流和变量值最直接的方法。善用断点调试在怀疑有问题的代码行左侧点击设置断点以Debug模式运行应用。当程序执行到断点时会暂停你可以查看此时所有变量的值并单步执行F8或步入函数F7这是排查逻辑错误的神器。布局检查器Layout Inspector如果UI显示不正常在Tools - Layout Inspector中可以看到视图树的层级和每个View的详细属性位置、大小对于解决布局问题非常直观。模拟不同的屏幕尺寸和方向在AVD Manager中创建不同尺寸和分辨率的模拟器测试你的游戏布局是否都能正常适配。开发这样一个完整的项目从设计到实现再到调试和优化是一个系统工程。它锻炼的不仅仅是编码能力更是解决问题的思维、对框架的理解和耐心。当你最终看到自己亲手打造的游戏流畅运行并且可以自豪地展示给同学或朋友时那种成就感是无与伦比的。希望这篇超详细的指南能成为你开发路上的得力助手祝你编码愉快大作业取得高分本文还有配套的精品资源点击获取
返回列表