简介:面向安卓开发初学者和游戏编程爱好者,这份原创资源以经典俄罗斯方块为实战案例,完整讲解在安卓平台上从项目搭建、界面设计、图形绘制,到方块生成、移动旋转、碰撞检测、消行计分与状态保存的实现要点。压缩包共52个文件、约1.22MB,其中包含8个Java源文件、16个class字节码、8个XML布局与配置、9张PNG图片资源,另附可直接安装的APK文件和Android支持库JAR,目录结构清晰,便于对照源码逐步理解。已有498人学习下载,适合希望通过小游戏项目巩固原生开发能力的开发者。项目提供完整工程文件、布局资源和可运行APK,既能直接运行体验,也能在调试中掌握Handler定时刷新、触屏与按键事件、得分等级系统、音效反馈等关键实现;配合源码和资源清单,可较容易地把这套逻辑迁移到其他方块类游戏或面试作品中。 很多人第一眼看到“Android俄罗斯方块”这个项目,会觉得这不过是个练手的小玩意——方块下落、旋转、消行,逻辑简单,网上随便一搜就是几百篇教程。但我自己动手做下来才发现,真正把这套逻辑写成能玩、能扩展、界面还不崩的App,里面值得讲的东西远比想象中多。这篇文章就用我实际开发的Android俄罗斯方块为例,从数据结构、游戏循环、碰撞检测到交互体验,完整梳理一遍从零到跑通的实现路径。如果你正在学Android开发,或者想拿一个经典小游戏练手,这篇内容应该能帮你少走不少弯路。
我最早用Android Studio写这个项目时,第一版代码全部堆在Activity里,方块、棋盘、绘制、计分全搅在一起,改一个旋转的bug要翻三四个方法,后来实在忍不了,重构了一遍才把逻辑理顺。所以这篇文章会特别强调“怎么把代码写得不烂”这件事,而不只是“怎么让方块落下来”。
1. 先想清楚:一个“能玩的俄罗斯方块”到底包含哪些事
俄罗斯方块的规则人人都懂,但“懂规则”和“能实现”之间隔着一大段设计工作。动手前把整个系统拆开看,后面写代码才会顺畅。
把功能拆开,你会发现一局完整的俄罗斯方块至少要处理六件事:
- 棋盘的存储与绘制:用什么样的数据结构表示10×20的方阵,怎么把数据画到屏幕上。
- 方块的定义与生成:7种标准方块怎么表示,每种方块的旋转结果如何计算。
- 核心动作:下落、左移、右移、旋转、硬降、软降,每个动作都要先做碰撞检测再做实际位移。
- 消行与计分:满足什么条件消行,一次消多行怎么计分,等级如何提升。
- 状态流转:准备、运行、暂停、结束这四个状态怎么切换,防止玩家在游戏结束时还能继续操作。
- 交互方式:手指怎么控制方向、旋转和速降,这套交互必须和游戏引擎松耦合。
这六件事里,新手最容易栽跟头的是第一件和第三件。棋盘存不好,后面所有逻辑都别扭;碰撞检测写不好,方块会穿墙、重叠、甚至直接卡死。
从技术选型上说,这个项目我推荐用Canvas在SurfaceView上绘制,而不是用自定义View加xml布局。原因是游戏画面每帧都在变化,SurfaceView能在子线程里持续刷新,绘制逻辑更清晰;自定义View虽然也能做,但涉及到postInvalidate的调用时机,多线程下容易出问题。
整个项目的包结构,我建议分成三层:
- game核心逻辑层:负责棋盘数据、方块形状、碰撞检测、消行计分,不依赖任何Android UI代码。
- 渲染层:负责把game层的数据画到屏幕上,包括当前方块、已固定方块、网格背景。
- 交互层:负责捕获触摸事件,把用户的滑动、点击转换成具体的动作指令。
这个分层是“原创”这个标题下最值得参照的设计。核心逻辑独立出来后,你甚至不需要Android环境就能用JUnit测试碰撞检测和消行算法,这一点在后续调试时帮了我大忙。
2. 棋盘、方块与旋转:数据结构定生死
我在第一版里用了一个ArrayList来存棋盘数据结果,写起来是方便,但每次判断某一行是否满了都要遍历,而且边界判断特别容易出错。后来老老实实改成二维数组,整个逻辑一下就清楚了很多。
棋盘我定义为全局常量:10列、20行,用int[][] board = new int[20][10]保存,值0表示空格,非0表示该位置已被填上某种颜色的方块。为什么是20行而不是22行?因为经典俄罗斯方块的可见区域是20行,上半部分不用多留空间。这个尺寸在后续绘制和碰撞检测里都能直接和屏幕宽高做换算,省掉很多麻烦。
七种方块的定义,我用相对坐标数组来做。每种方块由4个单元格组成,用一个二维数组记录四个方块在4×4矩阵里的坐标位置。这个表达方式说起来有点抽象,我直接上代码:
public class Tetromino { public static final int[][][] SHAPES = { // I形 {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, // O形 {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, // T形 {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, // S形 {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, // Z形 {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, // J形 {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, // L形 {{2, 0}, {0, 1}, {1, 1}, {2, 1}} }; }这套坐标的取法其实有讲究。每个方块都放在一个4×4的虚拟矩阵里,坐标中的第一个数字是列、第二个数字是行,这样旋转公式计算起来才统一。O形方块不旋转,其余六种旋转时都以4×4矩阵的中心为轴转动90度。
我第一次写旋转时,是给每种方块手动定义四个旋转状态的坐标数组,结果代码冗长不说,新加一个方块就要重新维护四个数组,极其痛苦。后来改用旋转公式统一处理,代码量直接少了一大半。旋转公式的核心是:把坐标(x, y)绕4×4矩阵中心旋转90度,新坐标等于(y, 3 - x),顺时针旋转则用(3 - y, x)。
这里有一个特别容易踩的坑:旋转公式算出来后,方块可能会越界或撞到已固定的方块。比如I形方块靠在左墙,旋转后有两格跑到棋盘外面去了。如果直接旋转,游戏当场就崩了。所以旋转操作必须先算出旋转后的新坐标,再做碰撞检测,不合法就保持原始方向,而不是原地强转。这个“先试算再落子”的思路,几乎所有格子类游戏都适用。
3. Android里的“下落”到底是怎么驱动的
写完数据结构,下一个核心问题是:方块怎么定时往下掉?很多Android教程会教你用Thread.sleep()写个死循环,或者直接在while(true)里让方块坐标加一。这两个做法在Android里都会翻车——UI线程一旦被死循环卡住,界面直接假死,屏幕上的方块永远不会刷新。
正确的做法是用Handler配合postDelayed()来做定时任务。每次方块下落一格,就通过Handler再次预约下一次下落;等游戏暂停或结束时,把之前预约的Message清掉。这样既能控制下落间隔,又不会阻塞UI线程。
private final Handler gameHandler = new Handler(Looper.getMainLooper()); private final Runnable tickRunnable = new Runnable() { @Override public void run() { tick(); // 尝试让当前方块下落一格 long interval = getSpeedByLevel(level); gameHandler.postDelayed(this, interval); } }; public void start() { gameHandler.removeCallbacks(tickRunnable); gameHandler.postDelayed(tickRunnable, INITIAL_INTERVAL); }关于下落速度的计算,我用了这个公式:interval = Math.max(150, 800 - (level - 1) * 70)。等级越高,间隔越短,但设一个150毫秒的下限,防止后期方块落得快到肉眼完全跟不上。初始速度800毫秒一格,差不多是经典版本的手感,不会太快也不会让玩家等得无聊。
这里还有一个容易漏掉的小细节:当玩家触发硬降(方块直接到底)时,tick()里会先尝试一次性把方块降到最低,紧接着就要立刻生成新方块、检查新方块能不能摆放。如果新方块和已固定的格子重叠,直接切到Game Over状态。这个判断不做,游戏会在失败后继续“下落”,画面上出现一个幽灵方块,玩家点任何按钮都没反应,体验很差。
另外,我用的渲染类是SurfaceView加SurfaceHolder.Callback。在surfaceDestroyed回调里千万别忘了gameHandler.removeCallbacksAndMessages(null),否则Activity销毁后Handler还在不停post消息,轻则内存泄漏,重则一退出游戏就闪退。这个坑在我调试时出现过,后来排查才意识到是线程里的回调没清干净。
4. 碰撞检测与消行:这套“游戏物理”其实没你想的复杂
俄罗斯方块的碰撞检测,本质上就一句话:移动之前先问问目标位置能不能去,能去才动。代码实现上,我把它收敛成一个统一的判断函数,所有动作都复用它。
public boolean canMove(int[][][] shape, int rotation, int newX, int newY) { int[][] points = getRotatedPoints(shape, rotation); for (int[] p : points) { int x = newX + p[0]; int y = newY + p[1]; if (x < 0 || x >= COLS || y >= ROWS) return false; if (y >= 0 && board[y][x] != 0) return false; } return true; }注意这个判断里的两个要点:第一,左右边界和底边界必须同时判断,漏掉任何一个,方块就会出现“镶”在墙里的现象。第二,如果目标是已固定的方格就直接拒绝,否则消行后残留的部分会和新方块叠在一起,视觉上像穿模。我第一次写的时候漏了y >= 0的判断,导致方块在棋盘顶部时,会把负坐标也算成合法位置,然后数组越界崩溃。
当方块不能再下落时,就需要把它固定到棋盘上。固定操作就是把当前方块的格子坐标写进board数组,然后立刻执行消行检查。消行的逻辑也很直白:从最后一行往上遍历,只要某一行全部非0,就把这一行以上的所有行整体下移一行,行数加一,再重新检查当前行。用代码写出来是这样的:
private void clearFullRows() { for (int y = ROWS - 1; y >= 0; y--) { boolean full = true; for (int x = 0; x < COLS; x++) { if (board[y][x] == 0) { full = false; break; } } if (full) { for (int yy = y; yy > 0; yy--) { board[yy] = Arrays.copyOf(board[yy - 1], COLS); } board[0] = new int[COLS]; // 顶部补一行空行 y++; // 重新检查当前行,防止漏掉连续消行 } } }这里的y++特别关键。如果不加这一句,一次消掉两行到三行时,下面的行会漏检查,结果分数只加了一张面板的行数。我调试时发现消行后剩下的行没完全合并,就是漏了这句话。
计分规则我用的是经典算法:一次消1行得100分,2行得300分,3行得500分,4行得800分。这个倍数关系比线性加分更能激励玩家叠方块,也更贴近老街机的手感。每消10行升1级,等级升的时候用“整行清空+分数刷新”一起处理,给玩家一个明显的正反馈。
5. 触控交互与游戏状态:让玩家玩得顺手,代码还不会乱
用户能玩得爽,靠的是交互设计。我在这个项目里采用的交互方案是:
- 左右滑动:方块水平移动一格
- 下拉:方块加速下落(软降)
- 单击:方块顺时针旋转
- 长按:方块持续硬降(直接落底)
这个映射关系基本是手机俄罗斯方块的通用标准。实现上,我用GestureDetector或onTouchEvent里的ACTION_DOWN和ACTION_UP来计算位移。滑动判断的核心是取水平和竖直位移的绝对值做比较:如果你横向滑动的距离大于纵向距离,就根据横向偏移方向左右移动;否则根据纵向偏移方向决定软降或硬降。
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: downX = event.getX(); downY = event.getY(); downTime = System.currentTimeMillis(); return true; case MotionEvent.ACTION_UP: float dx = event.getX() - downX; float dy = event.getY() - downY; long duration = System.currentTimeMillis() - downTime; if (Math.abs(dx) > SLOP && Math.abs(dx) > Math.abs(dy)) { engine.move(dx > 0 ? 1 : -1); } else if (Math.abs(dy) > SLOP) { engine.softDrop(); } else if (duration < 300) { engine.rotate(); } else { engine.hardDrop(); } return true; } return super.onTouchEvent(event); }这段逻辑里的SLOP阈值我设成了屏幕宽度的1/8,太小容易误触,太大又显得迟钝。单击和长按的区分用的是300毫秒标准,不超过300毫秒算点击,超过就算长按速降。这个数值其实可以参考用户的习惯微调,但300毫秒实测下来比较平衡。
除了交互,游戏状态的管理也绝对不能乱。我用一个枚举管理状态:READY、RUNNING、PAUSED、OVER。只有RUNNING状态下才接受移动和旋转指令,OVER状态下任何触摸都直接忽略。暂停按钮点击时,gameHandler里的回调全部移除;恢复时重新启动postDelayed。没有这套状态机,会出现“游戏已经结束了还能消行”的诡异情况,我早期版本就出过这种逻辑错乱。
界面上,我用了一个横向的RelativeLayout布局,左边是游戏主区域,右边竖排显示下一个方块、得分、等级和暂停按钮。下一个方块的预览直接复用同一个绘制逻辑,只是把方块画到一个缩小的Canvas上。这样既简洁又不需要维护两套绘制代码。
6. 调试之路:旋转方向算反、连消失败、穿墙——这些坑我一个个说
实话说,这个项目的大部分代码我一天就写完了,但调试和修bug反而花了两三天。挑几个印象最深的坑,给正准备动手的你提个醒。
第一个坑是旋转方向反了。我最初用的顺时针旋转公式是(3 - y, x),结果实际表现变成了逆时针。原因是Android的屏幕坐标系y轴是向下增长的,和数学上常见的坐标系正好相反。现象就是你按旋转按钮,方块往反方向转。解决办法其实很简单:把公式换成(y, 3 - x)。但如果你没意识到坐标轴方向的问题,会在代码里反复试错很久。
第二个坑是消行后数组索引错位。上面提到的y++那个点,如果漏写,连续两组消行只处理了一组,余下的满行会“卡”在棋盘里。排查方法也很直观:在消行函数前后各打一个日志,把棋盘用字符串输出,你会发现第二组满行明明还在数组里,游戏却已经计分了。
第三个坑是方块卡在某一行原地抖动。问题出在软降:玩家一直按着下拉,每次tick都会执行canMove检测,一旦检测失败,方块既没有固定到棋盘,也没有触发新方块生成,就变成原地抽搐。修复方法是:软降检测到无法下落时,立刻调用固定函数,而不是继续等下一帧。
这里我推荐一个非常实用的调试方法:在日志里用字符画打印棋盘。把每个格子转成0或1,按行拼成String,用Log打印出来。这个方法对碰撞检测和消行逻辑特别有效,比打一堆断点看得清楚得多。
private void dumpBoard() { StringBuilder sb = new StringBuilder(); for (int y = 0; y < ROWS; y++) { for (int x = 0; x < COLS; x++) { sb.append(board[y][x] == 0 ? "." : "#"); } sb.append("\n"); } Log.d("Tetris", sb.toString()); }我几乎每次修改逻辑后都会调用一次dumpBoard,看到棋盘格局和预期一致才继续往下写。这个方法也适用于其他类型的网格类游戏,比如扫雷、连连看,值得养成习惯。
最后再说一个关于性能的体会。很多人担心Canvas逐帧重绘会卡,但俄罗斯方块的画面其实非常简单,整个棋盘就20行10列,加上一两个活动方块,重绘开销完全可以忽略。真正要关心的不是绘制性能,而是不要让Handler的定时回调在异常情况下反复执行。入口函数里先removeCallbacks再postDelayed,这个习惯比任何绘制的性能优化都重要。
这是我这次开发过程中收获最大的一点:表面看是一个“画格子”的小游戏,实际写下来,你会重新认识Android的开发模式里,什么叫做线程、生命周期、事件分发和状态管理。如果你正在找练手项目,认真的把这个方块游戏从数据结构写到手感调优,收获会远超你的预期。
本文还有配套的精品资源,点击获取