news 2026/9/21 1:13:53

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现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状态。这个判断不做,游戏会在失败后继续“下落”,画面上出现一个幽灵方块,玩家点任何按钮都没反应,体验很差。

另外,我用的渲染类是SurfaceViewSurfaceHolder.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. 触控交互与游戏状态:让玩家玩得顺手,代码还不会乱

用户能玩得爽,靠的是交互设计。我在这个项目里采用的交互方案是:

  • 左右滑动:方块水平移动一格
  • 下拉:方块加速下落(软降)
  • 单击:方块顺时针旋转
  • 长按:方块持续硬降(直接落底)

这个映射关系基本是手机俄罗斯方块的通用标准。实现上,我用GestureDetectoronTouchEvent里的ACTION_DOWNACTION_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毫秒实测下来比较平衡。

除了交互,游戏状态的管理也绝对不能乱。我用一个枚举管理状态:READYRUNNINGPAUSEDOVER。只有RUNNING状态下才接受移动和旋转指令,OVER状态下任何触摸都直接忽略。暂停按钮点击时,gameHandler里的回调全部移除;恢复时重新启动postDelayed。没有这套状态机,会出现“游戏已经结束了还能消行”的诡异情况,我早期版本就出过这种逻辑错乱。

界面上,我用了一个横向的RelativeLayout布局,左边是游戏主区域,右边竖排显示下一个方块、得分、等级和暂停按钮。下一个方块的预览直接复用同一个绘制逻辑,只是把方块画到一个缩小的Canvas上。这样既简洁又不需要维护两套绘制代码。

6. 调试之路:旋转方向算反、连消失败、穿墙——这些坑我一个个说

实话说,这个项目的大部分代码我一天就写完了,但调试和修bug反而花了两三天。挑几个印象最深的坑,给正准备动手的你提个醒。

第一个坑是旋转方向反了。我最初用的顺时针旋转公式是(3 - y, x),结果实际表现变成了逆时针。原因是Android的屏幕坐标系y轴是向下增长的,和数学上常见的坐标系正好相反。现象就是你按旋转按钮,方块往反方向转。解决办法其实很简单:把公式换成(y, 3 - x)。但如果你没意识到坐标轴方向的问题,会在代码里反复试错很久。

第二个坑是消行后数组索引错位。上面提到的y++那个点,如果漏写,连续两组消行只处理了一组,余下的满行会“卡”在棋盘里。排查方法也很直观:在消行函数前后各打一个日志,把棋盘用字符串输出,你会发现第二组满行明明还在数组里,游戏却已经计分了。

第三个坑是方块卡在某一行原地抖动。问题出在软降:玩家一直按着下拉,每次tick都会执行canMove检测,一旦检测失败,方块既没有固定到棋盘,也没有触发新方块生成,就变成原地抽搐。修复方法是:软降检测到无法下落时,立刻调用固定函数,而不是继续等下一帧。

这里我推荐一个非常实用的调试方法:在日志里用字符画打印棋盘。把每个格子转成01,按行拼成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的开发模式里,什么叫做线程、生命周期、事件分发和状态管理。如果你正在找练手项目,认真的把这个方块游戏从数据结构写到手感调优,收获会远超你的预期。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 1:11:01

OpenResearch开放研究指南:从实验记录到可复现协作的完整实践

你要是跟我一样&#xff0c;在很多研究类项目里泡过&#xff0c;大概率见过这种场景&#xff1a;两个团队各自闷头做了半年&#xff0c;最后发现解决的是同一个问题&#xff0c;中间踩过的坑、试错的路径几乎可以一一对上。区别只在于&#xff0c;一个团队把这个过程写成公开的…

作者头像 李华
网站建设 2026/9/21 1:08:32

acme.sh+阿里云DNS实现SSL证书全自动续期实战指南

如果你还在靠日历提醒自己“该续SSL证书了”&#xff0c;那说明你还没被证书过期坑过&#xff0c;或者坑得还不够狠。我入行这几年&#xff0c;见过凌晨三点被用户截图砸醒的运维&#xff0c;也见过因为证书过期被浏览器拦在站点外面、业务直接停摆的团队。后来我把acme.sh和阿…

作者头像 李华
网站建设 2026/9/21 1:08:24

从蓝图到施工:AI工程化落地的四层架构与实战避坑指南

1. 从愿景到图纸&#xff1a;《智能世界2035》到底画了什么1.1 先看清全貌&#xff1a;它不是一栋楼&#xff0c;而是一座城我见过不少团队把AI项目当成装修工程——买几张模型API的“壁纸”往业务墙上一贴&#xff0c;就觉得完成了智能化改造。结果运行三个月&#xff0c;发现…

作者头像 李华
网站建设 2026/9/21 1:06:41

昇腾Atlas 300V推理卡部署YOLO全流程指南与踩坑实录

你搜“atlas”想找的东西&#xff0c;十有八九是昇腾Atlas。我先说结论&#xff1a;Atlas 300V 24G不是训练卡&#xff0c;它是华为昇腾的AI推理加速卡&#xff0c;24G指的是显存容量&#xff0c;很多人把它和训练卡搞混&#xff0c;最后买回去才发现场景不对。这篇文章我就围绕…

作者头像 李华
网站建设 2026/9/21 1:04:35

用Git+符号链接实现Claude Code多机同步:告别换电脑配置全丢

把 Claude Code 当主力工具的这些日子&#xff0c;我最怕的一件事就是换电脑。公司台式机、家里 MacBook、出差用的 Windows 笔记本&#xff0c;四台机器来回切&#xff0c;经常是这台机器上调好的权限规则、写了一半的全局记忆、攒下来的自定义 skills&#xff0c;换到另一台机…

作者头像 李华
网站建设 2026/9/21 1:04:28

OpenResearch 落地实践:文件系统+Git+索引构建可追溯研究知识库

1. 为什么我要认真聊聊 OpenResearch 这件事第一次看到“OpenResearch”这个词&#xff0c;很多人脑子里蹦出来的可能是某个开源社区、某个学术搜索引擎&#xff0c;或者干脆觉得它就是个“开放研究”的口号。我一开始也这么想&#xff0c;直到自己真正动手搭了一套面向团队内部…

作者头像 李华