从零实现Android俄罗斯方块:数据结构、碰撞检测与交互设计

发布时间:2026/9/21 1:14:06
从零实现Android俄罗斯方块:数据结构、碰撞检测与交互设计 简介面向安卓开发初学者和游戏编程爱好者这份原创资源以经典俄罗斯方块为实战案例完整讲解在安卓平台上从项目搭建、界面设计、图形绘制到方块生成、移动旋转、碰撞检测、消行计分与状态保存的实现要点。压缩包共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的开发模式里什么叫做线程、生命周期、事件分发和状态管理。如果你正在找练手项目认真的把这个方块游戏从数据结构写到手感调优收获会远超你的预期。本文还有配套的精品资源点击获取