开头先说个结论:俄罗斯方块这类游戏,玩法规则几十年没变过,真正拉开体验差距的,恰恰是“消行”这个瞬间的处理。我最近在做一个基于 Flutter 的 OpenHarmony 游戏集合 App,把俄罗斯方块作为其中一个模块接入,花了最多心思的就是消行效果——从判定、下落补偿到闪光动画和震动反馈。这个过程里踩了不少坑,也验证了一些比较靠谱的做法。如果你也想在 OpenHarmony 设备上用 Flutter 做游戏模块,尤其是想做俄罗斯方块这类有“瞬间反馈”的小游戏,这篇文章应该能帮你少走一段弯路。
这类小游戏看起来逻辑简单,但只要你开始做,就会发现有大量细节需要处理:棋盘怎么建模、消行以后上面的方块怎么下落、动画怎么做才能不僵硬、手势怎么和方向键配合、渲染层怎么避免每帧重建整个界面。我下面会按照从底层数据到画面呈现的顺序,完整拆解一遍我的实现思路和代码方案。
1. 为什么游戏集合 App 选择了 Flutter 来承载 OpenHarmony 模块
1.1 Flutter 在 OpenHarmony 平台上的落地现状
先明确一个前提:我们现在说的 Flutter for OpenHarmony,不是把 Google 官方的 Flutter 直接拉到 OpenHarmony 上跑,而是基于 OpenHarmony 的适配版本,社区和生态已经有比较完整的落地方案,支持 Dart 层 API 和大部分渲染能力。我在开发时用的是 Flutter 的 OpenHarmony 分支,在 DevEco Studio 里创建原生工程后,通过安装依赖的方式把 Flutter 库引进去,然后在原生工程里加载 Flutter 页面,Dart 代码跑在 Flutter 引擎里,渲染层通过 Skia 绘制到 OpenHarmony 的 Surface 上。这套链路的好处是,我能用同一套 Dart 逻辑同时维护后续其他平台的版本,不至于被单一系统绑死。
但要注意,OpenHarmony 适配分支和标准 Flutter 在部分插件上仍有差异,比如一些系统能力插件在 ohos 目录下才有实现。如果某天你在集成某个第三方包时发现编译报错,先别急着怀疑自己的代码,很大可能是这个包根本没有提供 OpenHarmony 端实现,此时只能自己写平台通道或者换一个能力等效的包。
1.2 游戏集合 App 的模块划分与工程组织
既然做的是游戏集合 App,就不是只塞一个俄罗斯方块进去,而是需要在同一个应用里放下多款小游戏的入口、共用页面框架和统一的数据层。所以我采用了单工程多模块的结构:
- 壳工程负责启动页、游戏列表、全局设置和主题切换。
- 游戏模块各自独立,用
Module目录区分,俄罗斯方块放在tetris模块中。 - 公共层放音频播放、震动反馈、数据存储等通用能力,通过接口注入到各游戏模块。
这样的组织方式有一个很直接的好处:俄罗斯方块的内部状态可以完全封闭,不需要和主界面耦合。它有自己独立的 GamePage、自己的状态管理类、自己的渲染组件。当用户从游戏列表点击“俄罗斯方块”时,壳工程只需要通过路由跳转到 TetrisGamePage,剩下的逻辑全部由这个页面内部负责。后续想加扫雷、贪吃蛇,也是同样的套路,每款游戏自己带一套状态机和渲染方案,老模块不会因为新模块的加入而回归。
1.3 为什么把“消行效果”当成单独的攻坚点
行,俄罗斯方块谁都会写,但大多数人的版本,消行就是整行删除,然后上面的格子瞬间往下掉,画面非常生硬。对这个 App 来说,用户期待的是一款手感细腻、看起来舒服的休闲游戏,如果消行动画稀碎,用户大概率会直接划走。
所以我把消行效果拆成了三层目标:
- 逻辑层:准确判定哪些行被填满,并计算出消除后的下落位移。
- 渲染层:在行消除瞬间播放闪光和逐渐消失的动画,剩余方块再执行平滑下落。
- 反馈层:配合动画触发短震动和音效,让手指和眼睛都能“感知”到这行已经被消掉了。
这个拆法看起来平平无奇,但实现时每一层都有讲究。尤其渲染层,如果你用普通 Widget 列表去刷新棋盘,动画一起来就是灾难。这也是我后面放弃一套方案,改用 CustomPaint 的原因,下面逐段细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 棋盘与方块状态:所有消行效果的地基
2.1 用二维数组管好 20×10 的棋盘
俄罗斯方块的标准棋盘是 10 列 × 20 行,我用一个 List<List<int>> 来表示:行从下往上编号,board[row][col] 为 0 表示空,非 0 表示有方块。方块颜色则用变量存储。为什么要用二维数组而不是直接用一个对象集合?因为消行判定是典型的按行扫描操作,二维数组可以用下标直接定位每一行,遍历成本极低,而且消行后的数据搬移也只需要做整行替换,不会出现对象残留问题。
棋盘初始化的代码如下:
dart复制class TetrisBoard {
static const int rows = 20;
static const int cols = 10;
late List<List<int>> grid;
TetrisBoard() {
grid = List.generate(
rows,
(_) => List<int>.filled(cols, 0),
);
}
bool isRowFull(int row) {
return grid[row].every((cell) => cell != 0);
}
List<int> getFullRows() {
List<int> fullRows = [];
for (int r = 0; r < rows; r++) {
if (isRowFull(r)) fullRows.add(r);
}
return fullRows;
}
}
这套数据结构会贯穿游戏全过程:新方块生成时根据方块形状写入棋盘上方,方块下落时只是改变当前活动方块的坐标,只有方块落定后才会刷进 grid。换句话说,grid 里存放的是“已经落定”的方块,而不是正在移动的活动方块。
2.2 方块数据与碰撞检测
每个方块我定义成 Tetromino 类,里面保存形状矩阵、当前坐标、旋转角度:
dart复制class Tetromino {
final List<List<int>> shape;
int row;
int col;
int rotation;
Tetromino(this.shape, {this.row = 0, this.col = 3, this.rotation = 0});
List<List<int>> get currentShape {
// 根据 rotation 旋转 shape 得到当前朝向的矩阵
}
bool collides(TetrisBoard board) {
final shape = currentShape;
for (int r = 0; r < shape.length; r++) {
for (int c = 0; c < shape[0].length; c++) {
if (shape[r][c] == 0) continue;
final boardRow = row + r;
final boardCol = col + c;
if (boardCol < 0 ||
boardCol >= board.cols ||
boardRow >= board.rows) {
return true;
}
if (boardRow >= 0 && board.grid[boardRow][boardCol] != 0) {
return true;
}
}
}
return false;
}
}
碰撞检测的核心逻辑就是拿方块矩阵和棋盘格逐个对齐,方块有值的格子必须落在棋盘范围内,且对应棋盘格为空。这个函数会被用在移动、旋转、下落三个动作里:每次尝试改变坐标前都先复制一份新坐标,执行 collides 判断,不碰撞才真正应用。实践里这点非常重要,一定要避免先移动再回滚,因为动画进行时数据一致性很容易出问题。
2.3 从底层数据到消行判定
方块落定后,会执行一次落盘操作:把活动方块的矩阵值写入 board.grid 对应位置。然后调用 getFullRows() 扫描出所有满行。这时候有两个选择:一是拿到所有满行后一次性消除,二是逐行消除。我建议一次性消除,原因是多行消除时下落补偿必须统一计算,逐行消除会因为先行后行的顺序不同产生位移累积偏差。
落盘并检测的代码大致是:
dart复制void lockTetromino(Tetromino tetro) {
final shape = tetro.currentShape;
for (int r = 0; r < shape.length; r++) {
for (int c = 0; c < shape[0].length; c++) {
if (shape[r][c] != 0) {
final boardRow = tetro.row + r;
final boardCol = tetro.col + c;
if (boardRow >= 0 && boardRow < rows) {
board.grid[boardRow][boardCol] = shape[r][c];
}
}
}
}
final fullRows = board.getFullRows();
if (fullRows.isNotEmpty) {
clearRows(fullRows);
}
}
3. 消行逻辑的完整实现:判定、删除与下落补偿
3.1 消行判定的核心写法
上节已经提到了 getFullRows(),这里给出更完整的消行与删行实现:
dart复制void clearRows(List<int> rowsToClear) {
for (final row in rowsToClear) {
board.grid.removeAt(row);
board.grid.insert(0, List<int>.filled(board.cols, 0));
}
}
这个写法的思路很直接:满行直接从二维数组里移除,然后在顶部补一行全 0。底下的所有行自然往下沉,不需要手动做循环搬移。用 removeAt(row) 加 insert(0, ...) 的组合,等效于把该行上面的所有数据往下挪一位,但代码简洁得多。
有个细节要认真处理:如果一次消除了第 4 行和第 8 行,removeAt 会改变后续行下标。比如先删第 4 行,原来的第 8 行会变成第 7 行,如果直接拿原数组继续删除第 8 行,就会删错。稳妥的办法是先对 rowsToClear 排序,按从大到小的顺序删除,这样删除高位行不会影响低位行的下标;或者像我这样,直接在循环里不断 removeAt 和 insert,让容器自己维护索引。
3.2 下落补偿计算:消除多行时的位移
消行动画需要让“非满行”的格子平滑落下。假设消除了第 3 行、第 7 行、第 12 行,那么第 13 行及以上的所有行都要依次向下掉 3 格,而 8~11 行向下掉 2 格,4~6 行向下掉 1 格。这个位移不是统一值,而是“该行下方有多少个被消除的行”。
计算逻辑:
dart复制Map<int, int> computeDropOffsets(List<int> clearedRows) {
Map<int, int> offsets = {};
for (int r = 0; r < board.rows; r++) {
if (clearedRows.contains(r)) continue;
int offset = 0;
for (final cr in clearedRows) {
if (cr < r) offset++;
}
offsets[r] = offset;
}
return offsets;
}
这个 offset 表是动画的输入参数。渲染时我不需要真的去修改 board.grid,而是先保持一个“消除前”的静态瞬间,然后根据 offset 计算出每个格子的目标位置,再由动画插值把它从旧位置平滑挪到新位置。等动画结束后,才把 grid 真正更新成消除后的状态。这个“先动画后落库”的顺序,能避免动画过程中数据被移动、触碰等操作打断。
3.3 消除行数的组合策略与得分规则
俄罗斯方块的得分规则可以简单也可以复杂。我采用了一个经典计分策略:一次消 1 行得 100 分,消 2 行得 300 分,消 3 行得 500 分,消 4 行得 800 分,并且消行数越高,下落速度会适当提升。由于动作快的用户会连续消行,我用了一个组合计数:在 5 秒内连续发生消除,每次的得分额外附加 50×连续次数。这样做的好处是增加了一点刺激感,同时避免用户只靠一个动作刷分。
游戏的速度调节由 level 驱动,level 每获得 1000 分提升一级,下落间隔从 800ms 降低到 650ms、500ms,最低到 200ms。注意在 T 转消除等极限操作时,速度过快会导致操作反馈不及时,所以我给每局设置了 4 次锁定延迟,也就是方块落到底后仍可以继续移动一小段时间,方便玩家做最后的微调。
4. 渲染与动画:把“消行”做出存在感
4.1 用 CustomPaint 实现棋盘与方块的统一渲染
我一开始尝试用 Positioned + Container 来渲染每个格子,每个方块一个 Widget。当棋盘状态发生变化时,为了同步更新,我不得不重建大量 Widget,动画帧率一度掉到 30 帧左右,在 OpenHarmony 真机上体验很差。
后来我把整个棋盘渲染切换到 CustomPaint 方案:在 CustomPainter 里用 canvas.drawRect 一次性画出所有格子、活动方块、幽灵块和消行动画层。这样做的好处非常明显:
- 减少 Widget 树节点数,渲染引擎不需要做大量 diff。
- 所有格子颜色和位置都从同一帧数据计算,不存在多 Widget 之间的同步延迟。
- 动画期间可以通过 repaint 机制驱动画布局部更新,性能开销稳定。
棋盘绘制核心代码如下:
dart复制class BoardPainter extends CustomPainter {
final TetrisBoard board;
final Tetromino? active;
final double cellSize;
final Map<int, int> dropOffsets;
final double animProgress;
BoardPainter({
required this.board,
required this.active,
required this.cellSize,
required this.dropOffsets,
required this.animProgress,
}) : super(repaint: animController);
@override
void paint(Canvas canvas, Size size) {
// 绘制落定方块
for (int r = 0; r < board.rows; r++) {
for (int c = 0; c < board.cols; c++) {
if (board.grid[r][c] == 0) continue;
final offset = dropOffsets[r] ?? 0;
final top = (size.height - (r + 1) * cellSize) - offset * cellSize * animProgress;
final left = c * cellSize;
canvas.drawRect(
Rect.fromLTWH(left, top, cellSize, cellSize),
Paint()..color = blockColors[board.grid[r][c]],
);
}
}
// 绘制活动方块
if (active != null) {
final shape = active!.currentShape;
for (int r = 0; r < shape.length; r++) {
for (int c = 0; c < shape[0].length; c++) {
if (shape[r][c] == 0) continue;
final top = (size.height - (active!.row + r + 1) * cellSize);
final left = (active!.col + c) * cellSize;
canvas.drawRect(
Rect.fromLTWH(left, top, cellSize, cellSize),
Paint()..color = blockColors[active!.shapeValue],
);
}
}
}
}
@override
bool shouldRepaint(covariant BoardPainter oldDelegate) {
return true;
}
}
这里有两个关键点。第一,格子位置使用的是“行从下往上”的坐标换算,也就是 top = 画布高度 - (行号 + 1) × 单元格大小,这样逻辑层用数组下标从 0 走到 19 时,视觉上是从上到下,但底层坐标不需要反向处理。第二,我在 BoardPainter 中加入了 dropOffsets 和 animProgress,这样每次动画进度变化都会触发 repaint,而不需要重建 Widget。
4.2 消行动画的实现思路:先闪光,再塌缩
消行动画我分两个阶段实现。第一阶段是“闪光”:满行格子在 120ms 内由亮白色到原色渐变闪烁,同时整行微微横向扩张 3 像素再回弹;第二阶段是“塌缩”:行内容逐渐淡出,剩余方块按照 3.2 节算出的 offset 向下平滑移动。两段动画由同一个 AnimationController 顺序驱动,只是在进度划分上不同。
实现上,我增加了一个 RowAnimationLayer,它在 BoardPainter 里额外绘制:
dart复制void paintFlashRow(Canvas canvas, int row, double progress) {
final rect = Rect.fromLTWH(
flashExpand * (1 - progress),
(size.height - (row + 1) * cellSize),
cols * cellSize - flashExpand * 2 * (1 - progress),
cellSize,
);
canvas.drawRect(
rect,
Paint()
..color = Color.lerp(Colors.white, blockColors[flashColor], progress)
..style = PaintingStyle.fill,
);
}
这段代码里的 flashExpand 是横向扩张量,我用 1 - progress 来产生“先扩张、后回缩”的效果。实际调参时发现,扩张幅度不能超过 6 像素,否则格子之间会露出缝隙,但也不能没有,否则闪光会显得死板。我个人最终定在 4 像素左右。
“塌缩”阶段则不一样,它不再绘制被消除行的格子,而是让所有需要下落的格子根据 animProgress 线性插值移动。这里的动画曲线我选择了 Curves.easeOutCubic,开始快、结尾慢,符合“下落+顿住”的物理直觉。如果使用线性曲线,整个画面会显得机械感很强。
4.3 动画曲线与时间控制的调参心得
动画时间控制在 180ms 到 250ms 之间比较合适。太短了用户还没看清发生了什么就结束了,太长了下落感和节奏感都会被打断。我最终把闪光阶段定为 120ms、塌缩阶段为 130ms,整体 250ms。听起来不长,但在连续操作时,这个时长刚好能让玩家感知、又不会拖后腿。
这里有一个容易忽略的问题:动画运行期间,新方块不能立刻生成。因为棋盘数据虽然在动画结束后才更新,但视觉上已经在“移动+淡出”了,如果此时新方块开始下落,会出现新旧方块重叠的错觉。我在状态机里加了一个 isClearing 标志,动画期间禁止生成新方块、禁止输入移动和旋转,只允许玩家看到画面播放。250ms 的等待换来的视觉一致性非常值。
5. 输入手感与游戏节奏的控制
5.1 触控、方向键与滑动手势的结合
俄罗斯方块在手机上的输入方案无非三种:屏幕上的虚拟按键、方向键、滑动手势。我在游戏集合 App 里同时支持了虚拟按键和滑动手势,因为不同玩家习惯不一样。虚拟按键区放在棋盘下方,四个方向键加一个旋转键,按键较大、间距合理,避免误触。
滑动手势则是用 GestureDetector 监听垂直和水平方向的拖动距离:水平拖动超过 24px 触发左移或右移,垂直向上滑动触发旋转,垂直向下滑动触发硬降。这里有个细节:判断手势方向时要看“较长的一次拖动”,而不是“第一个动作”,否则在斜向拖动时会频繁误触发。
dart复制double startDx = 0;
double startDy = 0;
onPanStart: (details) {
startDx = details.delta.dx;
startDy = details.delta.dy;
},
onPanUpdate: (details) {
startDx += details.delta.dx;
startDy += details.delta.dy;
if (startDy.abs() > startDx.abs()) {
if (startDy > 24) hardDrop();
else if (startDy < -24) rotate();
} else {
if (startDx > 24) moveLeft();
else if (startDx < -24) moveRight();
}
},
实际用下来,手势和虚拟按键同时保留是最稳妥的。有些用户在快速冲刺时喜欢连续按按键,有些用户则全靠滑动。需要注意的是,硬降手势的阈值不能设太低,否则正常的下落操作会被误判成硬降。我最后把硬降阈值定在屏幕高度的六分之一,这样竖滑稍微用力一点才会触发。
5.2 软降、硬降与锁定延迟的节奏设计
游戏的核心节奏三要素是软降速度、硬降和锁定延迟。软降速度由 level 决定,但每次按键触发的单步下落也会影响节奏。我采用的机制是:基于计时器驱动 Timer.periodic,每 dropInterval 毫秒让当前方块自动下落一格;而玩家手动按“下”键时,会让下一次自动下落提前触发。
硬降的实现很简单:把活动方块直接移动到能下落的最底格,并立刻触发落盘检查和消行动画。硬降虽然看起来是一瞬间,但我会先做一个 60ms 的快速下落动画,给玩家一点“唰”的感觉,而不是直接瞬移。锁定延迟主要配合旋转和左右微调用,避免玩家在方块贴地时无法调整。
还有个手感细节:方块左右移动时,我每次移动之间保留 80ms 最小间隔,防止按住按键时出现“抖动式连移”。旋转则不设间隔,因为单次旋转误触风险不大,反而需要更快响应。
5.3 音效、震动与画面反馈的联动
消行效果不只是视觉上的,听觉和触觉反馈同样重要。我在 OpenHarmony 设备上通过 Fly 系统的震动服务接口触发了时长 60ms 的短震,强度中等,只在消除发生时震动一次;如果是连续消 4 行(Tetris 满分操作),会连续触发两次 60ms 震动,中间隔 30ms,做出“更强反馈”的感觉。
音效方面用的是打包在 assets 里的短音效文件,消 1 行一个音高,消 2 行音高上升,消 4 行使用完全不同的和弦音,通过这种音调变化让玩家在闭眼时也能感知到当前消除规模。
反馈和动画之间的同步是门学问。音效应该在“闪光阶段”开始时播放,震动则在“塌缩阶段”开始的瞬间触发,因为视觉上此时玩家的注意力已经集中在“下落”上。如果音效和视觉错位 50ms 以上,玩家会明显感觉到延迟。
6. 常见问题与性能优化实录
6.1 掉帧与卡顿排查
我在 OpenHarmony 真机上遇到过的掉帧原因主要有三个。
第一个是 Widget 数量爆炸。早期用 Container 渲染 200 个格子时,性能很差。后来切换到 CustomPaint 后立竿见影,掉帧问题基本消失。第二个是动画绘制期间频繁触发 repaint,由于我直接在 BoardPainter 结构里引用了 AnimationController 作为 repaint 信号,进度每次变化都会重绘整块画布,在 200 格子规模下完全可接受,但如果你的棋盘尺寸更大、或者同时开了多个动画,就需要考虑局部脏矩形重绘。
第三个隐藏问题是垃圾回收卡顿。消行动画结束后我会创建大量临时对象(如 offset 表、绘制用的 Paint 对象)。Dart 的垃圾回收触发时如果恰好在动画播放中途,会出现一帧卡顿。我通过复用 Paint 对象、把临时坐标系计算抽离到 painter 外部来缓解。这也解释了为什么我把 dropOffsets 提前计算并传给 painter,而不是在 paint 方法里每次计算。
6.2 布局错位与分辨率适配
不同 OpenHarmony 设备屏幕宽高比差异很大,棋盘是 10:20 的比例,如果直接按比例缩放画面,有的设备上会留白,有的会被裁切。我的方案是:先根据屏幕可用宽度计算单元格尺寸 cellSize = (screenWidth - 2 * horizontalPadding) / cols,然后用这个 cellSize 推导棋盘高度。当棋盘高度超出可用高度时,再按高度反推单元格尺寸,保证棋盘完整显示。
横向的按键区也要随之调整。按键高度取 cellSize * 2.5,这样棋盘和按键始终能维持比较协调的比例。真机测试中,最大的坑是系统状态栏高度不一致,导致棋盘顶部被状态栏遮挡。我在布局时通过 MediaQuery.of(context).padding.top 动态预留了顶部安全区,避免这个问题。
6.3 一套经验清单
- 消行判定用“先取满行集合、再统一清除”的方案,不要逐行删除。
- 动画期间禁止新方块生成和输入操作,保证数据一致。
- offset 表只计算到动画开始时,动画过程中不再更新,避免数据抖动。
- 活动方块不写入棋盘,始终单独维护,碰撞检测前先复制坐标再判断。
- 板子的渲染层和逻辑层分离,逻辑层用
Grid、渲染层用CustomPaint,这样测试和视觉调整互不影响。 - 每次消行动画结束时才更新
board.grid,并且立刻重新计算一次getFullRows(),防止极端情况下动画没结束又有新方块落定产生逻辑错乱。
这套经验是我在开发这个游戏集合 App 过程中,从早期不断出现视觉错位和数据不同步的版本里慢慢提炼出来的,对我的项目帮助很大。
打个比方,消行效果就像乐高积木被抽走底层那一排时,整座塔需要缓慢下沉,而不是瞬间坍塌。如果你玩过那种品质感比较好的俄罗斯方块,就会明白这个微妙的沉降动画有多重要。Flutter 的动画体系完全可以做出这种质感,关键在于把逻辑层和渲染层彻底解耦,再把反馈做得跟动画同频。
最后分享一个小技巧:在做消行闪光动画时,不要只对格子本身做颜色变化,可以连棋盘背景的网格线一起闪一下,也就是整条消行区域的背光短暂亮起。这个效果实现成本极低,但视觉高级感提升非常明显,你可以在自己的实现里试试。
