Android Studio实现俄罗斯方块:从矩阵建模到游戏循环的完整指南
简介一款基于Android Studio开发的俄罗斯方块游戏项目面向安卓初学者与课程设计人群解决入门练习缺少完整可运行示例的问题。压缩包共1228个文件约13.08MB主要包含Java源码、XML界面布局、Gradle构建配置、资源图片以及可直接安装的APK工程结构完整便于导入开发环境运行与调试。已有541人学习浏览适合作为安卓程序设计课程设计或期末项目的参考模板。代码实现了经典方块的移动、旋转、快速下落、消行与计分等核心机制并覆盖7种方块形状与随机生成逻辑可帮助初学者理解游戏循环、事件监听和Canvas绘制等关键知识点同时支持在此基础上扩展暂停、难度调整等功能。1. 用 Android Studio 做俄罗斯方块为什么是安卓课设的最稳选择如果你正在找安卓课程设计或者期末大作业的题目俄罗斯方块可能看起来简单实际上的实现深度却非常合适。逻辑上它要处理 7 种方块的建模、旋转、碰撞检测、行消除、等级加速和最高分存储界面上它要完成网格绘制、触摸手势识别和物理键盘响应工程上它还需要管理 Activity 生命周期和 UI 线程调度。把这些都串起来正好覆盖了一款小游戏的全部关键环节。我建议用原生 Android Studio 配合 Java 来完成不引额外的游戏引擎。原因很实际课程答辩时老师最关注的是程序结构而不是你用了多炫的库。自绘 View 加上 Handler 驱动的游戏循环几乎不依赖第三方依赖出问题也好定位。而且代码量控制在 1500 行以内小白拿到源码看得懂、改得动。下面这篇文章会从数据建模开始一步步带你拆到触摸事件和持久化存档。你可以把它当作课设的参考答案也可以当成一个完整 Android 小游戏的拆解范例。2. 方块数据建模4x4 矩阵、旋转与碰撞检测的数学处理2.1 7 种标准方块的矩阵定义先解决 “一块积木怎么存” 的问题。俄罗斯方块所有拼块都恰好由 4 个小方块组成所以一个很自然的做法是用 4x4 的布尔矩阵或 int 矩阵来表示每种形状1 代表有方块0 代表空。选择 4x4 而不是 3x3是为了迁就 I 型方块——它在水平状态下占据 4 列如果矩阵宽度不够旋转时数据就会溢出。在实际代码里我会把这些形状直接定义成一个枚举这样后续的随机生成、颜色绑定和旋转方法都可以挂到这个类型上。下面是一个常见的定义方式public enum Tetromino { I(new int[][]{ {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, 0x00E5FFFF), // 青色 O(new int[][]{ {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, 0x00FFFF00), // 黄色 T(new int[][]{ {0,0,0,0}, {0,1,0,0}, {1,1,1,0}, {0,0,0,0} }, 0x00AA66CC), // 紫色 L(new int[][]{ {0,0,0,0}, {0,0,1,0}, {0,1,1,1}, {0,0,0,0} }, 0x00FF7F00), // 橙色 J(new int[][]{ {0,0,0,0}, {0,1,0,0}, {0,1,1,1}, {0,0,0,0} }, 0x003366FF), // 蓝色 S(new int[][]{ {0,0,0,0}, {0,1,1,0}, {1,1,0,0}, {0,0,0,0} }, 0x0066FF33), // 绿色 Z(new int[][]{ {0,0,0,0}, {1,1,0,0}, {0,1,1,0}, {0,0,0,0} }, 0x00FF0000); // 红色 public final int[][] shape; public final int color; Tetromino(int[][] shape, int color) { this.shape shape; this.color color; } }这段代码里的整数颜色用的是 ARGB 格式可以直接传给 Android 的Paint。强调一点矩阵必须用二维数组的深层复制不能直接赋值引用否则旋转方法会修改到枚举的原始数据。常用的做法是在方块生成时调用clone()或者手动复制矩阵。下面用表格把 7 种方块的外观、颜色和旋转注意点列出来方便对照排查。这张表在写绘制代码时非常有用。方块形状说明颜色旋转时的注意点I一条直线青色旋转后宽度从1变4需要检查左右边界O正方形黄色旋转 4 次外观不变可以直接跳过旋转T十字去掉一个角紫色旋转中心在中间点附近L左边竖条加底部横条橙色旋转后中心偏移需要配合踢墙逻辑J右边竖条加底部横条蓝色与 L 镜像碰撞检测时方向相反S上短下长向右斜绿色旋转后不能简单平移需要计算新矩阵Z上长下短向左斜红色与 S 相反注意边界清除2.2 旋转算法先转置后逆序矩阵旋转是这个项目里最容易出错的地方。顺时针旋转可以用一个经典公式新矩阵的dst[j][n-1-i]等于旧矩阵的src[i][j]。对于 4x4 矩阵来说就是嵌套循环重新排列。我用一个独立的方法返回旋转后的临时矩阵而不是直接修改当前矩阵这样在判断“旋转后是否碰撞”时会更安全。public int[][] rotateCW(int[][] src) { int n 4; int[][] dst new int[n][n]; for (int i 0; i n; i) { for (int j 0; j n; j) { dst[j][n - 1 - i] src[i][j]; } } return dst; }逻辑说明假设源矩阵中第 i 行第 j 列的元素旋转 90 度后应该落在第 j 行第 n-1-i 列。例如src[1][0]是 I 型方块水平状态下的左起第二个小方块旋转后它会出现在dst[0][2]。这套推导方式对所有 4x4 矩阵统一生效不需要每类方块单独写规则。需要注意的是 O 型方块旋转后结果完全一样但在代码里仍然要让它走这个通用流程否则会引入分支增加测试成本。而 I 型方块旋转后往往紧贴左墙或右墙这时必须配合边界碰撞检测否则会直接数组越界。生产中常见的问题是方块在墙壁边旋转后坐标没有回退导致部分格子穿墙。解决方式是在旋转后调用碰撞检测如果碰撞就把方块重新平移到刚才的位置或者尝试向左偏移 1 格、向右偏移 1 格这也就是所谓“踢墙”的简单版。2.3 碰撞检测矩阵与网格的状态位运算俄罗斯方块的核心是网格grid一般用boolean二维数组表示true表示该格子已经被固定。碰撞检测其实是在移动或旋转之前把当前方块的矩阵套到目标坐标上逐格检查是否超出屏幕或者与已有格子重叠。public boolean canMove(int[][] shape, int newX, int newY, boolean[][] grid) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (shape[i][j] 0) continue; int gx newX j; int gy newY i; // 超出左右边界或者底部 if (gx 0 || gx COLS || gy ROWS) return false; // gy 0 表示方块还在屏幕顶部之上不阻挡 if (gy 0 grid[gy][gx]) return false; } } return true; }参数newX和newY表示方块在网格坐标系里的左上角坐标。shape[i][j]遍历到 0 时直接跳过因为空位置不需要参与碰撞。当gy为负数时说明方块还没有完全进入屏幕这种情况只让下落不让它横向移动所以不能返回false否则一出生就判定 game over。这套接口最大的优点是控制移动和旋转用的是同一套逻辑。左移调用canMove(shape, x-1, y, grid)右移调用canMove(shape, x1, y, grid)快速下落调用canMove(shape, x, y1, grid)。我在实际项目中还会把这个方法提为一个统一入口boolean move(int dx, int dy)内部先计算目标坐标再检查碰撞最后更新坐标并触发重绘。这样后续处理键盘和触摸事件时只调move(0, 1)或move(-1, 0)即可不用再关心网格细节。3. 游戏循环设计Handler 延迟消息与 Canvas 绘制的协作3.1 单线程模型下为什么不能 sleep新手最容易犯的错误是在主线程写while (true) { blockMoveDown(); Thread.sleep(500); }。Android 的 UI 线程一旦被 sleep 阻塞系统在几秒内检测不到消息响应就会弹出 ANR 对话框。俄罗斯方块这种需要持续刷新的小游戏不能用sleep来驱动而应该用消息队列的延迟投递机制让Handler每隔固定时间发送一条改状态的消息。关于计时方案我整理了一张对比表进课设答辩时可以用它来讲设计取舍。方案是否阻塞 UI是否可取消回调是否在 UI 线程适用场景Handler.postDelayed否可是本项目采用结构简单Choreographer.postFrameCallback否可是帧率要求高需要随时间驱动重绘TimerTask否可否需手动 post不推荐容易包一层线程Thread.sleep 配合 runOnUiThread是难通过切换线程实现课程演示但生产不推荐Choreographer虽然更平滑但它是跟屏幕刷新率绑定的每帧都会回调你需要自己计算上一帧到现在是否超过下落间隔这比写 Handler 要复杂。俄罗方块这种每秒最多下移几格的游戏用Handler完全够用还容易控制速度曲线。3.2 用 Handler 模拟重力下落的计时器游戏循环我一般拆成三步定时触发、逻辑更新、绘制刷新。其中定时触发用Handler的postDelayed(Runnable, delay)逻辑更新在 Runnable 里执行最后调用invalidate()让 Android 系统在下一帧重新调用onDraw()。关键代码并不长本质上是自己调度自己public class GameEngine implements Runnable { private final Handler handler new Handler(Looper.getMainLooper()); private final GameView view; private int downMs 500; public void startGame() { handler.post(this); } public void stopGame() { handler.removeCallbacks(this); } Override public void run() { // 当前方块向下移动一格 boolean moved moveDown(); if (!moved) { lockCurrentBlock(); // 无法再下降固定到网格 clearFullLines(); // 检查并消除满行 spawnNextBlock(); // 生成下一个方块 if (isGameOver()) { stopGame(); return; } } view.invalidate(); // 刷新 UI handler.postDelayed(this, downMs); } }逻辑说明每次run()被调用就表示一个“游戏 tick”过去了当前方块先尝试下落失败则锁定并消行然后再安排下一次postDelayed。downMs是从外部传入的下落间隔等级越高这个值越小。这里要注意handler.postDelayed(this, downMs)必须放在run()的结尾如果放在startGame()里只会在启动时执行一次。Android 系统中Handler的回调对象Runnable不需要手工销毁但要在 Activity 或 View 的onDetachedFromWindow()里调用removeCallbacks否则退出界面后它仍然在消息队列里会导致内存泄漏或者重复绘制。这一点在答辩时经常被追问请务必记得写上。3.3 消行检测与分数计算的具体实现消行的逻辑要从网格底部往上扫。找到一整行所有格子都为true后把这行以上的数据整体往下移动一行然后在顶部填上false。循环处理时有个小陷阱如果处理完一行后y手动回退一位就不会漏掉连续多行一起消除的情况。这里我直接传回消除的行数方便做积分。private int clearFullLines() { int cleared 0; for (int y ROWS - 1; y 0; y--) { boolean full true; for (int x 0; x COLS; x) { if (!grid[y][x]) { full false; break; } } if (full) { cleared; // 上方所有行整体下移一行 for (int ny y; ny 0; ny--) { System.arraycopy(grid[ny - 1], 0, grid[ny], 0, COLS); } Arrays.fill(grid[0], false); y; // 当前行从上方补了数据需要重新检查 } } score cleared * 100; return cleared; }参数和细节说明grid是boolean[ROWS][COLS]下标 0 是顶部ROWS-1是底部。System.arraycopy是移动整行最高效的方法比循环逐格复制快得多。分数计算直接用了最原始的单行 100 分实际上你可以根据消除行数叠加比如一次消四行给 800 分这能鼓励玩家堆竖条。这个函数每次只在方块锁定后调用不需要放在绘制线程里因此不会造成 UI 卡顿。4. 输入事件分发滑动、旋转按钮与物理键盘在同一套逻辑里4.1 自定义 View 中的 onTouchEvent 手势识别俄罗斯方块的安卓版输入基本上有三种来源触摸滑动、屏幕上的按钮点击、物理键盘方向键。由于游戏逻辑全部收敛在GameEngine里输入层只需要把动作翻译成moveLeft()、moveRight()、softDrop()、rotate()这四种命令。触摸屏上我使用自定义的GameView在onTouchEvent里通过两根手指按下点的坐标差来判断滑动方向。下面是一段简单但可靠的手势识别代码Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: lastTouchX event.getX(); lastTouchY event.getY(); return true; case MotionEvent.ACTION_UP: float dx event.getX() - lastTouchX; float dy event.getY() - lastTouchY; if (Math.abs(dx) 30 Math.abs(dy) 30) { engine.rotate(); // 单击 旋转 } else if (Math.abs(dx) Math.abs(dy)) { engine.move(dx 0 ? RIGHT : LEFT); // 横向滑动 } else if (dy 0) { engine.softDrop(); // 下滑加速下落 } return true; } return super.onTouchEvent(event); }逻辑说明这里使用了getActionMasked()而不是getAction()因为多点触控时getAction()会混入触点索引容易判断错。滑动阈值 30 像素是我在真机上试出来的平衡值太大会导致三四个格子距离的滑动没反应太小则单击变得异常难触发。上下左右判定时先比较绝对值再做方向判断避免对角线滑动被误判。滑块和软键盘在这个架构里是并列的输入源。建议把所有动作调用统一成engine.move(int direction)和engine.rotate()两个公开方法这样后面接蓝牙游戏手柄或者遥控器时不需要改引擎层。4.2 方块边界修正考虑状态栏和 View 的 padding自定义 View 的坐标系有点反直觉如果不处理 padding那么方块画出来会贴着重绘区域的左上角坐标在非全屏模式上刚好躲开状态栏但在全屏模式下状态栏透明背景会遮住顶部的方块。稳妥的做法是给GameView预留一个topOffset等于状态栏高度加上工具栏高度。int statusBarHeight 0; int resourceId getResources().getIdentifier(status_bar_height, dimen, android); if (resourceId 0) { statusBarHeight getResources().getDimensionPixelSize(resourceId); }参数说明getIdentifier是 Android 系统的内部资源查找方式它并不可靠如果在 OPhone 或定制系统上找不到这个资源返回值就是 0。另一种做法是在布局文件里给GameView设置android:paddingTop16dp然后在绘制时统一加上getPaddingTop()。我在实际项目里更推荐后者因为你不需要关心具体设备的状态栏像素值系统会按密度自动换算。网格尺寸的计算建议放在onSizeChanged里避免每次onDraw重复计算。每个小格的边长等于(getHeight() - getPaddingTop() - getPaddingBottom()) / ROWS再用这个边长来计算方块的左边缘和上边缘偏移量确保绘制时方块始终完整落在网格内。4.3 用接口解耦 UI 层与游戏逻辑层我一般会把游戏逻辑封装成一个独立的GameEngine不持有任何 View 引用而是通过回调接口把分数变化、游戏结束通知抛给 Activity 或 Fragment。这里的接口只需要定义三个方法就够用public interface OnGameEventListener { void onScoreChanged(int score); void onLevelChanged(int level); void onGameOver(int score); }逻辑说明GameEngine内部持有这个接口引用消行后调用listener.onScoreChanged(score)游戏结束时调用listener.onGameOver(score)。这个设计的价值在于如果最后你想把俄罗斯方块移植到 Wear OS 手表上只需要实现一个新的 View 和一个新的事件监听游戏逻辑完全不用动。下面用表格把常见的输入源和对应处理方式整理清楚方便你对照自己的实现做接线。输入源监听方式动作映射触摸滑动onTouchEvent左滑、右滑、下滑触摸单击onTouchEvent旋转屏幕按钮Button.setOnClickListener左移、右移、旋转、加速物理键盘onKeyDown方向键控制移动和旋转空格键硬降物理键盘的onKeyDown通常在 Activity 里处理比如按下KeyEvent.KEYCODE_DPAD_LEFT时调用gameView.getEngine().move(GameEngine.LEFT)。要注意的是重新注册 Activity 里的onKeyDown时确保默认软键盘不会弹出来否则方向键会导致输入法弹起游戏界面被挤压。5. 做得更完整一点等级加速、最高分持久化与回归测试5.1 等级与下落间隔的曲线设定俄罗斯方块的“成瘾感”很大程度来自下落速度的递增。我建议把下落间隔定义为随分数单调递减的函数而不是分档级联判断。最简单的实现是让等级level score / 1000 1间隔毫秒数为Math.max(80, 600 - (level - 1) * 40)。下限 80 毫秒保证人还能反应否则程序跑得比画面还快体验反而很差。private int computeDownMs(int level) { return Math.max(80, 600 - (level - 1) * 40); }参数说明600 是初始下落间隔等级每升一级减去 40 毫秒80 是最低间隔。上面这段写成了纯函数方便单元测试。在你的项目里可以在clearFullLines()加分后立即调用一次然后把新间隔传给Handler注意要先removeCallbacks(this)再重新postDelayed否则旧的延迟任务还会继续触发。5.2 SharedPreferences 保存最高分最高分不需要存数据库用SharedPreferences足够。它适合小规模键值对读取速度快代码简单。存分数的时机应该在GameEngine抛出onGameOver事件后因为此时用户已经看到了最终得分。SharedPreferences prefs getSharedPreferences(tetris_score, MODE_PRIVATE); int highScore prefs.getInt(high_score, 0); if (score highScore) { prefs.edit().putInt(high_score, score).apply(); }这里用apply()而不是commit()原因是apply()是异步写入不会阻塞主线程适合游戏结算这种非关键路径。唯一需要注意的是 进程被强杀的时候写入可能没落盘但这是所有异步写法的共同问题课程设计里可以接受。5.3 几个关键场景的验证技巧最后分享几个我在调试这类项目时常用的自测方法这些技巧比直接跑一次模拟器更精准。第一测试旋转边界时把网格宽度临时改成 4 看 I 型方块是否越界。具体做法是构造一个只包含 I 方块的测试场景让方块贴左墙调用rotate()然后打印旋转后的矩阵和canMove返回值。如果返回false但画面里方块没有弹回基本可以确定是碰撞检测函数的坐标参数传错了。第二测试连续消行时可以手动向网格填充接近满行的数据Arrays.fill(grid[10], true);并留一个洞然后启动游戏看消行计数是否正确。这个操作在onCreate里临时写两行代码即可验证完删掉。第三触摸测试一定要在真机上跑模拟器上的触摸事件和真实屏幕存在密度差异。我习惯先打印event.getX()和网格边界的换算结果把触摸点坐标和方块坐标并排打出来一旦发现手指和方块明显错位优先检查cellSize计算时是否忽略了padding或导航栏高度。最后加速和积分这两个功能最容易出现闪断问题。我建议把score、level和downMs全部输出到一个常量值里每次消行后打一次日志连续消四行的场景重点观察分数是否叠加到了 800。做完这些验证整套俄罗斯方块项目的稳定性就有保证了。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →