Android Studio实现俄罗斯方块:从矩阵建模到游戏循环的完整指南
2026/9/16 10:27:47 网站建设 项目流程

简介:一款基于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; }

参数newXnewY表示方块在网格坐标系里的左上角坐标。shape[i][j]遍历到 0 时直接跳过,因为空位置不需要参与碰撞。当gy为负数时说明方块还没有完全进入屏幕,这种情况只让下落,不让它横向移动,所以不能返回false,否则一出生就判定 game over。

这套接口最大的优点是控制移动和旋转用的是同一套逻辑。左移调用canMove(shape, x-1, y, grid),右移调用canMove(shape, x+1, y, grid),快速下落调用canMove(shape, x, y+1, 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 模拟重力下落的计时器

游戏循环我一般拆成三步:定时触发、逻辑更新、绘制刷新。其中定时触发用HandlerpostDelayed(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”过去了,当前方块先尝试下落,失败则锁定并消行,然后再安排下一次postDelayeddownMs是从外部传入的下落间隔,等级越高这个值越小。这里要注意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; }

参数和细节说明:gridboolean[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:paddingTop="16dp",然后在绘制时统一加上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或导航栏高度。

最后,加速和积分这两个功能最容易出现闪断问题。我建议把scoreleveldownMs全部输出到一个常量值里,每次消行后打一次日志,连续消四行的场景重点观察分数是否叠加到了 800。做完这些验证,整套俄罗斯方块项目的稳定性就有保证了。

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

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

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

立即咨询